Source cache syncing behavior

This commit is contained in:
John Burwell 2026-05-28 17:16:47 -05:00
parent 42366c1713
commit b7ea9ce89d
10 changed files with 663 additions and 196 deletions

View File

@ -96,6 +96,7 @@ struct ContentView: View {
directoryPreviewLimit: directoryPreviewLimit, directoryPreviewLimit: directoryPreviewLimit,
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty, isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty,
isPerformingItemAction: isPerformingItemAction, isPerformingItemAction: isPerformingItemAction,
areFileActionsEnabled: areCurrentItemFileActionsEnabled,
exportTitle: currentSelectedItem.map(primaryActionTitle(for:)), exportTitle: currentSelectedItem.map(primaryActionTitle(for:)),
exportAction: { exportAction: {
guard let item = currentSelectedItem else { guard let item = currentSelectedItem else {
@ -122,7 +123,7 @@ struct ContentView: View {
.frame(minWidth: 450) .frame(minWidth: 450)
} }
.overlay { .overlay {
if library.isRestoringPersistedSources { if library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty {
LaunchRestoreOverlayView() LaunchRestoreOverlayView()
} }
} }
@ -141,10 +142,7 @@ struct ContentView: View {
.task { .task {
AppTerminationCoordinator.shared.register(library: library) AppTerminationCoordinator.shared.register(library: library)
} }
.disabled(library.isRestoringPersistedSources) .disabled(library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
library.shutdown()
}
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in .onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else { guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
return return
@ -318,6 +316,14 @@ struct ContentView: View {
return nil return nil
} }
private var areCurrentItemFileActionsEnabled: Bool {
guard currentSelectedItem != nil else {
return false
}
return currentSource?.availability == .available
}
private var searchScopeTitle: String { private var searchScopeTitle: String {
switch selectedSidebarSelection { switch selectedSidebarSelection {
case .some(.source(let sourceID)): case .some(.source(let sourceID)):
@ -438,16 +444,19 @@ struct ContentView: View {
Button("Share...") { Button("Share...") {
shareItem(item, from: nil) shareItem(item, from: nil)
} }
.disabled(!areFileActionsEnabled(for: item))
Button(exportMenuTitle(for: item)) { Button(exportMenuTitle(for: item)) {
saveItem(item) saveItem(item)
} }
.disabled(!areFileActionsEnabled(for: item))
Divider() Divider()
Button("Reveal in Finder") { Button("Reveal in Finder") {
revealInFinder(item) revealInFinder(item)
} }
.disabled(!areFileActionsEnabled(for: item))
} }
private func exportMenuTitle(for item: MinecraftContentItem) -> String { 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) { private func saveItem(_ item: MinecraftContentItem) {
guard !isPerformingItemAction else { guard !isPerformingItemAction, areFileActionsEnabled(for: item) else {
return return
} }
let source = currentSource let source = currentSource
@ -653,8 +670,9 @@ struct ContentView: View {
let panel = NSSavePanel() let panel = NSSavePanel()
panel.canCreateDirectories = true panel.canCreateDirectories = true
panel.isExtensionHidden = false panel.isExtensionHidden = false
panel.title = primaryActionTitle(for: item) panel.showsTagField = false
panel.message = primaryActionSubtitle(for: item) panel.title = exportMenuTitle(for: item)
panel.prompt = "Save"
panel.nameFieldStringValue = ContentPackageExporter.suggestedBaseFilename(for: item) panel.nameFieldStringValue = ContentPackageExporter.suggestedBaseFilename(for: item)
panel.allowedContentTypes = [archiveType(for: item)] panel.allowedContentTypes = [archiveType(for: item)]
@ -693,7 +711,7 @@ struct ContentView: View {
} }
private func shareItem(_ item: MinecraftContentItem, from anchorView: NSView?) { private func shareItem(_ item: MinecraftContentItem, from anchorView: NSView?) {
guard !isPerformingItemAction else { guard !isPerformingItemAction, areFileActionsEnabled(for: item) else {
return return
} }
let source = currentSource let source = currentSource
@ -742,7 +760,7 @@ struct ContentView: View {
} }
private func revealInFinder(_ item: MinecraftContentItem) { private func revealInFinder(_ item: MinecraftContentItem) {
guard let source = currentSource else { guard let source = currentSource, areFileActionsEnabled(for: item) else {
return return
} }

View File

@ -20,6 +20,7 @@ struct ItemDetailColumnView: View {
let directoryPreviewLimit: Int let directoryPreviewLimit: Int
let isEmpty: Bool let isEmpty: Bool
let isPerformingItemAction: Bool let isPerformingItemAction: Bool
let areFileActionsEnabled: Bool
let exportTitle: String? let exportTitle: String?
let exportAction: () -> Void let exportAction: () -> Void
let revealAction: () -> Void let revealAction: () -> Void
@ -42,6 +43,7 @@ struct ItemDetailColumnView: View {
contents: contents, contents: contents,
directoryPreviewLimit: directoryPreviewLimit, directoryPreviewLimit: directoryPreviewLimit,
isPerformingItemAction: isPerformingItemAction, isPerformingItemAction: isPerformingItemAction,
areFileActionsEnabled: areFileActionsEnabled,
exportTitle: exportTitle, exportTitle: exportTitle,
exportAction: exportAction, exportAction: exportAction,
revealAction: revealAction, revealAction: revealAction,
@ -60,7 +62,7 @@ struct ItemDetailColumnView: View {
Button(action: exportAction) { Button(action: exportAction) {
Image(systemName: "arrow.down.circle") Image(systemName: "arrow.down.circle")
} }
.disabled(isPerformingItemAction) .disabled(isPerformingItemAction || !areFileActionsEnabled)
.help(exportTitle ?? "Export") .help(exportTitle ?? "Export")
} }
@ -68,14 +70,14 @@ struct ItemDetailColumnView: View {
Button(action: revealAction) { Button(action: revealAction) {
Image(systemName: "folder") Image(systemName: "folder")
} }
.disabled(isPerformingItemAction) .disabled(isPerformingItemAction || !areFileActionsEnabled)
.help("Reveal in Finder") .help("Reveal in Finder")
} }
ToolbarItem { ToolbarItem {
ToolbarShareButton( ToolbarShareButton(
systemImage: "square.and.arrow.up", systemImage: "square.and.arrow.up",
isEnabled: !isPerformingItemAction isEnabled: !isPerformingItemAction && areFileActionsEnabled
) { anchorView in ) { anchorView in
shareAction(anchorView) shareAction(anchorView)
} }
@ -626,6 +628,7 @@ struct ItemDetailView: View {
let contents: [DirectoryPreviewEntry] let contents: [DirectoryPreviewEntry]
let directoryPreviewLimit: Int let directoryPreviewLimit: Int
let isPerformingItemAction: Bool let isPerformingItemAction: Bool
let areFileActionsEnabled: Bool
let exportTitle: String? let exportTitle: String?
let exportAction: () -> Void let exportAction: () -> Void
let revealAction: () -> Void let revealAction: () -> Void
@ -1186,7 +1189,7 @@ struct ItemDetailView: View {
ActionPillButton( ActionPillButton(
title: actionRowExportTitle, title: actionRowExportTitle,
systemImage: "arrow.down.circle.fill", systemImage: "arrow.down.circle.fill",
isDisabled: isPerformingItemAction, isDisabled: isPerformingItemAction || !areFileActionsEnabled,
prominence: .primary, prominence: .primary,
action: exportAction action: exportAction
) )
@ -1194,7 +1197,7 @@ struct ItemDetailView: View {
ActionPillButton( ActionPillButton(
title: "Reveal", title: "Reveal",
systemImage: "folder.fill", systemImage: "folder.fill",
isDisabled: isPerformingItemAction, isDisabled: isPerformingItemAction || !areFileActionsEnabled,
prominence: .secondary, prominence: .secondary,
action: revealAction action: revealAction
) )
@ -1202,7 +1205,7 @@ struct ItemDetailView: View {
SharingPillButton( SharingPillButton(
title: "Share", title: "Share",
systemImage: "square.and.arrow.up", systemImage: "square.and.arrow.up",
isEnabled: !isPerformingItemAction, isEnabled: !isPerformingItemAction && areFileActionsEnabled,
action: shareAction action: shareAction
) )
} }

View File

@ -355,6 +355,7 @@ struct ItemDetailColumnPreviewContainer: View {
directoryPreviewLimit: 12, directoryPreviewLimit: 12,
isEmpty: false, isEmpty: false,
isPerformingItemAction: false, isPerformingItemAction: false,
areFileActionsEnabled: true,
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle, exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
exportAction: {}, exportAction: {},
revealAction: {}, revealAction: {},

View File

@ -168,6 +168,7 @@ final class SourceLibrary: ObservableObject {
return return
} }
await persistVisibleSourcesForShutdown()
shutdown() shutdown()
try? await Task.sleep(for: .seconds(timeout)) try? await Task.sleep(for: .seconds(timeout))
} }
@ -183,7 +184,7 @@ final class SourceLibrary: ObservableObject {
} }
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source) source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
} }
startScan(for: normalizedURL) startScan(for: normalizedURL, mode: .fullScan)
return normalizedURL return normalizedURL
} }
@ -225,7 +226,7 @@ final class SourceLibrary: ObservableObject {
persistSourceIfAvailable(withID: source.id) persistSourceIfAvailable(withID: source.id)
} }
if shouldScan { if shouldScan {
startScan(for: source.id) startScan(for: source.id, mode: .fullScan)
} }
return source.id return source.id
@ -236,7 +237,7 @@ final class SourceLibrary: ObservableObject {
} }
func rescanSource(withID sourceID: URL) { func rescanSource(withID sourceID: URL) {
startScan(for: sourceID) startScan(for: sourceID, mode: .fullScan)
} }
func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] { func listContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] {
@ -305,7 +306,7 @@ final class SourceLibrary: ObservableObject {
return "Scanning \(scanningSources.count) sources..." return "Scanning \(scanningSources.count) sources..."
} }
private func startScan(for sourceID: URL) { private func startScan(for sourceID: URL, mode: SourceDiscoveryMode) {
guard !isShuttingDown else { guard !isShuttingDown else {
return return
} }
@ -319,7 +320,7 @@ final class SourceLibrary: ObservableObject {
return return
} }
await self.scanSource(withID: sourceID) await self.scanSource(withID: sourceID, mode: mode)
} }
scanTasks[sourceID] = task scanTasks[sourceID] = task
@ -333,7 +334,7 @@ final class SourceLibrary: ObservableObject {
sources.contains { $0.isScanning && $0.origin.kind == .connectedDevice } 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<Void, Never>] = [] var workerTasks: [Task<Void, Never>] = []
var sizeWorkerTasks: [Task<Void, Never>] = [] var sizeWorkerTasks: [Task<Void, Never>] = []
let scanStartTime = Date() let scanStartTime = Date()
@ -353,7 +354,7 @@ final class SourceLibrary: ObservableObject {
source.isScanning = true source.isScanning = true
source.scanError = nil source.scanError = nil
source.scanDiagnostic = nil source.scanDiagnostic = nil
source.scanStatus = initialScanStatus(for: source) source.scanStatus = initialScanStatus(for: source, mode: mode)
source.scanProgress = nil source.scanProgress = nil
source.indexedItemCount = 0 source.indexedItemCount = 0
source.indexedDetailCount = 0 source.indexedDetailCount = 0
@ -380,7 +381,7 @@ final class SourceLibrary: ObservableObject {
updateSource(sourceID) { source in updateSource(sourceID) { source in
source.availability = .available source.availability = .available
source.scanStatus = scanningLibraryStatus(for: source) source.scanStatus = scanningLibraryStatus(for: source, mode: mode)
} }
refreshSidebarFooterState() refreshSidebarFooterState()
@ -414,7 +415,7 @@ final class SourceLibrary: ObservableObject {
let accessMethod = sourceAccessMethod let accessMethod = sourceAccessMethod
let discoveryTask = Task.detached(priority: .userInitiated) { let discoveryTask = Task.detached(priority: .userInitiated) {
do { do {
_ = try await accessMethod.discoverItems(for: source) { item in _ = try await accessMethod.discoverItems(for: source, mode: mode) { item in
continuation.yield(item) continuation.yield(item)
} }
continuation.finish() 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 discoveredCount = 0
var discoveredCollectionNames = Set<String>()
let discoveryStartTime = Date() let discoveryStartTime = Date()
for try await item in discoveryStream { for try await item in discoveryStream {
@ -437,16 +445,59 @@ final class SourceLibrary: ObservableObject {
} }
discoveredCount += 1 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( if let snapshot = await index.addDiscoveredItem(
item, itemForIndex,
discoveredCount: discoveredCount discoveredCount: discoveredCount
) { ) {
applySnapshot(snapshot, to: sourceID) applySnapshot(snapshot, to: sourceID)
} }
scheduleSidebarFooterRefresh() scheduleSidebarFooterRefresh()
if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false {
await enrichmentQueue.enqueue(item) 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( logScanStage(
"Discovery", "Discovery",
@ -481,8 +532,9 @@ final class SourceLibrary: ObservableObject {
refreshSidebarFooterState() refreshSidebarFooterState()
let previewStageStartTime = Date() let previewStageStartTime = Date()
let previewSeedItems = await index.currentItems()
let previewItems = await sourceAccessMethod.loadPreviewAssets( let previewItems = await sourceAccessMethod.loadPreviewAssets(
for: await index.currentItems(), for: previewSeedItems.filter { !$0.previewLoaded },
in: source in: source
) )
for previewItem in previewItems { for previewItem in previewItems {
@ -507,8 +559,9 @@ final class SourceLibrary: ObservableObject {
if source.origin.kind == .connectedDevice { if source.origin.kind == .connectedDevice {
let sizeStageStartTime = Date() let sizeStageStartTime = Date()
let sizeSeedItems = await index.currentItems()
let sizedItems = await sourceAccessMethod.loadSizeAssets( let sizedItems = await sourceAccessMethod.loadSizeAssets(
for: await index.currentItems(), for: sizeSeedItems.filter { !$0.sizeLoaded },
in: source in: source
) )
for sizedItem in sizedItems { 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.enqueue(item)
} }
await sizeQueue.finish() await sizeQueue.finish()
@ -1483,7 +1536,7 @@ final class SourceLibrary: ObservableObject {
) { ) {
let nextAvailability = availability(for: device, hasMinecraftContainer: true) let nextAvailability = availability(for: device, hasMinecraftContainer: true)
updateSource(sourceID) { source in updateSource(sourceID) { source in
guard case .connectedDevice(_, let previousContainer) = source.origin else { guard case .connectedDevice(let previousDevice, let previousContainer) = source.origin else {
return return
} }
@ -1491,8 +1544,17 @@ final class SourceLibrary: ObservableObject {
$0.appID == previousContainer.appID && $0.accessMode == previousContainer.accessMode $0.appID == previousContainer.appID && $0.accessMode == previousContainer.accessMode
}) ?? previousContainer }) ?? previousContainer
source.origin = .connectedDevice(device: device, container: resolvedContainer) var resolvedDevice = device
source.displayName = "\(device.name)\(resolvedContainer.appName)" 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) source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
} }
let transition = updateAvailability(for: sourceID, to: nextAvailability) let transition = updateAvailability(for: sourceID, to: nextAvailability)
@ -1503,12 +1565,14 @@ final class SourceLibrary: ObservableObject {
} }
if transition.becameAvailable { if transition.becameAvailable {
if shouldRefreshConnectedDeviceOnReconnect(source, device: device) {
queueAutomaticSync( queueAutomaticSync(
for: sourceID, for: sourceID,
reason: source.hasCachedContent reason: source.hasCachedContent
? "Device available. Refreshing cached library..." ? "Device available. Refreshing cached library..."
: "Device available. Scanning Minecraft library..." : "Device available. Scanning Minecraft library..."
) )
}
return 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 { private func restorePersistedSources() async {
defer { defer {
isRestoringPersistedSources = false isRestoringPersistedSources = false
@ -1566,29 +1666,64 @@ final class SourceLibrary: ObservableObject {
accessDescriptor: record.accessDescriptor, accessDescriptor: record.accessDescriptor,
availability: record.availability availability: record.availability
) )
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 source.displayName = record.displayName
source.rawItems = await restoreCachedImages(in: record.rawItems) }
} else {
source.displayName = record.displayName
}
source.rawItems = record.rawItems
source.indexedItemCount = record.rawItems.count source.indexedItemCount = record.rawItems.count
source.indexedDetailCount = source.rawItems.filter(\.metadataLoaded).count source.indexedDetailCount = record.rawItems.filter(\.metadataLoaded).count
source.previewLoadedCount = source.rawItems.filter(\.previewLoaded).count source.previewLoadedCount = record.rawItems.filter(\.previewLoaded).count
source.sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count source.sizeLoadedCount = record.rawItems.filter(\.sizeLoaded).count
source.lastScanDate = record.lastScanDate source.lastScanDate = record.lastScanDate
source.snapshot = record.snapshot source.snapshot = record.snapshot
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 source.scanStatus = source.indexedItemCount == 0
? "No Minecraft items found." ? "No Minecraft items found."
: "Loaded \(source.indexedDetailCount) items." : "Loaded \(source.indexedDetailCount) items."
sources.append(source)
rebuildNormalizedIndex(for: source.id)
}
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 } 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 refreshConnectedDevices()
await refreshLocalSources() await refreshLocalSources()
@ -1638,10 +1773,9 @@ final class SourceLibrary: ObservableObject {
return true return true
} }
let fileManager = FileManager.default
let sourceURL = record.folderURL let sourceURL = record.folderURL
guard fileManager.fileExists(atPath: sourceURL.path) else { guard FileManager.default.fileExists(atPath: sourceURL.path) else {
return true return true
} }
@ -1653,19 +1787,8 @@ final class SourceLibrary: ObservableObject {
} }
for (folderName, persistedCollection) in persistedCollections { for (folderName, persistedCollection) in persistedCollections {
guard let currentCollection = currentCollections[folderName], currentCollection == persistedCollection else { guard let currentCollection = currentCollections[folderName],
return true currentCollection.fingerprint == persistedCollection.fingerprint else {
}
}
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 {
return true return true
} }
} }
@ -1682,10 +1805,9 @@ final class SourceLibrary: ObservableObject {
return true return true
} }
let fileManager = FileManager.default
let sourceURL = source.folderURL let sourceURL = source.folderURL
guard fileManager.fileExists(atPath: sourceURL.path) else { guard FileManager.default.fileExists(atPath: sourceURL.path) else {
return false return false
} }
@ -1697,19 +1819,8 @@ final class SourceLibrary: ObservableObject {
} }
for (folderName, persistedCollection) in persistedCollections { for (folderName, persistedCollection) in persistedCollections {
guard let currentCollection = currentCollections[folderName], currentCollection == persistedCollection else { guard let currentCollection = currentCollections[folderName],
return true currentCollection.fingerprint == persistedCollection.fingerprint else {
}
}
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 {
return true return true
} }
} }
@ -1749,35 +1860,7 @@ final class SourceLibrary: ObservableObject {
} }
private func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] { private func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] {
let fileManager = FileManager.default WorldScanner.collectionSnapshots(in: sourceURL)
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: "::")
)
}
} }
private func buildDisplayItems( 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) { private func deletePersistedSource(withID sourceID: URL) {
Task { Task {
try? await persistenceStore.deleteSource(withID: sourceID) try? await persistenceStore.deleteSource(withID: sourceID)
@ -1864,6 +1954,8 @@ final class SourceLibrary: ObservableObject {
source.scanProgress = nil source.scanProgress = nil
} }
let mode: SourceDiscoveryMode = source.hasCachedContent ? .reconcile : .fullScan
let task = Task { [weak self] in let task = Task { [weak self] in
do { do {
try await Task.sleep(for: .seconds(resolvedDebounce)) try await Task.sleep(for: .seconds(resolvedDebounce))
@ -1876,7 +1968,7 @@ final class SourceLibrary: ObservableObject {
} }
await MainActor.run { await MainActor.run {
self.startScan(for: sourceID) self.startScan(for: sourceID, mode: mode)
} }
} }
@ -2032,19 +2124,45 @@ final class SourceLibrary: ObservableObject {
return false 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 { guard let lastScanDate = source.lastScanDate else {
return true return true
} }
let refreshInterval: TimeInterval let reconnectGracePeriod: TimeInterval = 5 * 60
switch device.connection { if Date().timeIntervalSince(lastScanDate) < reconnectGracePeriod {
case .usb: return false
refreshInterval = Self.usbConnectedDeviceAutoRefreshInterval
case .network:
refreshInterval = Self.networkConnectedDeviceAutoRefreshInterval
} }
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() { private func cancelFooterReset() {
@ -2052,21 +2170,64 @@ final class SourceLibrary: ObservableObject {
footerResetTask = nil footerResetTask = nil
} }
private func initialScanStatus(for source: MinecraftSource) -> String { private func initialScanStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
switch source.origin { switch (source.origin, mode) {
case .localFolder: case (.localFolder, .fullScan):
return "Preparing folder scan..." 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..." 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 { private func scanningLibraryStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
switch source.origin { switch (source.origin, mode) {
case .localFolder: case (.localFolder, .fullScan):
return "Scanning Minecraft library..." return "Scanning Minecraft library..."
case .connectedDevice: case (.localFolder, .reconcile):
return "Reconciling cached library..."
case (.connectedDevice, .fullScan):
return "Scanning Minecraft library on device..." 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, scanRootURL: URL,
packMetadataByItemID: [URL: PackMetadata] packMetadataByItemID: [URL: PackMetadata]
) -> SourceSnapshot { ) -> SourceSnapshot {
let collectionSnapshots = MinecraftContentType.allCases.compactMap { type -> CollectionSnapshot? in let collectionSnapshots = WorldScanner.collectionSnapshots(in: scanRootURL)
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 itemSnapshots = source.rawItems.map { item in let itemSnapshots = source.rawItems.map { item in
let relativePath = item.folderURL.path.replacingOccurrences(of: scanRootURL.path + "/", with: "") let relativePath = item.folderURL.path.replacingOccurrences(of: scanRootURL.path + "/", with: "")
@ -2323,6 +2458,15 @@ private actor SourceIndexActor {
orderedItemIDs.append(item.id) orderedItemIDs.append(item.id)
itemsByID[item.id] = item itemsByID[item.id] = item
indexedItemCount = discoveredCount indexedItemCount = discoveredCount
if item.metadataLoaded {
indexedDetailCount += 1
}
if item.previewLoaded {
previewLoadedCount += 1
}
if item.sizeLoaded {
sizeLoadedCount += 1
}
return snapshotIfNeeded() return snapshotIfNeeded()
} }

View File

@ -19,6 +19,7 @@ struct PersistedSourceRecord: Sendable {
let rawItems: [MinecraftContentItem] let rawItems: [MinecraftContentItem]
let snapshot: SourceSnapshot? let snapshot: SourceSnapshot?
let lastScanDate: Date? let lastScanDate: Date?
let needsRepair: Bool
} }
private struct PersistedItemSnapshotPayload: Codable, Sendable { private struct PersistedItemSnapshotPayload: Codable, Sendable {
@ -173,6 +174,7 @@ private struct PersistedSourceSnapshotPayload: Codable, Sendable {
actor SourcePersistenceStore { actor SourcePersistenceStore {
static let shared = SourcePersistenceStore() static let shared = SourcePersistenceStore()
private static let cacheGeneration = "v2026-05-28"
private let databaseURL: URL private let databaseURL: URL
@ -181,7 +183,10 @@ actor SourcePersistenceStore {
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true) ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true)
let directoryURL = applicationSupportURL let directoryURL = applicationSupportURL
.appendingPathComponent("World Manager for Minecraft", isDirectory: true) .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) { init(databaseURL: URL) {
@ -214,21 +219,17 @@ actor SourcePersistenceStore {
let sourceID = sourceID(from: statement) ?? URL(fileURLWithPath: String(cString: folderPathPointer)).standardizedFileURL let sourceID = sourceID(from: statement) ?? URL(fileURLWithPath: String(cString: folderPathPointer)).standardizedFileURL
let folderPath = String(cString: folderPathPointer) 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 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 displayName = String(cString: sqlite3_column_text(statement, 6))
let rawItems = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: 7) ?? [] let rawItemsResult = decodeRawItems(statement: statement, columnIndex: 7)
let snapshotPayload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: 8) let snapshotResult = decodeSnapshot(statement: statement, columnIndex: 8)
let snapshot = snapshotPayload?.sourceSnapshot let rawItems = rawItemsResult.value
let snapshot = snapshotResult.value
let lastScanDate = sqlite3_column_type(statement, 9) == SQLITE_NULL let lastScanDate = sqlite3_column_type(statement, 9) == SQLITE_NULL
? nil ? nil
: Date(timeIntervalSince1970: sqlite3_column_double(statement, 9)) : Date(timeIntervalSince1970: sqlite3_column_double(statement, 9))
@ -244,7 +245,11 @@ actor SourcePersistenceStore {
displayName: displayName, displayName: displayName,
rawItems: rawItems, rawItems: rawItems,
snapshot: snapshot, 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() let database = try openDatabase()
defer { sqlite3_close(database) } 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 = """ let sql = """
INSERT INTO source_cache ( INSERT INTO source_cache (
source_id, source_id,
@ -287,17 +318,17 @@ actor SourcePersistenceStore {
} }
defer { sqlite3_finalize(statement) } defer { sqlite3_finalize(statement) }
try bindText(normalizedIdentifierText(for: source.id), to: statement, at: 1) try bindText(normalizedIdentifierText(for: record.sourceID), to: statement, at: 1)
try bindText(source.folderURL.path, to: statement, at: 2) try bindText(record.folderURL.path, to: statement, at: 2)
try bindJSON(source.origin, to: statement, at: 3) try bindJSON(record.origin, to: statement, at: 3)
try bindJSON(source.accessDescriptor, to: statement, at: 4) try bindJSON(record.accessDescriptor, to: statement, at: 4)
try bindText(source.availability.rawValue, to: statement, at: 5) try bindText(record.availability.rawValue, to: statement, at: 5)
try bindData(source.bookmarkData, to: statement, at: 6) try bindData(record.bookmarkData, to: statement, at: 6)
try bindText(source.displayName, to: statement, at: 7) try bindText(record.displayName, to: statement, at: 7)
try bindJSON(source.rawItems, to: statement, at: 8) try bindJSON(record.rawItems, to: statement, at: 8)
try bindJSON(source.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 9) 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) sqlite3_bind_double(statement, 10, lastScanDate.timeIntervalSince1970)
} else { } else {
sqlite3_bind_null(statement, 10) sqlite3_bind_null(statement, 10)
@ -450,19 +481,10 @@ actor SourcePersistenceStore {
} }
private func decodeColumn<T: Decodable>(_ type: T.Type, statement: OpaquePointer?, columnIndex: Int32) throws -> T? { private func decodeColumn<T: Decodable>(_ 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 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) return try JSONDecoder().decode(type, from: data)
} }
@ -482,6 +504,98 @@ actor SourcePersistenceStore {
return Data(bytes: bytes, count: byteCount) 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<Element: Decodable>(_ 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? { private func sourceID(from statement: OpaquePointer?) -> URL? {
guard let pointer = sqlite3_column_text(statement, 0) else { guard let pointer = sqlite3_column_text(statement, 0) else {
return nil return nil

View File

@ -86,6 +86,66 @@ enum WorldScanner {
return discoveredItems 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<URL>()
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 { nonisolated static func enrich(item: MinecraftContentItem) async -> MinecraftContentItem {
let fileManager = FileManager.default let fileManager = FileManager.default
var enrichedItem = item 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] { nonisolated fileprivate static func immediateChildDirectories(of directoryURL: URL, fileManager: FileManager) throws -> [URL] {
let children = try fileManager.contentsOfDirectory( let children = try fileManager.contentsOfDirectory(
at: directoryURL, at: directoryURL,

View File

@ -85,7 +85,7 @@ struct SourcesSidebarView: View {
) )
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?) .tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.padding(.top, 6) .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 8))
.contextMenu { .contextMenu {
Button("Rescan \"\(source.displayName)\"") { Button("Rescan \"\(source.displayName)\"") {
rescanSourceAction(source) rescanSourceAction(source)
@ -113,7 +113,7 @@ struct SourcesSidebarView: View {
} : nil } : nil
) )
.listRowSeparator(.hidden) .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 { private var backgroundStyle: AnyShapeStyle {
if isSelected { if isHovering && !isSelected {
return AnyShapeStyle(Color.appAccent.opacity(0.14))
}
if isHovering {
return AnyShapeStyle(.secondary.opacity(0.08)) return AnyShapeStyle(.secondary.opacity(0.08))
} }

View File

@ -101,8 +101,10 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
nonisolated func discoverItems( nonisolated func discoverItems(
for source: MinecraftSource, for source: MinecraftSource,
mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws -> [MinecraftContentItem] { ) async throws -> [MinecraftContentItem] {
_ = mode
guard case .connectedDevice(_, let container) = source.origin else { guard case .connectedDevice(_, let container) = source.origin else {
throw SourceAccessError.accessFailed( throw SourceAccessError.accessFailed(
reason: "The selected source is not backed by a connected mobile device." reason: "The selected source is not backed by a connected mobile device."

View File

@ -7,12 +7,18 @@
import Foundation import Foundation
enum SourceDiscoveryMode: Sendable {
case fullScan
case reconcile
}
protocol SourceAccessMethod: Sendable { protocol SourceAccessMethod: Sendable {
nonisolated var accessorIdentifier: SourceAccessorIdentifier { get } nonisolated var accessorIdentifier: SourceAccessorIdentifier { get }
nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability nonisolated func availability(for source: MinecraftSource) async -> SourceAvailability
nonisolated func discoverItems( nonisolated func discoverItems(
for source: MinecraftSource, for source: MinecraftSource,
mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws -> [MinecraftContentItem] ) async throws -> [MinecraftContentItem]
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
@ -46,9 +52,11 @@ extension SourceAccessMethod {
nonisolated func discoverItems( nonisolated func discoverItems(
for source: MinecraftSource, for source: MinecraftSource,
mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws -> [MinecraftContentItem] { ) async throws -> [MinecraftContentItem] {
_ = source _ = source
_ = mode
_ = onDiscovered _ = onDiscovered
return [] return []
} }
@ -147,9 +155,14 @@ struct SourceAccessCoordinator: SourceAccessMethod {
nonisolated func discoverItems( nonisolated func discoverItems(
for source: MinecraftSource, for source: MinecraftSource,
mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws -> [MinecraftContentItem] { ) 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 { nonisolated func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {

View File

@ -46,6 +46,7 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
nonisolated func discoverItems( nonisolated func discoverItems(
for source: MinecraftSource, for source: MinecraftSource,
mode: SourceDiscoveryMode,
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws -> [MinecraftContentItem] { ) async throws -> [MinecraftContentItem] {
guard case .localFolder(let bookmarkData) = source.origin else { 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) return try WorldScanner.discoverItems(in: resolvedURL, onDiscovered: onDiscovered)
} }
@ -120,4 +131,63 @@ struct LocalFolderSourceAccess: SourceAccessMethod {
_ = source _ = source
return item.folderURL 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<String>()
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)
}
} }