diff --git a/World Manager for Minecraft/Models/Sources/MinecraftSource.swift b/World Manager for Minecraft/Models/Sources/MinecraftSource.swift index 8472461..e8321ac 100644 --- a/World Manager for Minecraft/Models/Sources/MinecraftSource.swift +++ b/World Manager for Minecraft/Models/Sources/MinecraftSource.swift @@ -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 [] diff --git a/World Manager for Minecraft/UI/Detail/ItemDetailColumnViews.swift b/World Manager for Minecraft/UI/Detail/ItemDetailColumnViews.swift index 49b02e5..c2f9385 100644 --- a/World Manager for Minecraft/UI/Detail/ItemDetailColumnViews.swift +++ b/World Manager for Minecraft/UI/Detail/ItemDetailColumnViews.swift @@ -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) diff --git a/World Manager for Minecraft/UI/Detail/SourceCandidateDetailView.swift b/World Manager for Minecraft/UI/Detail/SourceCandidateDetailView.swift new file mode 100644 index 0000000..6620ffc --- /dev/null +++ b/World Manager for Minecraft/UI/Detail/SourceCandidateDetailView.swift @@ -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) + } +} diff --git a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift index 90affa6..6bd15e4 100644 --- a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift +++ b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift @@ -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 } ) } } diff --git a/World Manager for Minecraft/UI/Root/ContentView.swift b/World Manager for Minecraft/UI/Root/ContentView.swift index 24598c8..c297064 100644 --- a/World Manager for Minecraft/UI/Root/ContentView.swift +++ b/World Manager for Minecraft/UI/Root/ContentView.swift @@ -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) } diff --git a/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift b/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift index 1272097..280c4f0 100644 --- a/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift +++ b/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift @@ -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): diff --git a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift index f8dd205..9402b03 100644 --- a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift +++ b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift @@ -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) }