make source headers clickable to show source details

This commit is contained in:
John Burwell 2026-05-27 23:04:57 -05:00
parent 3788b5f2a9
commit ee621d7eb2
7 changed files with 393 additions and 44 deletions

View File

@ -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 {

View File

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

View File

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

View File

@ -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: [],

View File

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

View File

@ -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 {

View 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.