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 {
|
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 []
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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(
|
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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
switch selectedSidebarSelection {
|
||||||
|
case .sourceCandidate(let candidateID):
|
||||||
|
if !library.sourceCandidates.contains(where: { $0.id == candidateID }) {
|
||||||
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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(
|
||||||
|
candidate: candidate,
|
||||||
|
onSelect: {
|
||||||
|
selection = .sourceCandidate(candidateID: candidate.id)
|
||||||
|
},
|
||||||
|
addAction: {
|
||||||
addCandidateSourceAction(candidate)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user