From ee621d7eb2141da85156e107dd3af517e8de21d8 Mon Sep 17 00:00:00 2001 From: John Burwell Date: Wed, 27 May 2026 23:04:57 -0500 Subject: [PATCH] make source headers clickable to show source details --- World Manager for Minecraft/ContentView.swift | 82 +++++---- .../ItemDetailColumnViews.swift | 162 ++++++++++++++++++ .../Models/MinecraftContentItem.swift | 7 - .../PreviewFixtures.swift | 1 + .../Services/SourceLibrary.swift | 16 ++ .../SidebarColumnViews.swift | 52 +++++- docs/library-intelligence-notes.md | 117 +++++++++++++ 7 files changed, 393 insertions(+), 44 deletions(-) create mode 100644 docs/library-intelligence-notes.md diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index 2ed5ec3..a89c4df 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -50,7 +50,7 @@ struct ContentView: View { addDeviceSourceAction: { isShowingDeviceSourceSheet = true }, addConnectedDeviceAction: addConnectedDeviceSource(from:), rescanSourceAction: { source in - selectedSidebarSelection = .allContent(sourceID: source.id) + selectedSidebarSelection = .source(sourceID: source.id) selectedItemID = nil library.rescanSource(withID: source.id) }, @@ -86,6 +86,7 @@ struct ContentView: View { ItemDetailColumnView( item: currentSelectedItem, source: currentSource, + showsSourceDetails: currentSelectedItem == nil && isSourceOverviewSelection, behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [], resourcePacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [], worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [], @@ -131,7 +132,7 @@ struct ContentView: View { sourceFactory: deviceSourceFactory, onAddSource: { source in let sourceID = library.addSource(source, shouldPersist: true, shouldScan: true) - selectedSidebarSelection = .allContent(sourceID: sourceID) + selectedSidebarSelection = .source(sourceID: sourceID) selectedItemID = nil isShowingDeviceSourceSheet = false } @@ -151,6 +152,15 @@ struct ContentView: View { 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 syncSelection(with: library.visibleSources.map(\.id)) } @@ -168,7 +178,7 @@ struct ContentView: View { } switch selectedSidebarSelection { - case .allContent(let sourceID): + case .source(let sourceID), .allContent(let sourceID): return library.source(withID: sourceID)?.items ?? [] case .contentType(let sourceID, let contentType): return library.source(withID: sourceID)?.items.filter { $0.contentType == contentType } ?? [] @@ -261,7 +271,7 @@ struct ContentView: View { } switch selectedSidebarSelection { - case .allContent: + case .source, .allContent: return "All Items" case .contentType(_, let contentType): return sidebarTitle(for: contentType) @@ -310,6 +320,8 @@ struct ContentView: View { private var searchScopeTitle: String { switch selectedSidebarSelection { + case .some(.source(let sourceID)): + return library.source(withID: sourceID)?.displayName ?? "Library" case .some(.allContent): return "All" case .some(.contentType(_, let contentType)): @@ -337,7 +349,7 @@ struct ContentView: View { } switch selectedSidebarSelection { - case .allContent: + case .source, .allContent: return scopedItems.count == 1 ? "item" : "items" case .contentType(_, let contentType): switch contentType { @@ -351,6 +363,9 @@ struct ContentView: View { private var searchPrompt: String { switch selectedSidebarSelection { + case .some(.source(let sourceID)): + let sourceName = library.source(withID: sourceID)?.displayName ?? "Library" + return "Search \(sourceName)" case .some(.allContent): return "Search All Items" case .some(.contentType(_, let contentType)): @@ -361,32 +376,31 @@ struct ContentView: View { } private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] { - var filters = [ - SidebarFilter( - title: "All Items", - iconName: "square.grid.2x2", - 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) - ) + MinecraftContentType.allCases.compactMap { contentType in + let count = source.items.filter { $0.contentType == contentType }.count + guard count > 0 else { + return nil } - ) - 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 { @@ -585,7 +599,7 @@ struct ContentView: View { return } - selectedSidebarSelection = .allContent(sourceID: sourceID) + selectedSidebarSelection = .source(sourceID: sourceID) } private func removeSource(_ sourceID: URL) { @@ -593,7 +607,7 @@ struct ContentView: View { library.removeSource(withID: sourceID) if selectedSidebarSelection?.sourceID == sourceID { - selectedSidebarSelection = fallbackSourceID.map { .allContent(sourceID: $0) } + selectedSidebarSelection = fallbackSourceID.map { .source(sourceID: $0) } } if let selectedItemID, currentSelectedItem?.id != selectedItemID { @@ -608,15 +622,15 @@ struct ContentView: View { let source = deviceSourceFactory.makeSource(device: entry.device, container: container) let sourceID = library.addSource(source, shouldPersist: true, shouldScan: true) - selectedSidebarSelection = .allContent(sourceID: sourceID) + selectedSidebarSelection = .source(sourceID: sourceID) selectedItemID = nil } private func syncSelection(with sourceIDs: [URL]) { 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 { - self.selectedSidebarSelection = .allContent(sourceID: firstSourceID) + self.selectedSidebarSelection = .source(sourceID: firstSourceID) } if let selectedItemID { diff --git a/World Manager for Minecraft/ItemDetailColumnViews.swift b/World Manager for Minecraft/ItemDetailColumnViews.swift index 03e1a2c..3a23141 100644 --- a/World Manager for Minecraft/ItemDetailColumnViews.swift +++ b/World Manager for Minecraft/ItemDetailColumnViews.swift @@ -10,6 +10,7 @@ struct DirectoryPreviewEntry: Identifiable { struct ItemDetailColumnView: View { let item: MinecraftContentItem? let source: MinecraftSource? + let showsSourceDetails: Bool let behaviorPacks: [ContentPackReference] let resourcePacks: [ContentPackReference] let worldsUsingPack: [MinecraftContentItem] @@ -46,6 +47,8 @@ struct ItemDetailColumnView: View { revealAction: revealAction, shareAction: shareAction ) + } else if showsSourceDetails, let source { + SourceDetailView(source: source) } else { Text("Select a world or pack to see details") .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 { private let detailContentMaxWidth: CGFloat = 760 private let heroContentMaxWidth: CGFloat = 1080 diff --git a/World Manager for Minecraft/Models/MinecraftContentItem.swift b/World Manager for Minecraft/Models/MinecraftContentItem.swift index 74f3fce..2e7f1b7 100644 --- a/World Manager for Minecraft/Models/MinecraftContentItem.swift +++ b/World Manager for Minecraft/Models/MinecraftContentItem.swift @@ -211,11 +211,4 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable { .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) - } } diff --git a/World Manager for Minecraft/PreviewFixtures.swift b/World Manager for Minecraft/PreviewFixtures.swift index e45b283..488f2d7 100644 --- a/World Manager for Minecraft/PreviewFixtures.swift +++ b/World Manager for Minecraft/PreviewFixtures.swift @@ -345,6 +345,7 @@ struct ItemDetailColumnPreviewContainer: View { ItemDetailColumnView( item: PreviewFixtures.featuredWorld, source: PreviewFixtures.primarySource, + showsSourceDetails: false, behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack), resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack), worldsUsingPack: [], diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index b16921a..5927a53 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -1736,6 +1736,14 @@ final class SourceLibrary: ObservableObject { 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 { return (candidate.modifiedDate ?? .distantPast) > (existing.modifiedDate ?? .distantPast) } @@ -2259,6 +2267,14 @@ private actor SourceIndexActor { 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 { return (candidate.modifiedDate ?? .distantPast) > (existing.modifiedDate ?? .distantPast) } diff --git a/World Manager for Minecraft/SidebarColumnViews.swift b/World Manager for Minecraft/SidebarColumnViews.swift index 1b87b01..737c23c 100644 --- a/World Manager for Minecraft/SidebarColumnViews.swift +++ b/World Manager for Minecraft/SidebarColumnViews.swift @@ -1,12 +1,13 @@ import SwiftUI enum SidebarSelection: Hashable { + case source(sourceID: URL) case allContent(sourceID: URL) case contentType(sourceID: URL, contentType: MinecraftContentType) var sourceID: URL { switch self { - case .allContent(let sourceID), .contentType(let sourceID, _): + case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _): return sourceID } } @@ -75,7 +76,14 @@ struct SourcesSidebarView: View { @ViewBuilder 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) .padding(.top, 6) .contextMenu { @@ -143,13 +151,20 @@ private struct SidebarSourcesSectionHeaderView: View { private struct SourceHeaderRow: View { let source: MinecraftSource + let isSelected: Bool + let onSelect: () -> Void @State private var isPresentingStatusPopover = false + @State private var isHovering = false var body: some View { HStack(spacing: 8) { + Image(systemName: headerSymbolName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(titleColor) + Text(source.displayName) .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) + .foregroundStyle(titleColor) 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? { @@ -194,6 +215,31 @@ private struct SourceHeaderRow: View { 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 private var statusIndicator: some View { if source.isScanning { diff --git a/docs/library-intelligence-notes.md b/docs/library-intelligence-notes.md new file mode 100644 index 0000000..caf8e9b --- /dev/null +++ b/docs/library-intelligence-notes.md @@ -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.