From b7ea9ce89d45e481cc5bfee92aeee29041970c5a Mon Sep 17 00:00:00 2001 From: John Burwell Date: Thu, 28 May 2026 17:16:47 -0500 Subject: [PATCH] Source cache syncing behavior --- World Manager for Minecraft/ContentView.swift | 38 +- .../ItemDetailColumnViews.swift | 15 +- .../PreviewFixtures.swift | 1 + .../Services/SourceLibrary.swift | 418 ++++++++++++------ .../Services/SourcePersistenceStore.swift | 184 ++++++-- .../Services/WorldScanner.swift | 106 +++++ .../SidebarColumnViews.swift | 10 +- .../AppleMobileDeviceSourceAccess.swift | 2 + .../Core/SourceAccessCoordinator.swift | 15 +- .../LocalFolder/LocalFolderSourceAccess.swift | 70 +++ 10 files changed, 663 insertions(+), 196 deletions(-) diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index a89c4df..517bef0 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -96,6 +96,7 @@ struct ContentView: View { directoryPreviewLimit: directoryPreviewLimit, isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty, isPerformingItemAction: isPerformingItemAction, + areFileActionsEnabled: areCurrentItemFileActionsEnabled, exportTitle: currentSelectedItem.map(primaryActionTitle(for:)), exportAction: { guard let item = currentSelectedItem else { @@ -122,7 +123,7 @@ struct ContentView: View { .frame(minWidth: 450) } .overlay { - if library.isRestoringPersistedSources { + if library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty { LaunchRestoreOverlayView() } } @@ -141,10 +142,7 @@ struct ContentView: View { .task { AppTerminationCoordinator.shared.register(library: library) } - .disabled(library.isRestoringPersistedSources) - .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in - library.shutdown() - } + .disabled(library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty) .onChange(of: displayedItems.map(\.id)) { _, filteredIDs in guard let selectedItemID, !filteredIDs.contains(selectedItemID) else { return @@ -318,6 +316,14 @@ struct ContentView: View { return nil } + private var areCurrentItemFileActionsEnabled: Bool { + guard currentSelectedItem != nil else { + return false + } + + return currentSource?.availability == .available + } + private var searchScopeTitle: String { switch selectedSidebarSelection { case .some(.source(let sourceID)): @@ -438,16 +444,19 @@ struct ContentView: View { Button("Share...") { shareItem(item, from: nil) } + .disabled(!areFileActionsEnabled(for: item)) Button(exportMenuTitle(for: item)) { saveItem(item) } + .disabled(!areFileActionsEnabled(for: item)) Divider() Button("Reveal in Finder") { revealInFinder(item) } + .disabled(!areFileActionsEnabled(for: item)) } private func exportMenuTitle(for item: MinecraftContentItem) -> String { @@ -644,8 +653,16 @@ struct ContentView: View { } } + private func areFileActionsEnabled(for item: MinecraftContentItem) -> Bool { + guard let source = library.visibleSources.first(where: { $0.items.contains(where: { $0.id == item.id }) }) else { + return false + } + + return source.availability == .available + } + private func saveItem(_ item: MinecraftContentItem) { - guard !isPerformingItemAction else { + guard !isPerformingItemAction, areFileActionsEnabled(for: item) else { return } let source = currentSource @@ -653,8 +670,9 @@ struct ContentView: View { let panel = NSSavePanel() panel.canCreateDirectories = true panel.isExtensionHidden = false - panel.title = primaryActionTitle(for: item) - panel.message = primaryActionSubtitle(for: item) + panel.showsTagField = false + panel.title = exportMenuTitle(for: item) + panel.prompt = "Save" panel.nameFieldStringValue = ContentPackageExporter.suggestedBaseFilename(for: item) panel.allowedContentTypes = [archiveType(for: item)] @@ -693,7 +711,7 @@ struct ContentView: View { } private func shareItem(_ item: MinecraftContentItem, from anchorView: NSView?) { - guard !isPerformingItemAction else { + guard !isPerformingItemAction, areFileActionsEnabled(for: item) else { return } let source = currentSource @@ -742,7 +760,7 @@ struct ContentView: View { } private func revealInFinder(_ item: MinecraftContentItem) { - guard let source = currentSource else { + guard let source = currentSource, areFileActionsEnabled(for: item) else { return } diff --git a/World Manager for Minecraft/ItemDetailColumnViews.swift b/World Manager for Minecraft/ItemDetailColumnViews.swift index d64f383..42bdf03 100644 --- a/World Manager for Minecraft/ItemDetailColumnViews.swift +++ b/World Manager for Minecraft/ItemDetailColumnViews.swift @@ -20,6 +20,7 @@ struct ItemDetailColumnView: View { let directoryPreviewLimit: Int let isEmpty: Bool let isPerformingItemAction: Bool + let areFileActionsEnabled: Bool let exportTitle: String? let exportAction: () -> Void let revealAction: () -> Void @@ -42,6 +43,7 @@ struct ItemDetailColumnView: View { contents: contents, directoryPreviewLimit: directoryPreviewLimit, isPerformingItemAction: isPerformingItemAction, + areFileActionsEnabled: areFileActionsEnabled, exportTitle: exportTitle, exportAction: exportAction, revealAction: revealAction, @@ -60,7 +62,7 @@ struct ItemDetailColumnView: View { Button(action: exportAction) { Image(systemName: "arrow.down.circle") } - .disabled(isPerformingItemAction) + .disabled(isPerformingItemAction || !areFileActionsEnabled) .help(exportTitle ?? "Export") } @@ -68,14 +70,14 @@ struct ItemDetailColumnView: View { Button(action: revealAction) { Image(systemName: "folder") } - .disabled(isPerformingItemAction) + .disabled(isPerformingItemAction || !areFileActionsEnabled) .help("Reveal in Finder") } ToolbarItem { ToolbarShareButton( systemImage: "square.and.arrow.up", - isEnabled: !isPerformingItemAction + isEnabled: !isPerformingItemAction && areFileActionsEnabled ) { anchorView in shareAction(anchorView) } @@ -626,6 +628,7 @@ struct ItemDetailView: View { let contents: [DirectoryPreviewEntry] let directoryPreviewLimit: Int let isPerformingItemAction: Bool + let areFileActionsEnabled: Bool let exportTitle: String? let exportAction: () -> Void let revealAction: () -> Void @@ -1186,7 +1189,7 @@ struct ItemDetailView: View { ActionPillButton( title: actionRowExportTitle, systemImage: "arrow.down.circle.fill", - isDisabled: isPerformingItemAction, + isDisabled: isPerformingItemAction || !areFileActionsEnabled, prominence: .primary, action: exportAction ) @@ -1194,7 +1197,7 @@ struct ItemDetailView: View { ActionPillButton( title: "Reveal", systemImage: "folder.fill", - isDisabled: isPerformingItemAction, + isDisabled: isPerformingItemAction || !areFileActionsEnabled, prominence: .secondary, action: revealAction ) @@ -1202,7 +1205,7 @@ struct ItemDetailView: View { SharingPillButton( title: "Share", systemImage: "square.and.arrow.up", - isEnabled: !isPerformingItemAction, + isEnabled: !isPerformingItemAction && areFileActionsEnabled, action: shareAction ) } diff --git a/World Manager for Minecraft/PreviewFixtures.swift b/World Manager for Minecraft/PreviewFixtures.swift index 488f2d7..e35ebef 100644 --- a/World Manager for Minecraft/PreviewFixtures.swift +++ b/World Manager for Minecraft/PreviewFixtures.swift @@ -355,6 +355,7 @@ struct ItemDetailColumnPreviewContainer: View { directoryPreviewLimit: 12, isEmpty: false, isPerformingItemAction: false, + areFileActionsEnabled: true, exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle, exportAction: {}, revealAction: {}, diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 7bd120e..32454e1 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -168,6 +168,7 @@ final class SourceLibrary: ObservableObject { return } + await persistVisibleSourcesForShutdown() shutdown() try? await Task.sleep(for: .seconds(timeout)) } @@ -183,7 +184,7 @@ final class SourceLibrary: ObservableObject { } source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source) } - startScan(for: normalizedURL) + startScan(for: normalizedURL, mode: .fullScan) return normalizedURL } @@ -225,7 +226,7 @@ final class SourceLibrary: ObservableObject { persistSourceIfAvailable(withID: source.id) } if shouldScan { - startScan(for: source.id) + startScan(for: source.id, mode: .fullScan) } return source.id @@ -236,7 +237,7 @@ final class SourceLibrary: ObservableObject { } func rescanSource(withID sourceID: URL) { - startScan(for: sourceID) + startScan(for: sourceID, mode: .fullScan) } func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] { @@ -305,7 +306,7 @@ final class SourceLibrary: ObservableObject { return "Scanning \(scanningSources.count) sources..." } - private func startScan(for sourceID: URL) { + private func startScan(for sourceID: URL, mode: SourceDiscoveryMode) { guard !isShuttingDown else { return } @@ -319,7 +320,7 @@ final class SourceLibrary: ObservableObject { return } - await self.scanSource(withID: sourceID) + await self.scanSource(withID: sourceID, mode: mode) } scanTasks[sourceID] = task @@ -333,7 +334,7 @@ final class SourceLibrary: ObservableObject { sources.contains { $0.isScanning && $0.origin.kind == .connectedDevice } } - private func scanSource(withID sourceID: URL) async { + private func scanSource(withID sourceID: URL, mode: SourceDiscoveryMode) async { var workerTasks: [Task] = [] var sizeWorkerTasks: [Task] = [] let scanStartTime = Date() @@ -353,7 +354,7 @@ final class SourceLibrary: ObservableObject { source.isScanning = true source.scanError = nil source.scanDiagnostic = nil - source.scanStatus = initialScanStatus(for: source) + source.scanStatus = initialScanStatus(for: source, mode: mode) source.scanProgress = nil source.indexedItemCount = 0 source.indexedDetailCount = 0 @@ -380,7 +381,7 @@ final class SourceLibrary: ObservableObject { updateSource(sourceID) { source in source.availability = .available - source.scanStatus = scanningLibraryStatus(for: source) + source.scanStatus = scanningLibraryStatus(for: source, mode: mode) } refreshSidebarFooterState() @@ -414,7 +415,7 @@ final class SourceLibrary: ObservableObject { let accessMethod = sourceAccessMethod let discoveryTask = Task.detached(priority: .userInitiated) { do { - _ = try await accessMethod.discoverItems(for: source) { item in + _ = try await accessMethod.discoverItems(for: source, mode: mode) { item in continuation.yield(item) } continuation.finish() @@ -428,7 +429,14 @@ final class SourceLibrary: ObservableObject { } } + let previousItemsByID = Dictionary(uniqueKeysWithValues: previousSource.rawItems.map { ($0.id, $0) }) + let previousSnapshotByItemID = Dictionary( + uniqueKeysWithValues: (previousSource.snapshot?.itemSnapshots ?? []).map { ($0.id, $0) } + ) + let shouldReconcileFromCache = mode == .reconcile && previousSource.hasCachedContent + var discoveredCount = 0 + var discoveredCollectionNames = Set() let discoveryStartTime = Date() for try await item in discoveryStream { @@ -437,15 +445,58 @@ final class SourceLibrary: ObservableObject { } discoveredCount += 1 + discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent) + let itemForIndex: MinecraftContentItem + if shouldReconcileFromCache, + let cachedItem = previousItemsByID[item.id], + shouldReuseCachedItem( + cachedItem, + forDiscoveredItem: item, + source: source, + previousSnapshot: previousSnapshotByItemID[item.id] + ) { + itemForIndex = cachedItem + } else { + itemForIndex = item + } + if let snapshot = await index.addDiscoveredItem( - item, + itemForIndex, discoveredCount: discoveredCount ) { applySnapshot(snapshot, to: sourceID) } scheduleSidebarFooterRefresh() - await enrichmentQueue.enqueue(item) + if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false { + await enrichmentQueue.enqueue(item) + } + } + + if mode == .reconcile, source.origin.kind == .connectedDevice { + let cachedItemsByCollection = Dictionary(grouping: previousSource.rawItems) { item in + item.collectionRootURL.lastPathComponent + } + + for (collectionName, cachedItems) in cachedItemsByCollection { + guard !cachedItems.isEmpty else { + continue + } + + guard !discoveredCollectionNames.contains(collectionName) else { + continue + } + + for cachedItem in cachedItems { + discoveredCount += 1 + if let snapshot = await index.addDiscoveredItem( + cachedItem, + discoveredCount: discoveredCount + ) { + applySnapshot(snapshot, to: sourceID) + } + } + } } logScanStage( @@ -481,8 +532,9 @@ final class SourceLibrary: ObservableObject { refreshSidebarFooterState() let previewStageStartTime = Date() + let previewSeedItems = await index.currentItems() let previewItems = await sourceAccessMethod.loadPreviewAssets( - for: await index.currentItems(), + for: previewSeedItems.filter { !$0.previewLoaded }, in: source ) for previewItem in previewItems { @@ -507,8 +559,9 @@ final class SourceLibrary: ObservableObject { if source.origin.kind == .connectedDevice { let sizeStageStartTime = Date() + let sizeSeedItems = await index.currentItems() let sizedItems = await sourceAccessMethod.loadSizeAssets( - for: await index.currentItems(), + for: sizeSeedItems.filter { !$0.sizeLoaded }, in: source ) for sizedItem in sizedItems { @@ -582,7 +635,7 @@ final class SourceLibrary: ObservableObject { } } } - for item in await index.currentItems() { + for item in await index.currentItems() where !item.sizeLoaded { await sizeQueue.enqueue(item) } await sizeQueue.finish() @@ -1483,7 +1536,7 @@ final class SourceLibrary: ObservableObject { ) { let nextAvailability = availability(for: device, hasMinecraftContainer: true) updateSource(sourceID) { source in - guard case .connectedDevice(_, let previousContainer) = source.origin else { + guard case .connectedDevice(let previousDevice, let previousContainer) = source.origin else { return } @@ -1491,8 +1544,17 @@ final class SourceLibrary: ObservableObject { $0.appID == previousContainer.appID && $0.accessMode == previousContainer.accessMode }) ?? previousContainer - source.origin = .connectedDevice(device: device, container: resolvedContainer) - source.displayName = "\(device.name) • \(resolvedContainer.appName)" + var resolvedDevice = device + resolvedDevice.name = preferredConnectedDeviceName( + currentName: device.name, + fallbackDeviceName: previousDevice.name, + fallbackDisplayName: source.displayName + ) + source.origin = .connectedDevice(device: resolvedDevice, container: resolvedContainer) + source.displayName = connectedDeviceSourceFactory.displayName( + for: resolvedDevice, + container: resolvedContainer + ) source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source) } let transition = updateAvailability(for: sourceID, to: nextAvailability) @@ -1503,12 +1565,14 @@ final class SourceLibrary: ObservableObject { } if transition.becameAvailable { - queueAutomaticSync( - for: sourceID, - reason: source.hasCachedContent - ? "Device available. Refreshing cached library..." - : "Device available. Scanning Minecraft library..." - ) + if shouldRefreshConnectedDeviceOnReconnect(source, device: device) { + queueAutomaticSync( + for: sourceID, + reason: source.hasCachedContent + ? "Device available. Refreshing cached library..." + : "Device available. Scanning Minecraft library..." + ) + } return } @@ -1544,6 +1608,42 @@ final class SourceLibrary: ObservableObject { } } + private func preferredConnectedDeviceName( + currentName: String, + fallbackDeviceName: String, + fallbackDisplayName: String + ) -> String { + if let sanitizedCurrentName = sanitizedConnectedDeviceName(currentName) { + return sanitizedCurrentName + } + + if let sanitizedFallbackName = sanitizedConnectedDeviceName(fallbackDeviceName) { + return sanitizedFallbackName + } + + if let displayNamePrefix = fallbackDisplayName.components(separatedBy: " • ").first, + let sanitizedDisplayName = sanitizedConnectedDeviceName(displayNamePrefix) { + return sanitizedDisplayName + } + + return "Unknown Device" + } + + private func sanitizedConnectedDeviceName(_ candidate: String) -> String? { + let trimmedCandidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedCandidate.isEmpty else { + return nil + } + + let normalizedCandidate = trimmedCandidate.lowercased() + guard normalizedCandidate != "unknown device", + normalizedCandidate != "unknown device..." else { + return nil + } + + return trimmedCandidate + } + private func restorePersistedSources() async { defer { isRestoringPersistedSources = false @@ -1566,29 +1666,64 @@ final class SourceLibrary: ObservableObject { accessDescriptor: record.accessDescriptor, availability: record.availability ) - source.displayName = record.displayName - source.rawItems = await restoreCachedImages(in: record.rawItems) + if case .connectedDevice(let device, let container) = source.origin { + var repairedDevice = device + repairedDevice.name = preferredConnectedDeviceName( + currentName: device.name, + fallbackDeviceName: "", + fallbackDisplayName: record.displayName + ) + source.origin = .connectedDevice(device: repairedDevice, container: container) + let persistedDeviceName = record.displayName.components(separatedBy: " • ").first ?? record.displayName + if sanitizedConnectedDeviceName(persistedDeviceName) == nil { + source.displayName = connectedDeviceSourceFactory.displayName( + for: repairedDevice, + container: container + ) + } else { + source.displayName = record.displayName + } + } else { + source.displayName = record.displayName + } + source.rawItems = record.rawItems source.indexedItemCount = record.rawItems.count - source.indexedDetailCount = source.rawItems.filter(\.metadataLoaded).count - source.previewLoadedCount = source.rawItems.filter(\.previewLoaded).count - source.sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count + source.indexedDetailCount = record.rawItems.filter(\.metadataLoaded).count + source.previewLoadedCount = record.rawItems.filter(\.previewLoaded).count + source.sizeLoadedCount = record.rawItems.filter(\.sizeLoaded).count source.lastScanDate = record.lastScanDate source.snapshot = record.snapshot + source.scanStatus = source.indexedItemCount == 0 + ? "No Minecraft items found." + : "Loaded \(source.indexedDetailCount) items." sources.append(source) rebuildNormalizedIndex(for: source.id) + } - updateSource(source.id) { source in - source.displayItems = source.displayItems.sorted(by: WorldScanner.sortItems) - source.previewLoadedCount = source.rawItems.filter(\.previewLoaded).count - source.sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count - source.scanStatus = source.indexedItemCount == 0 - ? "No Minecraft items found." - : "Loaded \(source.indexedDetailCount) items." + for record in records where record.needsRepair { + Task.detached(priority: .utility) { [persistenceStore] in + try? await persistenceStore.repair(record: record) } } sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } + refreshSidebarFooterState() + await Task.yield() + + for record in records { + let restoredItems = await restoreCachedImages(in: record.rawItems) + updateSource(record.sourceID) { source in + source.rawItems = restoredItems + source.indexedItemCount = restoredItems.count + source.indexedDetailCount = restoredItems.filter(\.metadataLoaded).count + source.previewLoadedCount = restoredItems.filter(\.previewLoaded).count + source.sizeLoadedCount = restoredItems.filter(\.sizeLoaded).count + source.lastScanDate = record.lastScanDate + source.snapshot = record.snapshot + } + rebuildNormalizedIndex(for: record.sourceID) + } await refreshConnectedDevices() await refreshLocalSources() @@ -1638,10 +1773,9 @@ final class SourceLibrary: ObservableObject { return true } - let fileManager = FileManager.default let sourceURL = record.folderURL - guard fileManager.fileExists(atPath: sourceURL.path) else { + guard FileManager.default.fileExists(atPath: sourceURL.path) else { return true } @@ -1653,19 +1787,8 @@ final class SourceLibrary: ObservableObject { } for (folderName, persistedCollection) in persistedCollections { - guard let currentCollection = currentCollections[folderName], currentCollection == persistedCollection else { - return true - } - } - - for itemSnapshot in snapshot.itemSnapshots { - let itemURL = sourceURL.appendingPathComponent(itemSnapshot.relativePath, isDirectory: true) - guard fileManager.fileExists(atPath: itemURL.path) else { - return true - } - - let modifiedDate = try? itemURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate - if modifiedDate != itemSnapshot.modifiedDate { + guard let currentCollection = currentCollections[folderName], + currentCollection.fingerprint == persistedCollection.fingerprint else { return true } } @@ -1682,10 +1805,9 @@ final class SourceLibrary: ObservableObject { return true } - let fileManager = FileManager.default let sourceURL = source.folderURL - guard fileManager.fileExists(atPath: sourceURL.path) else { + guard FileManager.default.fileExists(atPath: sourceURL.path) else { return false } @@ -1697,19 +1819,8 @@ final class SourceLibrary: ObservableObject { } for (folderName, persistedCollection) in persistedCollections { - guard let currentCollection = currentCollections[folderName], currentCollection == persistedCollection else { - return true - } - } - - for itemSnapshot in snapshot.itemSnapshots { - let itemURL = sourceURL.appendingPathComponent(itemSnapshot.relativePath, isDirectory: true) - guard fileManager.fileExists(atPath: itemURL.path) else { - return true - } - - let modifiedDate = try? itemURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate - if modifiedDate != itemSnapshot.modifiedDate { + guard let currentCollection = currentCollections[folderName], + currentCollection.fingerprint == persistedCollection.fingerprint else { return true } } @@ -1749,35 +1860,7 @@ final class SourceLibrary: ObservableObject { } private func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] { - let fileManager = FileManager.default - - return MinecraftContentType.allCases.compactMap { type -> CollectionSnapshot? in - let collectionURL = sourceURL.appendingPathComponent(type.collectionFolderName, isDirectory: true) - guard fileManager.fileExists(atPath: collectionURL.path) else { - return nil - } - - let children = (try? fileManager.contentsOfDirectory( - at: collectionURL, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - )) ?? [] - let childDirectoryCount = children.filter { - (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true - }.count - let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate - - return CollectionSnapshot( - folderName: type.collectionFolderName, - modifiedDate: modifiedDate, - childDirectoryCount: childDirectoryCount, - fingerprint: [ - type.collectionFolderName, - String(childDirectoryCount), - modifiedDate?.timeIntervalSince1970.formatted() ?? "nil" - ].joined(separator: "::") - ) - } + WorldScanner.collectionSnapshots(in: sourceURL) } private func buildDisplayItems( @@ -1829,6 +1912,13 @@ final class SourceLibrary: ObservableObject { } } + private func persistVisibleSourcesForShutdown() async { + let persistedSources = sources + for source in persistedSources { + try? await persistenceStore.save(source: source) + } + } + private func deletePersistedSource(withID sourceID: URL) { Task { try? await persistenceStore.deleteSource(withID: sourceID) @@ -1864,6 +1954,8 @@ final class SourceLibrary: ObservableObject { source.scanProgress = nil } + let mode: SourceDiscoveryMode = source.hasCachedContent ? .reconcile : .fullScan + let task = Task { [weak self] in do { try await Task.sleep(for: .seconds(resolvedDebounce)) @@ -1876,7 +1968,7 @@ final class SourceLibrary: ObservableObject { } await MainActor.run { - self.startScan(for: sourceID) + self.startScan(for: sourceID, mode: mode) } } @@ -2032,19 +2124,45 @@ final class SourceLibrary: ObservableObject { return false } + if connectedDeviceSourceHasRefreshDebt(source) { + return true + } + _ = device + return false + } + + private func shouldRefreshConnectedDeviceOnReconnect(_ source: MinecraftSource, device: ConnectedDevice) -> Bool { + guard !source.isScanning else { + return false + } + + if connectedDeviceSourceHasRefreshDebt(source) { + return true + } + guard let lastScanDate = source.lastScanDate else { return true } - let refreshInterval: TimeInterval - switch device.connection { - case .usb: - refreshInterval = Self.usbConnectedDeviceAutoRefreshInterval - case .network: - refreshInterval = Self.networkConnectedDeviceAutoRefreshInterval + let reconnectGracePeriod: TimeInterval = 5 * 60 + if Date().timeIntervalSince(lastScanDate) < reconnectGracePeriod { + return false } - return Date().timeIntervalSince(lastScanDate) >= refreshInterval + return shouldRefreshConnectedDeviceSource(source, device: device) + } + + private func connectedDeviceSourceHasRefreshDebt(_ source: MinecraftSource) -> Bool { + guard source.origin.kind == .connectedDevice else { + return false + } + + guard !source.rawItems.isEmpty else { + return true + } + + let itemCount = source.rawItems.count + return source.previewLoadedCount < itemCount || source.sizeLoadedCount < itemCount } private func cancelFooterReset() { @@ -2052,21 +2170,64 @@ final class SourceLibrary: ObservableObject { footerResetTask = nil } - private func initialScanStatus(for source: MinecraftSource) -> String { - switch source.origin { - case .localFolder: + private func initialScanStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String { + switch (source.origin, mode) { + case (.localFolder, .fullScan): return "Preparing folder scan..." - case .connectedDevice: + case (.localFolder, .reconcile): + return "Preparing cached library refresh..." + case (.connectedDevice, .fullScan): return "Connecting to device and discovering Minecraft items..." + case (.connectedDevice, .reconcile): + return "Connecting to device and refreshing cached library..." } } - private func scanningLibraryStatus(for source: MinecraftSource) -> String { - switch source.origin { - case .localFolder: + private func scanningLibraryStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String { + switch (source.origin, mode) { + case (.localFolder, .fullScan): return "Scanning Minecraft library..." - case .connectedDevice: + case (.localFolder, .reconcile): + return "Reconciling cached library..." + case (.connectedDevice, .fullScan): return "Scanning Minecraft library on device..." + case (.connectedDevice, .reconcile): + return "Reconciling cached device library..." + } + } + + private func shouldReuseCachedItem( + _ cachedItem: MinecraftContentItem, + forDiscoveredItem discoveredItem: MinecraftContentItem, + source: MinecraftSource, + previousSnapshot: ItemSnapshot? + ) -> Bool { + guard cachedItem.contentType == discoveredItem.contentType else { + return false + } + + guard cachedItem.metadataLoaded, cachedItem.previewLoaded, cachedItem.sizeLoaded else { + return false + } + + switch source.origin.kind { + case .localFolder: + guard let previousSnapshot else { + return false + } + + let currentModifiedDate = try? discoveredItem.folderURL + .resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate + return previousSnapshot.modifiedDate == currentModifiedDate + case .connectedDevice: + return cachedItem.folderName == discoveredItem.folderName + && cachedItem.displayName == discoveredItem.displayName + && cachedItem.hasKnownIcon == discoveredItem.hasKnownIcon + && cachedItem.packUUID == discoveredItem.packUUID + && cachedItem.packVersion == discoveredItem.packVersion + && cachedItem.packMetadataDetails == discoveredItem.packMetadataDetails + && cachedItem.packReferences == discoveredItem.packReferences } } @@ -2087,33 +2248,7 @@ final class SourceLibrary: ObservableObject { scanRootURL: URL, packMetadataByItemID: [URL: PackMetadata] ) -> SourceSnapshot { - let collectionSnapshots = MinecraftContentType.allCases.compactMap { type -> CollectionSnapshot? in - let collectionURL = scanRootURL.appendingPathComponent(type.collectionFolderName, isDirectory: true) - guard FileManager.default.fileExists(atPath: collectionURL.path) else { - return nil - } - - let children = (try? FileManager.default.contentsOfDirectory( - at: collectionURL, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - )) ?? [] - let childDirectoryCount = children.filter { - (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true - }.count - let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate - - return CollectionSnapshot( - folderName: type.collectionFolderName, - modifiedDate: modifiedDate, - childDirectoryCount: childDirectoryCount, - fingerprint: [ - type.collectionFolderName, - String(childDirectoryCount), - modifiedDate?.timeIntervalSince1970.formatted() ?? "nil" - ].joined(separator: "::") - ) - } + let collectionSnapshots = WorldScanner.collectionSnapshots(in: scanRootURL) let itemSnapshots = source.rawItems.map { item in let relativePath = item.folderURL.path.replacingOccurrences(of: scanRootURL.path + "/", with: "") @@ -2323,6 +2458,15 @@ private actor SourceIndexActor { orderedItemIDs.append(item.id) itemsByID[item.id] = item indexedItemCount = discoveredCount + if item.metadataLoaded { + indexedDetailCount += 1 + } + if item.previewLoaded { + previewLoadedCount += 1 + } + if item.sizeLoaded { + sizeLoadedCount += 1 + } return snapshotIfNeeded() } diff --git a/World Manager for Minecraft/Services/SourcePersistenceStore.swift b/World Manager for Minecraft/Services/SourcePersistenceStore.swift index f9bd70d..2fccdfc 100644 --- a/World Manager for Minecraft/Services/SourcePersistenceStore.swift +++ b/World Manager for Minecraft/Services/SourcePersistenceStore.swift @@ -19,6 +19,7 @@ struct PersistedSourceRecord: Sendable { let rawItems: [MinecraftContentItem] let snapshot: SourceSnapshot? let lastScanDate: Date? + let needsRepair: Bool } private struct PersistedItemSnapshotPayload: Codable, Sendable { @@ -173,6 +174,7 @@ private struct PersistedSourceSnapshotPayload: Codable, Sendable { actor SourcePersistenceStore { static let shared = SourcePersistenceStore() + private static let cacheGeneration = "v2026-05-28" private let databaseURL: URL @@ -181,7 +183,10 @@ actor SourcePersistenceStore { ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true) let directoryURL = applicationSupportURL .appendingPathComponent("World Manager for Minecraft", isDirectory: true) - self.databaseURL = directoryURL.appendingPathComponent("LibraryCache.sqlite", isDirectory: false) + self.databaseURL = directoryURL.appendingPathComponent( + "LibraryCache-\(Self.cacheGeneration).sqlite", + isDirectory: false + ) } init(databaseURL: URL) { @@ -214,21 +219,17 @@ actor SourcePersistenceStore { let sourceID = sourceID(from: statement) ?? URL(fileURLWithPath: String(cString: folderPathPointer)).standardizedFileURL let folderPath = String(cString: folderPathPointer) - let origin = try decodeColumn(MinecraftSourceOrigin.self, statement: statement, columnIndex: 2) - ?? .localFolder(bookmarkData: nil) - let accessDescriptor = try decodeColumn(SourceAccessDescriptor.self, statement: statement, columnIndex: 3) - ?? SourceAccessDescriptor( - accessorIdentifier: origin.defaultAccessorIdentifier, - kind: origin.kind, - capabilities: origin.defaultCapabilities, - refreshStrategy: origin.defaultRefreshStrategy - ) - let availability = decodeAvailability(statement: statement, columnIndex: 4) let bookmarkData = decodeDataColumn(statement: statement, columnIndex: 5) + let originResult = decodeOrigin(statement: statement, columnIndex: 2, bookmarkData: bookmarkData) + let origin = originResult.value + let accessDescriptorResult = decodeAccessDescriptor(statement: statement, columnIndex: 3, origin: origin) + let accessDescriptor = accessDescriptorResult.value + let availability = decodeAvailability(statement: statement, columnIndex: 4) let displayName = String(cString: sqlite3_column_text(statement, 6)) - let rawItems = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: 7) ?? [] - let snapshotPayload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: 8) - let snapshot = snapshotPayload?.sourceSnapshot + let rawItemsResult = decodeRawItems(statement: statement, columnIndex: 7) + let snapshotResult = decodeSnapshot(statement: statement, columnIndex: 8) + let rawItems = rawItemsResult.value + let snapshot = snapshotResult.value let lastScanDate = sqlite3_column_type(statement, 9) == SQLITE_NULL ? nil : Date(timeIntervalSince1970: sqlite3_column_double(statement, 9)) @@ -244,7 +245,11 @@ actor SourcePersistenceStore { displayName: displayName, rawItems: rawItems, snapshot: snapshot, - lastScanDate: lastScanDate + lastScanDate: lastScanDate, + needsRepair: originResult.didRepair + || accessDescriptorResult.didRepair + || rawItemsResult.didRepair + || snapshotResult.didRepair ) ) } @@ -256,6 +261,32 @@ actor SourcePersistenceStore { let database = try openDatabase() defer { sqlite3_close(database) } + try save( + record: PersistedSourceRecord( + sourceID: source.id, + folderURL: source.folderURL, + origin: source.origin, + accessDescriptor: source.accessDescriptor, + availability: source.availability, + bookmarkData: source.bookmarkData, + displayName: source.displayName, + rawItems: source.rawItems, + snapshot: source.snapshot, + lastScanDate: source.lastScanDate, + needsRepair: false + ), + on: database + ) + } + + func repair(record: PersistedSourceRecord) throws { + let database = try openDatabase() + defer { sqlite3_close(database) } + + try save(record: record, on: database) + } + + private func save(record: PersistedSourceRecord, on database: OpaquePointer?) throws { let sql = """ INSERT INTO source_cache ( source_id, @@ -287,17 +318,17 @@ actor SourcePersistenceStore { } defer { sqlite3_finalize(statement) } - try bindText(normalizedIdentifierText(for: source.id), to: statement, at: 1) - try bindText(source.folderURL.path, to: statement, at: 2) - try bindJSON(source.origin, to: statement, at: 3) - try bindJSON(source.accessDescriptor, to: statement, at: 4) - try bindText(source.availability.rawValue, to: statement, at: 5) - try bindData(source.bookmarkData, to: statement, at: 6) - try bindText(source.displayName, to: statement, at: 7) - try bindJSON(source.rawItems, to: statement, at: 8) - try bindJSON(source.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 9) + try bindText(normalizedIdentifierText(for: record.sourceID), to: statement, at: 1) + try bindText(record.folderURL.path, to: statement, at: 2) + try bindJSON(record.origin, to: statement, at: 3) + try bindJSON(record.accessDescriptor, to: statement, at: 4) + try bindText(record.availability.rawValue, to: statement, at: 5) + try bindData(record.bookmarkData, to: statement, at: 6) + try bindText(record.displayName, to: statement, at: 7) + try bindJSON(record.rawItems, to: statement, at: 8) + try bindJSON(record.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 9) - if let lastScanDate = source.lastScanDate { + if let lastScanDate = record.lastScanDate { sqlite3_bind_double(statement, 10, lastScanDate.timeIntervalSince1970) } else { sqlite3_bind_null(statement, 10) @@ -450,19 +481,10 @@ actor SourcePersistenceStore { } private func decodeColumn(_ type: T.Type, statement: OpaquePointer?, columnIndex: Int32) throws -> T? { - guard sqlite3_column_type(statement, columnIndex) != SQLITE_NULL else { + guard let data = decodeDataColumn(statement: statement, columnIndex: columnIndex) else { return nil } - let byteCount = Int(sqlite3_column_bytes(statement, columnIndex)) - guard - byteCount > 0, - let bytes = sqlite3_column_blob(statement, columnIndex) - else { - return nil - } - - let data = Data(bytes: bytes, count: byteCount) return try JSONDecoder().decode(type, from: data) } @@ -482,6 +504,98 @@ actor SourcePersistenceStore { return Data(bytes: bytes, count: byteCount) } + private func decodeOrigin( + statement: OpaquePointer?, + columnIndex: Int32, + bookmarkData: Data? + ) -> (value: MinecraftSourceOrigin, didRepair: Bool) { + do { + if let origin = try decodeColumn(MinecraftSourceOrigin.self, statement: statement, columnIndex: columnIndex) { + return (origin, false) + } + } catch { + } + + return (.localFolder(bookmarkData: bookmarkData), true) + } + + private func decodeAccessDescriptor( + statement: OpaquePointer?, + columnIndex: Int32, + origin: MinecraftSourceOrigin + ) -> (value: SourceAccessDescriptor, didRepair: Bool) { + do { + if let accessDescriptor = try decodeColumn(SourceAccessDescriptor.self, statement: statement, columnIndex: columnIndex) { + return (accessDescriptor, false) + } + } catch { + } + + return ( + SourceAccessDescriptor( + accessorIdentifier: origin.defaultAccessorIdentifier, + kind: origin.kind, + capabilities: origin.defaultCapabilities, + refreshStrategy: origin.defaultRefreshStrategy + ), + true + ) + } + + private func decodeRawItems(statement: OpaquePointer?, columnIndex: Int32) -> (value: [MinecraftContentItem], didRepair: Bool) { + do { + if let items = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: columnIndex) { + return (items, false) + } + + return ([], false) + } catch { + guard let data = decodeDataColumn(statement: statement, columnIndex: columnIndex) else { + return ([], true) + } + + let items = decodeArrayElementsLeniently(MinecraftContentItem.self, from: data) + return (items, true) + } + } + + private func decodeSnapshot(statement: OpaquePointer?, columnIndex: Int32) -> (value: SourceSnapshot?, didRepair: Bool) { + do { + let payload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: columnIndex) + return (payload?.sourceSnapshot, false) + } catch { + return (nil, true) + } + } + + private func decodeArrayElementsLeniently(_ type: Element.Type, from data: Data) -> [Element] { + guard let rawArray = try? JSONSerialization.jsonObject(with: data) as? [Any] else { + return [] + } + + let decoder = JSONDecoder() + var decodedElements: [Element] = [] + decodedElements.reserveCapacity(rawArray.count) + + for rawElement in rawArray { + guard JSONSerialization.isValidJSONObject(rawElement) else { + continue + } + + guard let elementData = try? JSONSerialization.data(withJSONObject: rawElement) else { + continue + } + + guard let element = try? decoder.decode(Element.self, from: elementData) else { + continue + } + + decodedElements.append(element) + } + + return decodedElements + } + private func sourceID(from statement: OpaquePointer?) -> URL? { guard let pointer = sqlite3_column_text(statement, 0) else { return nil diff --git a/World Manager for Minecraft/Services/WorldScanner.swift b/World Manager for Minecraft/Services/WorldScanner.swift index edd78f0..0de472a 100644 --- a/World Manager for Minecraft/Services/WorldScanner.swift +++ b/World Manager for Minecraft/Services/WorldScanner.swift @@ -86,6 +86,66 @@ enum WorldScanner { return discoveredItems } + nonisolated static func discoverItems( + inCollectionRootURL collectionRootURL: URL, + contentType: MinecraftContentType, + onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in } + ) throws -> [MinecraftContentItem] { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: collectionRootURL.path) else { + return [] + } + + let childDirectories = try immediateChildDirectories(of: collectionRootURL, fileManager: fileManager) + var discoveredItems: [MinecraftContentItem] = [] + var seenItemURLs = Set() + + for childDirectory in childDirectories { + let itemURL = childDirectory.standardizedFileURL + guard !seenItemURLs.contains(itemURL) else { + continue + } + + guard isCandidateItem(at: childDirectory, type: contentType, fileManager: fileManager) else { + continue + } + + let item = MinecraftContentItem( + folderURL: childDirectory, + folderName: childDirectory.lastPathComponent, + contentType: contentType, + collectionRootURL: collectionRootURL + ) + seenItemURLs.insert(itemURL) + discoveredItems.append(item) + onDiscovered(item) + + if contentType == .world { + let embeddedPackItems = discoverEmbeddedPackItems( + in: childDirectory, + fileManager: fileManager, + seenItemURLs: &seenItemURLs + ) + discoveredItems.append(contentsOf: embeddedPackItems) + embeddedPackItems.forEach(onDiscovered) + } + } + + discoveredItems.sort(by: sortItems) + return discoveredItems + } + + nonisolated static func collectionSnapshots(in sourceRootURL: URL) -> [CollectionSnapshot] { + let fileManager = FileManager.default + return MinecraftContentType.allCases.compactMap { type in + collectionSnapshot( + for: sourceRootURL.appendingPathComponent(type.collectionFolderName, isDirectory: true), + contentType: type, + fileManager: fileManager + ) + } + } + nonisolated static func enrich(item: MinecraftContentItem) async -> MinecraftContentItem { let fileManager = FileManager.default var enrichedItem = item @@ -146,6 +206,52 @@ enum WorldScanner { } } + nonisolated private static func collectionSnapshot( + for collectionURL: URL, + contentType: MinecraftContentType, + fileManager: FileManager + ) -> CollectionSnapshot? { + guard fileManager.fileExists(atPath: collectionURL.path) else { + return nil + } + + let children = (try? fileManager.contentsOfDirectory( + at: collectionURL, + includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + )) ?? [] + let childDirectorySnapshots = children.compactMap { childURL -> (name: String, modifiedDate: Date?)? in + guard (try? childURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { + return nil + } + + let modifiedDate = try? childURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + return (childURL.lastPathComponent, modifiedDate) + }.sorted { + $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + + let modifiedDate = try? collectionURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + let childFingerprint = childDirectorySnapshots.map { child in + [ + child.name, + child.modifiedDate?.timeIntervalSince1970.formatted() ?? "nil" + ].joined(separator: "@") + }.joined(separator: "|") + + return CollectionSnapshot( + folderName: contentType.collectionFolderName, + modifiedDate: modifiedDate, + childDirectoryCount: childDirectorySnapshots.count, + fingerprint: [ + contentType.collectionFolderName, + String(childDirectorySnapshots.count), + modifiedDate?.timeIntervalSince1970.formatted() ?? "nil", + childFingerprint + ].joined(separator: "::") + ) + } + nonisolated fileprivate static func immediateChildDirectories(of directoryURL: URL, fileManager: FileManager) throws -> [URL] { let children = try fileManager.contentsOfDirectory( at: directoryURL, diff --git a/World Manager for Minecraft/SidebarColumnViews.swift b/World Manager for Minecraft/SidebarColumnViews.swift index 4d7faab..f373d11 100644 --- a/World Manager for Minecraft/SidebarColumnViews.swift +++ b/World Manager for Minecraft/SidebarColumnViews.swift @@ -85,7 +85,7 @@ struct SourcesSidebarView: View { ) .tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?) .listRowSeparator(.hidden) - .padding(.top, 6) + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 8)) .contextMenu { Button("Rescan \"\(source.displayName)\"") { rescanSourceAction(source) @@ -113,7 +113,7 @@ struct SourcesSidebarView: View { } : nil ) .listRowSeparator(.hidden) - .padding(.top, 6) + .listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8)) } } @@ -233,11 +233,7 @@ private struct SourceHeaderRow: View { } private var backgroundStyle: AnyShapeStyle { - if isSelected { - return AnyShapeStyle(Color.appAccent.opacity(0.14)) - } - - if isHovering { + if isHovering && !isSelected { return AnyShapeStyle(.secondary.opacity(0.08)) } diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift index 681284b..cabac0f 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift @@ -101,8 +101,10 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod { nonisolated func discoverItems( for source: MinecraftSource, + mode: SourceDiscoveryMode, onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void ) async throws -> [MinecraftContentItem] { + _ = mode guard case .connectedDevice(_, let container) = source.origin else { throw SourceAccessError.accessFailed( reason: "The selected source is not backed by a connected mobile device." diff --git a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift index aef1463..799e4fe 100644 --- a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift +++ b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift @@ -7,12 +7,18 @@ import Foundation +enum SourceDiscoveryMode: Sendable { + case fullScan + case reconcile +} + protocol SourceAccessMethod: Sendable { nonisolated var accessorIdentifier: SourceAccessorIdentifier { get } nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability nonisolated func discoverItems( for source: MinecraftSource, + mode: SourceDiscoveryMode, onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void ) async throws -> [MinecraftContentItem] nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem @@ -46,9 +52,11 @@ extension SourceAccessMethod { nonisolated func discoverItems( for source: MinecraftSource, + mode: SourceDiscoveryMode, onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void ) async throws -> [MinecraftContentItem] { _ = source + _ = mode _ = onDiscovered return [] } @@ -147,9 +155,14 @@ struct SourceAccessCoordinator: SourceAccessMethod { nonisolated func discoverItems( for source: MinecraftSource, + mode: SourceDiscoveryMode, onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void ) async throws -> [MinecraftContentItem] { - return try await accessMethod(for: source).discoverItems(for: source, onDiscovered: onDiscovered) + return try await accessMethod(for: source).discoverItems( + for: source, + mode: mode, + onDiscovered: onDiscovered + ) } nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor { diff --git a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift index 2018a61..7e9c9f2 100644 --- a/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift +++ b/World Manager for Minecraft/SourceAccess/LocalFolder/LocalFolderSourceAccess.swift @@ -46,6 +46,7 @@ struct LocalFolderSourceAccess: SourceAccessMethod { nonisolated func discoverItems( for source: MinecraftSource, + mode: SourceDiscoveryMode, onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void ) async throws -> [MinecraftContentItem] { guard case .localFolder(let bookmarkData) = source.origin else { @@ -80,6 +81,16 @@ struct LocalFolderSourceAccess: SourceAccessMethod { } } + if case .reconcile = mode, + let snapshot = source.snapshot { + return try discoverItemsByReconcilingCache( + for: source, + snapshot: snapshot, + resolvedURL: resolvedURL, + onDiscovered: onDiscovered + ) + } + return try WorldScanner.discoverItems(in: resolvedURL, onDiscovered: onDiscovered) } @@ -120,4 +131,63 @@ struct LocalFolderSourceAccess: SourceAccessMethod { _ = source return item.folderURL } + + nonisolated private func discoverItemsByReconcilingCache( + for source: MinecraftSource, + snapshot: SourceSnapshot, + resolvedURL: URL, + onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void + ) throws -> [MinecraftContentItem] { + let currentCollections = Dictionary( + uniqueKeysWithValues: WorldScanner.collectionSnapshots(in: resolvedURL).map { ($0.folderName, $0) } + ) + let previousCollections = Dictionary( + uniqueKeysWithValues: snapshot.collectionSnapshots.map { ($0.folderName, $0) } + ) + + var changedCollectionNames = Set() + for type in MinecraftContentType.allCases { + let collectionName = type.collectionFolderName + let currentFingerprint = currentCollections[collectionName]?.fingerprint + let previousFingerprint = previousCollections[collectionName]?.fingerprint + if currentFingerprint != previousFingerprint { + changedCollectionNames.insert(collectionName) + } + } + + let unchangedCollectionNames = Set(currentCollections.keys).subtracting(changedCollectionNames) + var reconciledItems = source.rawItems.filter { item in + guard let collectionName = topLevelCollectionName(for: item, sourceRootURL: resolvedURL) else { + return false + } + + return unchangedCollectionNames.contains(collectionName) + } + + for type in MinecraftContentType.allCases { + let collectionName = type.collectionFolderName + guard changedCollectionNames.contains(collectionName) else { + continue + } + + let collectionURL = resolvedURL.appendingPathComponent(collectionName, isDirectory: true) + let discoveredItems = try WorldScanner.discoverItems( + inCollectionRootURL: collectionURL, + contentType: type + ) + reconciledItems.append(contentsOf: discoveredItems) + } + + reconciledItems.sort(by: WorldScanner.sortItems) + for item in reconciledItems { + onDiscovered(item) + } + return reconciledItems + } + + nonisolated private func topLevelCollectionName(for item: MinecraftContentItem, sourceRootURL: URL) -> String? { + let relativePath = item.folderURL.path.replacingOccurrences(of: sourceRootURL.path + "/", with: "") + let components = relativePath.split(separator: "/") + return components.first.map(String.init) + } }