Show source candidate details before adding

This commit is contained in:
John Burwell 2026-06-02 14:22:43 -05:00
parent bb4ef36f44
commit bd177832c0
7 changed files with 229 additions and 8 deletions

View File

@ -115,6 +115,8 @@ nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
} }
switch selection { switch selection {
case .sourceCandidate:
return []
case .source(let sourceID), .allContent(let sourceID): case .source(let sourceID), .allContent(let sourceID):
guard sourceID == id else { guard sourceID == id else {
return [] return []

View File

@ -7,6 +7,7 @@ import SwiftUI
struct ItemDetailColumnView: View { struct ItemDetailColumnView: View {
let item: MinecraftContentItem? let item: MinecraftContentItem?
let source: MinecraftSource? let source: MinecraftSource?
let sourceCandidate: SourceCandidate?
let showsSourceDetails: Bool let showsSourceDetails: Bool
let behaviorPacks: [ContentPackReference] let behaviorPacks: [ContentPackReference]
let resourcePacks: [ContentPackReference] let resourcePacks: [ContentPackReference]
@ -22,6 +23,8 @@ struct ItemDetailColumnView: View {
let exportAction: () -> Void let exportAction: () -> Void
let revealAction: () -> Void let revealAction: () -> Void
let shareAction: (NSView?) -> Void let shareAction: (NSView?) -> Void
let addCandidateSourceAction: (SourceCandidate) -> Void
let revealCandidateAction: (SourceCandidate) -> Void
var body: some View { var body: some View {
Group { Group {
@ -46,6 +49,16 @@ struct ItemDetailColumnView: View {
) )
} else if showsSourceDetails, let source { } else if showsSourceDetails, let source {
SourceDetailView(source: source) SourceDetailView(source: source)
} else if let sourceCandidate {
SourceCandidateDetailView(
candidate: sourceCandidate,
addAction: {
addCandidateSourceAction(sourceCandidate)
},
revealAction: {
revealCandidateAction(sourceCandidate)
}
)
} else { } else {
Text("Select a world or pack to see details") Text("Select a world or pack to see details")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)

View File

@ -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)
}
}

View File

@ -355,6 +355,7 @@ struct ItemDetailColumnPreviewContainer: View {
ItemDetailColumnView( ItemDetailColumnView(
item: PreviewFixtures.featuredWorld, item: PreviewFixtures.featuredWorld,
source: PreviewFixtures.primarySource, source: PreviewFixtures.primarySource,
sourceCandidate: nil,
showsSourceDetails: false, showsSourceDetails: false,
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack), behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack), resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
@ -369,7 +370,9 @@ struct ItemDetailColumnPreviewContainer: View {
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle, exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
exportAction: {}, exportAction: {},
revealAction: {}, revealAction: {},
shareAction: { _ in } shareAction: { _ in },
addCandidateSourceAction: { _ in },
revealCandidateAction: { _ in }
) )
} }
} }

View File

@ -45,6 +45,7 @@ struct ContentView: View {
var body: some View { var body: some View {
let isEmptyLibrary = library.visibleSources.isEmpty && library.connectedDevices.isEmpty let isEmptyLibrary = library.visibleSources.isEmpty && library.connectedDevices.isEmpty
let resolvedCurrentSource = currentSource let resolvedCurrentSource = currentSource
let resolvedCurrentSourceCandidate = currentSourceCandidate
let currentProjectionRequest = ItemCollectionProjectionRequest( let currentProjectionRequest = ItemCollectionProjectionRequest(
selection: selectedSidebarSelection, selection: selectedSidebarSelection,
searchText: searchText, searchText: searchText,
@ -122,6 +123,7 @@ struct ContentView: View {
ItemDetailColumnView( ItemDetailColumnView(
item: resolvedCurrentSelectedItem, item: resolvedCurrentSelectedItem,
source: resolvedCurrentSource, source: resolvedCurrentSource,
sourceCandidate: resolvedCurrentSourceCandidate,
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection, showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [], behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [], resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
@ -154,7 +156,9 @@ struct ContentView: View {
} }
shareItem(item, from: anchorView) shareItem(item, from: anchorView)
} },
addCandidateSourceAction: addCandidateSource(_:),
revealCandidateAction: revealCandidateInFinder(_:)
) )
.frame(minWidth: 450) .frame(minWidth: 450)
} }
@ -255,6 +259,14 @@ struct ContentView: View {
return library.source(withID: sourceID) 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? { private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? {
guard let selectedItemID else { guard let selectedItemID else {
return nil 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 { private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
let fileURLType = UTType.fileURL.identifier let fileURLType = UTType.fileURL.identifier
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) } let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }
@ -654,8 +670,18 @@ struct ContentView: View {
} }
private func syncSelection(with sourceIDs: [URL]) { private func syncSelection(with sourceIDs: [URL]) {
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) { if let selectedSidebarSelection {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) } 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 { } else if self.selectedSidebarSelection == nil, let firstSourceID = sourceIDs.first {
self.selectedSidebarSelection = .source(sourceID: firstSourceID) self.selectedSidebarSelection = .source(sourceID: firstSourceID)
} }

View File

@ -77,6 +77,8 @@ enum ItemCollectionProjector {
} }
switch selection { switch selection {
case .sourceCandidate:
return "Source Candidate"
case .source, .allContent: case .source, .allContent:
return "All Items" return "All Items"
case .contentType(_, let contentType): case .contentType(_, let contentType):
@ -88,6 +90,8 @@ enum ItemCollectionProjector {
nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String { nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String {
switch selection { switch selection {
case .some(.sourceCandidate):
return "Search Library"
case .some(.source): case .some(.source):
return "Search \(source?.displayName ?? "Library")" return "Search \(source?.displayName ?? "Library")"
case .some(.allContent): case .some(.allContent):
@ -103,6 +107,8 @@ enum ItemCollectionProjector {
nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String { nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String {
switch selection { switch selection {
case .some(.sourceCandidate):
return "Source Candidate"
case .some(.source): case .some(.source):
return "Library" return "Library"
case .some(.allContent): case .some(.allContent):
@ -122,6 +128,8 @@ enum ItemCollectionProjector {
} }
switch selection { switch selection {
case .sourceCandidate:
return "items"
case .source, .allContent: case .source, .allContent:
return scopedItemCount == 1 ? "item" : "items" return scopedItemCount == 1 ? "item" : "items"
case .contentType(_, let contentType): case .contentType(_, let contentType):

View File

@ -5,14 +5,17 @@ import SwiftUI
enum SidebarSelection: Hashable, Sendable { enum SidebarSelection: Hashable, Sendable {
case source(sourceID: URL) case source(sourceID: URL)
case sourceCandidate(candidateID: String)
case allContent(sourceID: URL) case allContent(sourceID: URL)
case contentType(sourceID: URL, contentType: MinecraftContentType) case contentType(sourceID: URL, contentType: MinecraftContentType)
case contentKind(sourceID: URL, contentKind: MinecraftContentKind) case contentKind(sourceID: URL, contentKind: MinecraftContentKind)
var sourceID: URL { var sourceID: URL? {
switch self { switch self {
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _), .contentKind(let sourceID, _): case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _), .contentKind(let sourceID, _):
return sourceID return sourceID
case .sourceCandidate:
return nil
} }
} }
} }
@ -65,9 +68,16 @@ struct SourcesSidebarView: View {
if !sourceCandidates.isEmpty { if !sourceCandidates.isEmpty {
Section { Section {
ForEach(sourceCandidates) { candidate in ForEach(sourceCandidates) { candidate in
SourceCandidateRow(candidate: candidate) { SourceCandidateRow(
addCandidateSourceAction(candidate) candidate: candidate,
} onSelect: {
selection = .sourceCandidate(candidateID: candidate.id)
},
addAction: {
addCandidateSourceAction(candidate)
}
)
.tag(SidebarSelection.sourceCandidate(candidateID: candidate.id) as SidebarSelection?)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
} }
@ -153,6 +163,7 @@ struct SourcesSidebarView: View {
private struct SourceCandidateRow: View { private struct SourceCandidateRow: View {
let candidate: SourceCandidate let candidate: SourceCandidate
let onSelect: () -> Void
let addAction: () -> Void let addAction: () -> Void
var body: some View { var body: some View {
@ -179,6 +190,8 @@ private struct SourceCandidateRow: View {
.buttonStyle(.borderless) .buttonStyle(.borderless)
.help("Add Source") .help("Add Source")
} }
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
.padding(.vertical, 4) .padding(.vertical, 4)
} }