make source headers clickable to show source details
This commit is contained in:
parent
3788b5f2a9
commit
ee621d7eb2
@ -50,7 +50,7 @@ struct ContentView: View {
|
|||||||
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
|
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
|
||||||
addConnectedDeviceAction: addConnectedDeviceSource(from:),
|
addConnectedDeviceAction: addConnectedDeviceSource(from:),
|
||||||
rescanSourceAction: { source in
|
rescanSourceAction: { source in
|
||||||
selectedSidebarSelection = .allContent(sourceID: source.id)
|
selectedSidebarSelection = .source(sourceID: source.id)
|
||||||
selectedItemID = nil
|
selectedItemID = nil
|
||||||
library.rescanSource(withID: source.id)
|
library.rescanSource(withID: source.id)
|
||||||
},
|
},
|
||||||
@ -86,6 +86,7 @@ struct ContentView: View {
|
|||||||
ItemDetailColumnView(
|
ItemDetailColumnView(
|
||||||
item: currentSelectedItem,
|
item: currentSelectedItem,
|
||||||
source: currentSource,
|
source: currentSource,
|
||||||
|
showsSourceDetails: currentSelectedItem == nil && isSourceOverviewSelection,
|
||||||
behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
||||||
resourcePacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
resourcePacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
||||||
worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [],
|
worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [],
|
||||||
@ -131,7 +132,7 @@ struct ContentView: View {
|
|||||||
sourceFactory: deviceSourceFactory,
|
sourceFactory: deviceSourceFactory,
|
||||||
onAddSource: { source in
|
onAddSource: { source in
|
||||||
let sourceID = library.addSource(source, shouldPersist: true, shouldScan: true)
|
let sourceID = library.addSource(source, shouldPersist: true, shouldScan: true)
|
||||||
selectedSidebarSelection = .allContent(sourceID: sourceID)
|
selectedSidebarSelection = .source(sourceID: sourceID)
|
||||||
selectedItemID = nil
|
selectedItemID = nil
|
||||||
isShowingDeviceSourceSheet = false
|
isShowingDeviceSourceSheet = false
|
||||||
}
|
}
|
||||||
@ -151,6 +152,15 @@ struct ContentView: View {
|
|||||||
|
|
||||||
self.selectedItemID = nil
|
self.selectedItemID = nil
|
||||||
}
|
}
|
||||||
|
.onChange(of: selectedSidebarSelection) { _, selection in
|
||||||
|
guard let selection else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .source = selection {
|
||||||
|
selectedItemID = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: library.sources.map(\.id)) { _, _ in
|
.onChange(of: library.sources.map(\.id)) { _, _ in
|
||||||
syncSelection(with: library.visibleSources.map(\.id))
|
syncSelection(with: library.visibleSources.map(\.id))
|
||||||
}
|
}
|
||||||
@ -168,7 +178,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch selectedSidebarSelection {
|
switch selectedSidebarSelection {
|
||||||
case .allContent(let sourceID):
|
case .source(let sourceID), .allContent(let sourceID):
|
||||||
return library.source(withID: sourceID)?.items ?? []
|
return library.source(withID: sourceID)?.items ?? []
|
||||||
case .contentType(let sourceID, let contentType):
|
case .contentType(let sourceID, let contentType):
|
||||||
return library.source(withID: sourceID)?.items.filter { $0.contentType == contentType } ?? []
|
return library.source(withID: sourceID)?.items.filter { $0.contentType == contentType } ?? []
|
||||||
@ -261,7 +271,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch selectedSidebarSelection {
|
switch selectedSidebarSelection {
|
||||||
case .allContent:
|
case .source, .allContent:
|
||||||
return "All Items"
|
return "All Items"
|
||||||
case .contentType(_, let contentType):
|
case .contentType(_, let contentType):
|
||||||
return sidebarTitle(for: contentType)
|
return sidebarTitle(for: contentType)
|
||||||
@ -310,6 +320,8 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private var searchScopeTitle: String {
|
private var searchScopeTitle: String {
|
||||||
switch selectedSidebarSelection {
|
switch selectedSidebarSelection {
|
||||||
|
case .some(.source(let sourceID)):
|
||||||
|
return library.source(withID: sourceID)?.displayName ?? "Library"
|
||||||
case .some(.allContent):
|
case .some(.allContent):
|
||||||
return "All"
|
return "All"
|
||||||
case .some(.contentType(_, let contentType)):
|
case .some(.contentType(_, let contentType)):
|
||||||
@ -337,7 +349,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch selectedSidebarSelection {
|
switch selectedSidebarSelection {
|
||||||
case .allContent:
|
case .source, .allContent:
|
||||||
return scopedItems.count == 1 ? "item" : "items"
|
return scopedItems.count == 1 ? "item" : "items"
|
||||||
case .contentType(_, let contentType):
|
case .contentType(_, let contentType):
|
||||||
switch contentType {
|
switch contentType {
|
||||||
@ -351,6 +363,9 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private var searchPrompt: String {
|
private var searchPrompt: String {
|
||||||
switch selectedSidebarSelection {
|
switch selectedSidebarSelection {
|
||||||
|
case .some(.source(let sourceID)):
|
||||||
|
let sourceName = library.source(withID: sourceID)?.displayName ?? "Library"
|
||||||
|
return "Search \(sourceName)"
|
||||||
case .some(.allContent):
|
case .some(.allContent):
|
||||||
return "Search All Items"
|
return "Search All Items"
|
||||||
case .some(.contentType(_, let contentType)):
|
case .some(.contentType(_, let contentType)):
|
||||||
@ -361,32 +376,31 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
|
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
|
||||||
var filters = [
|
MinecraftContentType.allCases.compactMap { contentType in
|
||||||
SidebarFilter(
|
let count = source.items.filter { $0.contentType == contentType }.count
|
||||||
title: "All Items",
|
guard count > 0 else {
|
||||||
iconName: "square.grid.2x2",
|
return nil
|
||||||
count: source.items.count,
|
|
||||||
selection: .allContent(sourceID: source.id)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
filters.append(
|
|
||||||
contentsOf: MinecraftContentType.allCases.compactMap { contentType in
|
|
||||||
let count = source.items.filter { $0.contentType == contentType }.count
|
|
||||||
guard count > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return SidebarFilter(
|
|
||||||
title: sidebarTitle(for: contentType),
|
|
||||||
iconName: sidebarIcon(for: contentType),
|
|
||||||
count: count,
|
|
||||||
selection: .contentType(sourceID: source.id, contentType: contentType)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
return filters
|
return SidebarFilter(
|
||||||
|
title: sidebarTitle(for: contentType),
|
||||||
|
iconName: sidebarIcon(for: contentType),
|
||||||
|
count: count,
|
||||||
|
selection: .contentType(sourceID: source.id, contentType: contentType)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isSourceOverviewSelection: Bool {
|
||||||
|
guard let selectedSidebarSelection else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .source = selectedSidebarSelection {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sidebarTitle(for contentType: MinecraftContentType) -> String {
|
private func sidebarTitle(for contentType: MinecraftContentType) -> String {
|
||||||
@ -585,7 +599,7 @@ struct ContentView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedSidebarSelection = .allContent(sourceID: sourceID)
|
selectedSidebarSelection = .source(sourceID: sourceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeSource(_ sourceID: URL) {
|
private func removeSource(_ sourceID: URL) {
|
||||||
@ -593,7 +607,7 @@ struct ContentView: View {
|
|||||||
library.removeSource(withID: sourceID)
|
library.removeSource(withID: sourceID)
|
||||||
|
|
||||||
if selectedSidebarSelection?.sourceID == sourceID {
|
if selectedSidebarSelection?.sourceID == sourceID {
|
||||||
selectedSidebarSelection = fallbackSourceID.map { .allContent(sourceID: $0) }
|
selectedSidebarSelection = fallbackSourceID.map { .source(sourceID: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if let selectedItemID, currentSelectedItem?.id != selectedItemID {
|
if let selectedItemID, currentSelectedItem?.id != selectedItemID {
|
||||||
@ -608,15 +622,15 @@ struct ContentView: View {
|
|||||||
|
|
||||||
let source = deviceSourceFactory.makeSource(device: entry.device, container: container)
|
let source = deviceSourceFactory.makeSource(device: entry.device, container: container)
|
||||||
let sourceID = library.addSource(source, shouldPersist: true, shouldScan: true)
|
let sourceID = library.addSource(source, shouldPersist: true, shouldScan: true)
|
||||||
selectedSidebarSelection = .allContent(sourceID: sourceID)
|
selectedSidebarSelection = .source(sourceID: sourceID)
|
||||||
selectedItemID = nil
|
selectedItemID = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func syncSelection(with sourceIDs: [URL]) {
|
private func syncSelection(with sourceIDs: [URL]) {
|
||||||
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) {
|
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) {
|
||||||
self.selectedSidebarSelection = sourceIDs.first.map { .allContent(sourceID: $0) }
|
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 = .allContent(sourceID: firstSourceID)
|
self.selectedSidebarSelection = .source(sourceID: firstSourceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let selectedItemID {
|
if let selectedItemID {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ struct DirectoryPreviewEntry: Identifiable {
|
|||||||
struct ItemDetailColumnView: View {
|
struct ItemDetailColumnView: View {
|
||||||
let item: MinecraftContentItem?
|
let item: MinecraftContentItem?
|
||||||
let source: MinecraftSource?
|
let source: MinecraftSource?
|
||||||
|
let showsSourceDetails: Bool
|
||||||
let behaviorPacks: [ContentPackReference]
|
let behaviorPacks: [ContentPackReference]
|
||||||
let resourcePacks: [ContentPackReference]
|
let resourcePacks: [ContentPackReference]
|
||||||
let worldsUsingPack: [MinecraftContentItem]
|
let worldsUsingPack: [MinecraftContentItem]
|
||||||
@ -46,6 +47,8 @@ struct ItemDetailColumnView: View {
|
|||||||
revealAction: revealAction,
|
revealAction: revealAction,
|
||||||
shareAction: shareAction
|
shareAction: shareAction
|
||||||
)
|
)
|
||||||
|
} else if showsSourceDetails, let source {
|
||||||
|
SourceDetailView(source: source)
|
||||||
} else {
|
} else {
|
||||||
Text("Select a world or pack to see details")
|
Text("Select a world or pack to see details")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@ -83,6 +86,165 @@ struct ItemDetailColumnView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SourceDetailView: View {
|
||||||
|
let source: MinecraftSource
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(source.displayName)
|
||||||
|
.font(.largeTitle.weight(.semibold))
|
||||||
|
|
||||||
|
Text(sourceSummary)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSection(title: "Overview", rows: overviewRows)
|
||||||
|
sourceSection(title: "Contents", rows: contentRows)
|
||||||
|
sourceSection(title: "Location", rows: locationRows)
|
||||||
|
|
||||||
|
if !technicalRows.isEmpty {
|
||||||
|
sourceSection(title: "Technical Details", rows: technicalRows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 760, alignment: .leading)
|
||||||
|
.padding(28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sourceSummary: String {
|
||||||
|
switch source.origin {
|
||||||
|
case .localFolder:
|
||||||
|
return "Local filesystem source"
|
||||||
|
case .connectedDevice(let device, let container):
|
||||||
|
return "\(device.name) • \(container.appName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var overviewRows: [(String, String)] {
|
||||||
|
var rows: [(String, String)] = [
|
||||||
|
("Type", sourceTypeLabel),
|
||||||
|
("Availability", availabilityLabel)
|
||||||
|
]
|
||||||
|
|
||||||
|
if let lastScanDate = source.lastScanDate {
|
||||||
|
rows.append(("Last Successful Scan", lastScanDate.formatted(date: .abbreviated, time: .shortened)))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch source.origin {
|
||||||
|
case .localFolder:
|
||||||
|
break
|
||||||
|
case .connectedDevice(let device, let container):
|
||||||
|
rows.append(("Connection", device.connection == .network ? "Network" : "USB"))
|
||||||
|
rows.append(("App Container", container.appName))
|
||||||
|
if let osVersion = device.osVersion, !osVersion.isEmpty {
|
||||||
|
rows.append(("OS Version", osVersion))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
private var contentRows: [(String, String)] {
|
||||||
|
[
|
||||||
|
("Total Items", source.items.count.formatted(.number)),
|
||||||
|
("Worlds", itemCount(for: .world).formatted(.number)),
|
||||||
|
("Behavior Packs", itemCount(for: .behaviorPack).formatted(.number)),
|
||||||
|
("Resource Packs", itemCount(for: .resourcePack).formatted(.number)),
|
||||||
|
("Skin Packs", itemCount(for: .skinPack).formatted(.number)),
|
||||||
|
("World Templates", itemCount(for: .worldTemplate).formatted(.number))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var locationRows: [(String, String)] {
|
||||||
|
switch source.origin {
|
||||||
|
case .localFolder:
|
||||||
|
return [("Filesystem Path", source.folderURL.path)]
|
||||||
|
case .connectedDevice(_, let container):
|
||||||
|
var rows: [(String, String)] = [
|
||||||
|
("Source Identifier", source.folderURL.absoluteString)
|
||||||
|
]
|
||||||
|
if let relativePath = container.minecraftFolderRelativePath, !relativePath.isEmpty {
|
||||||
|
rows.append(("Minecraft Path", relativePath))
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var technicalRows: [(String, String)] {
|
||||||
|
switch source.origin {
|
||||||
|
case .localFolder:
|
||||||
|
return []
|
||||||
|
case .connectedDevice(let device, let container):
|
||||||
|
var rows: [(String, String)] = [
|
||||||
|
("UDID", device.udid),
|
||||||
|
("App ID", container.appID),
|
||||||
|
("Access Mode", container.accessMode.rawValue)
|
||||||
|
]
|
||||||
|
if let productType = device.productType, !productType.isEmpty {
|
||||||
|
rows.append(("Product Type", productType))
|
||||||
|
}
|
||||||
|
rows.append(("Trust State", device.trustState.rawValue.capitalized))
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sourceTypeLabel: String {
|
||||||
|
switch source.origin {
|
||||||
|
case .localFolder:
|
||||||
|
return "Local Folder"
|
||||||
|
case .connectedDevice:
|
||||||
|
return "Connected Device"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var availabilityLabel: String {
|
||||||
|
switch source.availability {
|
||||||
|
case .unknown:
|
||||||
|
return "Unknown"
|
||||||
|
case .available:
|
||||||
|
return "Available"
|
||||||
|
case .disconnected:
|
||||||
|
return "Disconnected"
|
||||||
|
case .limited:
|
||||||
|
return "Limited"
|
||||||
|
case .unavailable:
|
||||||
|
return "Unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func itemCount(for type: MinecraftContentType) -> Int {
|
||||||
|
source.items.filter { $0.contentType == type }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||||
|
HStack(alignment: .top, spacing: 16) {
|
||||||
|
Text(row.0)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 170, alignment: .leading)
|
||||||
|
|
||||||
|
Text(row.1)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ItemDetailView: View {
|
struct ItemDetailView: View {
|
||||||
private let detailContentMaxWidth: CGFloat = 760
|
private let detailContentMaxWidth: CGFloat = 760
|
||||||
private let heroContentMaxWidth: CGFloat = 1080
|
private let heroContentMaxWidth: CGFloat = 1080
|
||||||
|
|||||||
@ -211,11 +211,4 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
|
|||||||
.joined(separator: "\n")
|
.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func == (lhs: MinecraftContentItem, rhs: MinecraftContentItem) -> Bool {
|
|
||||||
lhs.id == rhs.id
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -345,6 +345,7 @@ struct ItemDetailColumnPreviewContainer: View {
|
|||||||
ItemDetailColumnView(
|
ItemDetailColumnView(
|
||||||
item: PreviewFixtures.featuredWorld,
|
item: PreviewFixtures.featuredWorld,
|
||||||
source: PreviewFixtures.primarySource,
|
source: PreviewFixtures.primarySource,
|
||||||
|
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),
|
||||||
worldsUsingPack: [],
|
worldsUsingPack: [],
|
||||||
|
|||||||
@ -1736,6 +1736,14 @@ final class SourceLibrary: ObservableObject {
|
|||||||
return candidate.metadataLoaded
|
return candidate.metadataLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (candidate.iconURL != nil) != (existing.iconURL != nil) {
|
||||||
|
return candidate.iconURL != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidate.previewLoaded != existing.previewLoaded {
|
||||||
|
return candidate.previewLoaded
|
||||||
|
}
|
||||||
|
|
||||||
if candidate.modifiedDate != existing.modifiedDate {
|
if candidate.modifiedDate != existing.modifiedDate {
|
||||||
return (candidate.modifiedDate ?? .distantPast) > (existing.modifiedDate ?? .distantPast)
|
return (candidate.modifiedDate ?? .distantPast) > (existing.modifiedDate ?? .distantPast)
|
||||||
}
|
}
|
||||||
@ -2259,6 +2267,14 @@ private actor SourceIndexActor {
|
|||||||
return candidate.metadataLoaded
|
return candidate.metadataLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (candidate.iconURL != nil) != (existing.iconURL != nil) {
|
||||||
|
return candidate.iconURL != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidate.previewLoaded != existing.previewLoaded {
|
||||||
|
return candidate.previewLoaded
|
||||||
|
}
|
||||||
|
|
||||||
if candidate.modifiedDate != existing.modifiedDate {
|
if candidate.modifiedDate != existing.modifiedDate {
|
||||||
return (candidate.modifiedDate ?? .distantPast) > (existing.modifiedDate ?? .distantPast)
|
return (candidate.modifiedDate ?? .distantPast) > (existing.modifiedDate ?? .distantPast)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum SidebarSelection: Hashable {
|
enum SidebarSelection: Hashable {
|
||||||
|
case source(sourceID: URL)
|
||||||
case allContent(sourceID: URL)
|
case allContent(sourceID: URL)
|
||||||
case contentType(sourceID: URL, contentType: MinecraftContentType)
|
case contentType(sourceID: URL, contentType: MinecraftContentType)
|
||||||
|
|
||||||
var sourceID: URL {
|
var sourceID: URL {
|
||||||
switch self {
|
switch self {
|
||||||
case .allContent(let sourceID), .contentType(let sourceID, _):
|
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _):
|
||||||
return sourceID
|
return sourceID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -75,7 +76,14 @@ struct SourcesSidebarView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func sourceSectionRows(for source: MinecraftSource) -> some View {
|
private func sourceSectionRows(for source: MinecraftSource) -> some View {
|
||||||
SourceHeaderRow(source: source)
|
SourceHeaderRow(
|
||||||
|
source: source,
|
||||||
|
isSelected: selection == .source(sourceID: source.id),
|
||||||
|
onSelect: {
|
||||||
|
selection = .source(sourceID: source.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.padding(.top, 6)
|
.padding(.top, 6)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
@ -143,13 +151,20 @@ private struct SidebarSourcesSectionHeaderView: View {
|
|||||||
|
|
||||||
private struct SourceHeaderRow: View {
|
private struct SourceHeaderRow: View {
|
||||||
let source: MinecraftSource
|
let source: MinecraftSource
|
||||||
|
let isSelected: Bool
|
||||||
|
let onSelect: () -> Void
|
||||||
@State private var isPresentingStatusPopover = false
|
@State private var isPresentingStatusPopover = false
|
||||||
|
@State private var isHovering = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: headerSymbolName)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(titleColor)
|
||||||
|
|
||||||
Text(source.displayName)
|
Text(source.displayName)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(titleColor)
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
@ -172,6 +187,12 @@ private struct SourceHeaderRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(backgroundStyle, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(perform: onSelect)
|
||||||
|
.onHover { isHovering = $0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var connection: DeviceConnection? {
|
private var connection: DeviceConnection? {
|
||||||
@ -194,6 +215,31 @@ private struct SourceHeaderRow: View {
|
|||||||
return "Scanning library…"
|
return "Scanning library…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var headerSymbolName: String {
|
||||||
|
switch source.origin {
|
||||||
|
case .localFolder:
|
||||||
|
return "folder"
|
||||||
|
case .connectedDevice:
|
||||||
|
return "iphone.gen3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titleColor: Color {
|
||||||
|
isSelected ? .primary : .secondary
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundStyle: AnyShapeStyle {
|
||||||
|
if isSelected {
|
||||||
|
return AnyShapeStyle(Color.appAccent.opacity(0.14))
|
||||||
|
}
|
||||||
|
|
||||||
|
if isHovering {
|
||||||
|
return AnyShapeStyle(.secondary.opacity(0.08))
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnyShapeStyle(.clear)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var statusIndicator: some View {
|
private var statusIndicator: some View {
|
||||||
if source.isScanning {
|
if source.isScanning {
|
||||||
|
|||||||
117
docs/library-intelligence-notes.md
Normal file
117
docs/library-intelligence-notes.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# Library Intelligence Notes
|
||||||
|
|
||||||
|
## Focus Areas
|
||||||
|
|
||||||
|
- Cross-library search across all scanned sources, not just the currently selected library.
|
||||||
|
- Smart folders as saved queries over indexed content.
|
||||||
|
- Automated analysis that surfaces outliers, integrity issues, and duplicates.
|
||||||
|
- File operations for moving, copying, backing up, and restoring Minecraft content across sources.
|
||||||
|
|
||||||
|
## File Operations
|
||||||
|
|
||||||
|
Core operations:
|
||||||
|
|
||||||
|
- Copy a world from one library to another.
|
||||||
|
- Copy packs or templates between libraries.
|
||||||
|
- Export selected items as archives.
|
||||||
|
- Import archives or folders into a target library.
|
||||||
|
- Back up an entire accessible Minecraft library from a device or folder source.
|
||||||
|
- Restore items from a backup into a chosen target source.
|
||||||
|
|
||||||
|
Operational concerns:
|
||||||
|
|
||||||
|
- Detect duplicate world or pack identities before writing.
|
||||||
|
- Handle naming conflicts with overwrite, rename, or skip behavior.
|
||||||
|
- Validate that the destination source supports the content being copied.
|
||||||
|
- Show progress for long-running copy or backup work.
|
||||||
|
- Keep operations source-agnostic where possible so local folders, connected devices, and removable media can share the same workflow.
|
||||||
|
|
||||||
|
Future file-operation ideas:
|
||||||
|
|
||||||
|
- Batch copy selected worlds or packs.
|
||||||
|
- Sync or compare two libraries before copying.
|
||||||
|
- One-click backup of a connected device's Minecraft content.
|
||||||
|
- Backup manifests so backups remain browsable and restorable later.
|
||||||
|
- Restore preview showing what will be created or overwritten.
|
||||||
|
|
||||||
|
## Cross-Library Search
|
||||||
|
|
||||||
|
Goals:
|
||||||
|
|
||||||
|
- Search across every scanned source in one place.
|
||||||
|
- Show which library or device each result came from.
|
||||||
|
- Keep search useful even when some device-backed sources are offline by using cached scan results where possible.
|
||||||
|
|
||||||
|
Useful filters:
|
||||||
|
|
||||||
|
- Content type: worlds, behavior packs, resource packs, skin packs, templates.
|
||||||
|
- Source kind: local folders, connected devices, removable media.
|
||||||
|
- Source name or device name.
|
||||||
|
- Health state: complete, partial metadata, broken, unresolved references.
|
||||||
|
- Size ranges and date ranges.
|
||||||
|
|
||||||
|
Useful result metadata:
|
||||||
|
|
||||||
|
- Display name.
|
||||||
|
- Source name.
|
||||||
|
- Content type.
|
||||||
|
- Size.
|
||||||
|
- Last played or modified date.
|
||||||
|
- Availability state for the backing source.
|
||||||
|
|
||||||
|
## Smart Folders
|
||||||
|
|
||||||
|
Definition:
|
||||||
|
|
||||||
|
- Smart folders are saved predicates over indexed content, not physical folders on disk.
|
||||||
|
|
||||||
|
Built-in smart folder candidates:
|
||||||
|
|
||||||
|
- Largest Worlds
|
||||||
|
- Largest Archives
|
||||||
|
- Recently Modified
|
||||||
|
- Recently Played
|
||||||
|
- Broken Archives
|
||||||
|
- Worlds With Missing Packs
|
||||||
|
- Duplicate Packs
|
||||||
|
- Suspicious Packs
|
||||||
|
- Offline Results
|
||||||
|
- Incomplete Metadata
|
||||||
|
|
||||||
|
Future direction:
|
||||||
|
|
||||||
|
- Allow users to create custom smart folders from filters and sort rules.
|
||||||
|
|
||||||
|
## Automated Analysis
|
||||||
|
|
||||||
|
Potential analyses:
|
||||||
|
|
||||||
|
- Largest content items by size.
|
||||||
|
- Broken archives or invalid package structures.
|
||||||
|
- Worlds missing `level.dat` or other expected files.
|
||||||
|
- Worlds with unresolved pack references.
|
||||||
|
- Duplicate packs across libraries by UUID and version.
|
||||||
|
- Diverged duplicates that appear related but differ in size, modified date, or fingerprint.
|
||||||
|
- Orphaned packs not referenced by any world.
|
||||||
|
- Changes since the last scan.
|
||||||
|
|
||||||
|
Possible outputs:
|
||||||
|
|
||||||
|
- Smart folder population.
|
||||||
|
- Sidebar badges or warnings.
|
||||||
|
- A future dashboard or “Insights” view.
|
||||||
|
|
||||||
|
## Suggested Order
|
||||||
|
|
||||||
|
1. Add global search across all scanned libraries.
|
||||||
|
2. Add a small set of built-in smart folders.
|
||||||
|
3. Add integrity and duplicate analysis to feed those folders.
|
||||||
|
4. Add custom smart folders later if the built-ins prove useful.
|
||||||
|
|
||||||
|
## Product Notes
|
||||||
|
|
||||||
|
- “Search” solves retrieval.
|
||||||
|
- “Smart folders” solve recurring saved views.
|
||||||
|
- “Analysis” solves discovery and problem finding.
|
||||||
|
|
||||||
|
These should stay distinct in the product even if they share the same underlying index.
|
||||||
Loading…
Reference in New Issue
Block a user