Show source candidate details before adding
This commit is contained in:
parent
bb4ef36f44
commit
bd177832c0
@ -115,6 +115,8 @@ nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
}
|
||||
|
||||
switch selection {
|
||||
case .sourceCandidate:
|
||||
return []
|
||||
case .source(let sourceID), .allContent(let sourceID):
|
||||
guard sourceID == id else {
|
||||
return []
|
||||
|
||||
@ -7,6 +7,7 @@ import SwiftUI
|
||||
struct ItemDetailColumnView: View {
|
||||
let item: MinecraftContentItem?
|
||||
let source: MinecraftSource?
|
||||
let sourceCandidate: SourceCandidate?
|
||||
let showsSourceDetails: Bool
|
||||
let behaviorPacks: [ContentPackReference]
|
||||
let resourcePacks: [ContentPackReference]
|
||||
@ -22,6 +23,8 @@ struct ItemDetailColumnView: View {
|
||||
let exportAction: () -> Void
|
||||
let revealAction: () -> Void
|
||||
let shareAction: (NSView?) -> Void
|
||||
let addCandidateSourceAction: (SourceCandidate) -> Void
|
||||
let revealCandidateAction: (SourceCandidate) -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@ -46,6 +49,16 @@ struct ItemDetailColumnView: View {
|
||||
)
|
||||
} else if showsSourceDetails, let source {
|
||||
SourceDetailView(source: source)
|
||||
} else if let sourceCandidate {
|
||||
SourceCandidateDetailView(
|
||||
candidate: sourceCandidate,
|
||||
addAction: {
|
||||
addCandidateSourceAction(sourceCandidate)
|
||||
},
|
||||
revealAction: {
|
||||
revealCandidateAction(sourceCandidate)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Text("Select a world or pack to see details")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@ -0,0 +1,156 @@
|
||||
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SourceCandidateDetailView: View {
|
||||
let candidate: SourceCandidate
|
||||
let addAction: () -> Void
|
||||
let revealAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(candidate.displayName)
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
|
||||
Text("Found source candidate")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button("Add Source") {
|
||||
addAction()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Reveal in Finder") {
|
||||
revealAction()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
sourceSection(title: "Overview", rows: overviewRows)
|
||||
sourceSection(title: "Detected Content", rows: contentRows)
|
||||
sourceSection(title: "Location", rows: locationRows)
|
||||
sourceSection(title: "Technical Details", rows: technicalRows)
|
||||
}
|
||||
.frame(maxWidth: 760, alignment: .leading)
|
||||
.padding(28)
|
||||
}
|
||||
}
|
||||
|
||||
private var overviewRows: [(String, String)] {
|
||||
[
|
||||
("Edition", editionLabel),
|
||||
("Provider", providerLabel),
|
||||
("Confidence", confidenceLabel),
|
||||
("Reason", candidate.reason)
|
||||
]
|
||||
}
|
||||
|
||||
private var contentRows: [(String, String)] {
|
||||
guard !candidate.detectedKinds.isEmpty else {
|
||||
return [("Detected Kinds", "None")]
|
||||
}
|
||||
|
||||
return [("Detected Kinds", orderedKindLabels.joined(separator: ", "))]
|
||||
}
|
||||
|
||||
private var locationRows: [(String, String)] {
|
||||
[
|
||||
("Filesystem Path", candidate.sourceRootURL.path)
|
||||
]
|
||||
}
|
||||
|
||||
private var technicalRows: [(String, String)] {
|
||||
[
|
||||
("Provider ID", candidate.providerID),
|
||||
("Candidate ID", candidate.id)
|
||||
]
|
||||
}
|
||||
|
||||
private var editionLabel: String {
|
||||
switch candidate.edition {
|
||||
case .bedrock:
|
||||
return "Bedrock"
|
||||
case .java:
|
||||
return "Java"
|
||||
}
|
||||
}
|
||||
|
||||
private var providerLabel: String {
|
||||
switch candidate.providerID {
|
||||
case JavaLocalFolderSourceAccess().accessorIdentifier:
|
||||
return "Java Local Folder"
|
||||
case LocalFolderSourceAccess().accessorIdentifier:
|
||||
return "Bedrock Local Folder"
|
||||
case AppleMobileDeviceSourceAccess().accessorIdentifier:
|
||||
return "Bedrock iOS Device"
|
||||
default:
|
||||
return candidate.providerID
|
||||
}
|
||||
}
|
||||
|
||||
private var confidenceLabel: String {
|
||||
switch candidate.confidence {
|
||||
case .none:
|
||||
return "None"
|
||||
case .weak:
|
||||
return "Weak"
|
||||
case .medium:
|
||||
return "Medium"
|
||||
case .strong:
|
||||
return "Strong"
|
||||
case .exact:
|
||||
return "Exact"
|
||||
}
|
||||
}
|
||||
|
||||
private var orderedKindLabels: [String] {
|
||||
let orderedKinds: [(MinecraftContentKind, String)] = [
|
||||
(.world, "Worlds"),
|
||||
(.behaviorPack, "Behavior Packs"),
|
||||
(.resourcePack, "Resource Packs"),
|
||||
(.dataPack, "Data Packs"),
|
||||
(.skinPack, "Skin Packs"),
|
||||
(.worldTemplate, "World Templates"),
|
||||
(.shaderPack, "Shader Packs"),
|
||||
(.mod, "Mods")
|
||||
]
|
||||
|
||||
return orderedKinds.compactMap { kind, label in
|
||||
candidate.detectedKinds.contains(kind) ? label : nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(title)
|
||||
.appSectionTitleStyle(.section)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(rows, id: \.0) { title, value in
|
||||
detailRow(title: title, value: value)
|
||||
}
|
||||
}
|
||||
.appDetailSectionCard()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailRow(title: String, value: String) -> some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(title)
|
||||
.appTextStyle(.fieldLabel)
|
||||
.frame(width: 150, alignment: .leading)
|
||||
|
||||
Text(value)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
@ -355,6 +355,7 @@ struct ItemDetailColumnPreviewContainer: View {
|
||||
ItemDetailColumnView(
|
||||
item: PreviewFixtures.featuredWorld,
|
||||
source: PreviewFixtures.primarySource,
|
||||
sourceCandidate: nil,
|
||||
showsSourceDetails: false,
|
||||
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
|
||||
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
|
||||
@ -369,7 +370,9 @@ struct ItemDetailColumnPreviewContainer: View {
|
||||
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
|
||||
exportAction: {},
|
||||
revealAction: {},
|
||||
shareAction: { _ in }
|
||||
shareAction: { _ in },
|
||||
addCandidateSourceAction: { _ in },
|
||||
revealCandidateAction: { _ in }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,6 +45,7 @@ struct ContentView: View {
|
||||
var body: some View {
|
||||
let isEmptyLibrary = library.visibleSources.isEmpty && library.connectedDevices.isEmpty
|
||||
let resolvedCurrentSource = currentSource
|
||||
let resolvedCurrentSourceCandidate = currentSourceCandidate
|
||||
let currentProjectionRequest = ItemCollectionProjectionRequest(
|
||||
selection: selectedSidebarSelection,
|
||||
searchText: searchText,
|
||||
@ -122,6 +123,7 @@ struct ContentView: View {
|
||||
ItemDetailColumnView(
|
||||
item: resolvedCurrentSelectedItem,
|
||||
source: resolvedCurrentSource,
|
||||
sourceCandidate: resolvedCurrentSourceCandidate,
|
||||
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
|
||||
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
||||
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
||||
@ -154,7 +156,9 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
shareItem(item, from: anchorView)
|
||||
}
|
||||
},
|
||||
addCandidateSourceAction: addCandidateSource(_:),
|
||||
revealCandidateAction: revealCandidateInFinder(_:)
|
||||
)
|
||||
.frame(minWidth: 450)
|
||||
}
|
||||
@ -255,6 +259,14 @@ struct ContentView: View {
|
||||
return library.source(withID: sourceID)
|
||||
}
|
||||
|
||||
private var currentSourceCandidate: SourceCandidate? {
|
||||
guard case .sourceCandidate(let candidateID) = selectedSidebarSelection else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return library.sourceCandidates.first { $0.id == candidateID }
|
||||
}
|
||||
|
||||
private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? {
|
||||
guard let selectedItemID else {
|
||||
return nil
|
||||
@ -595,6 +607,10 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func revealCandidateInFinder(_ candidate: SourceCandidate) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([candidate.sourceRootURL])
|
||||
}
|
||||
|
||||
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
|
||||
let fileURLType = UTType.fileURL.identifier
|
||||
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }
|
||||
@ -654,8 +670,18 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func syncSelection(with sourceIDs: [URL]) {
|
||||
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) {
|
||||
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
|
||||
if let selectedSidebarSelection {
|
||||
switch selectedSidebarSelection {
|
||||
case .sourceCandidate(let candidateID):
|
||||
if !library.sourceCandidates.contains(where: { $0.id == candidateID }) {
|
||||
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
|
||||
}
|
||||
case .source, .allContent, .contentType, .contentKind:
|
||||
if let selectedSourceID = selectedSidebarSelection.sourceID,
|
||||
!sourceIDs.contains(selectedSourceID) {
|
||||
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
|
||||
}
|
||||
}
|
||||
} else if self.selectedSidebarSelection == nil, let firstSourceID = sourceIDs.first {
|
||||
self.selectedSidebarSelection = .source(sourceID: firstSourceID)
|
||||
}
|
||||
|
||||
@ -77,6 +77,8 @@ enum ItemCollectionProjector {
|
||||
}
|
||||
|
||||
switch selection {
|
||||
case .sourceCandidate:
|
||||
return "Source Candidate"
|
||||
case .source, .allContent:
|
||||
return "All Items"
|
||||
case .contentType(_, let contentType):
|
||||
@ -88,6 +90,8 @@ enum ItemCollectionProjector {
|
||||
|
||||
nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String {
|
||||
switch selection {
|
||||
case .some(.sourceCandidate):
|
||||
return "Search Library"
|
||||
case .some(.source):
|
||||
return "Search \(source?.displayName ?? "Library")"
|
||||
case .some(.allContent):
|
||||
@ -103,6 +107,8 @@ enum ItemCollectionProjector {
|
||||
|
||||
nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String {
|
||||
switch selection {
|
||||
case .some(.sourceCandidate):
|
||||
return "Source Candidate"
|
||||
case .some(.source):
|
||||
return "Library"
|
||||
case .some(.allContent):
|
||||
@ -122,6 +128,8 @@ enum ItemCollectionProjector {
|
||||
}
|
||||
|
||||
switch selection {
|
||||
case .sourceCandidate:
|
||||
return "items"
|
||||
case .source, .allContent:
|
||||
return scopedItemCount == 1 ? "item" : "items"
|
||||
case .contentType(_, let contentType):
|
||||
|
||||
@ -5,14 +5,17 @@ import SwiftUI
|
||||
|
||||
enum SidebarSelection: Hashable, Sendable {
|
||||
case source(sourceID: URL)
|
||||
case sourceCandidate(candidateID: String)
|
||||
case allContent(sourceID: URL)
|
||||
case contentType(sourceID: URL, contentType: MinecraftContentType)
|
||||
case contentKind(sourceID: URL, contentKind: MinecraftContentKind)
|
||||
|
||||
var sourceID: URL {
|
||||
var sourceID: URL? {
|
||||
switch self {
|
||||
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _), .contentKind(let sourceID, _):
|
||||
return sourceID
|
||||
case .sourceCandidate:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -65,9 +68,16 @@ struct SourcesSidebarView: View {
|
||||
if !sourceCandidates.isEmpty {
|
||||
Section {
|
||||
ForEach(sourceCandidates) { candidate in
|
||||
SourceCandidateRow(candidate: candidate) {
|
||||
addCandidateSourceAction(candidate)
|
||||
}
|
||||
SourceCandidateRow(
|
||||
candidate: candidate,
|
||||
onSelect: {
|
||||
selection = .sourceCandidate(candidateID: candidate.id)
|
||||
},
|
||||
addAction: {
|
||||
addCandidateSourceAction(candidate)
|
||||
}
|
||||
)
|
||||
.tag(SidebarSelection.sourceCandidate(candidateID: candidate.id) as SidebarSelection?)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
}
|
||||
@ -153,6 +163,7 @@ struct SourcesSidebarView: View {
|
||||
|
||||
private struct SourceCandidateRow: View {
|
||||
let candidate: SourceCandidate
|
||||
let onSelect: () -> Void
|
||||
let addAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@ -179,6 +190,8 @@ private struct SourceCandidateRow: View {
|
||||
.buttonStyle(.borderless)
|
||||
.help("Add Source")
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onSelect)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user