From b5046a16c26305a3361e0fd244e3b94afe5bb1c5 Mon Sep 17 00:00:00 2001 From: John Burwell Date: Mon, 1 Jun 2026 08:48:50 -0500 Subject: [PATCH] Improve projection performance --- .../Services/Sources/Core/SourceLibrary.swift | 34 ++++++- .../UI/List/ItemListColumnViews.swift | 14 +-- .../UI/Preview/PreviewFixtures.swift | 2 +- .../UI/Root/ContentView.swift | 96 +++++++++++++++---- 4 files changed, 116 insertions(+), 30 deletions(-) diff --git a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift index cf58c34..5f53d43 100644 --- a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift @@ -26,7 +26,11 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer category: "ConnectedDevicePerformance" ) - @Published var sources: [MinecraftSource] = [] + @Published var sources: [MinecraftSource] = [] { + didSet { + rebuildLookupIndexes() + } + } @Published var connectedDevices: [ConnectedDeviceSidebarEntry] = [] @Published var isRestoringPersistedSources = true @@ -40,6 +44,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer private let notificationService: ScanNotificationServicing private let itemActionService: ContentItemActionService private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory() + private var sourceIndexByID: [URL: Int] = [:] + private var sourceIDByItemID: [URL: URL] = [:] var lastMatchedConnectedSourceIDs: Set = [] var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:] var isShuttingDown = false @@ -90,6 +96,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer visibleSources } + func sourceID(forItemID itemID: URL) -> URL? { + sourceIDByItemID[itemID] + } + + func containsItem(withID itemID: URL) -> Bool { + sourceIDByItemID[itemID] != nil + } + func shutdown() { guard !isShuttingDown else { return @@ -189,7 +203,11 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer } func source(withID sourceID: URL) -> MinecraftSource? { - sources.first(where: { $0.id == sourceID }) + guard let index = sourceIndexByID[sourceID], sources.indices.contains(index) else { + return nil + } + + return sources[index] } func rescanSource(withID sourceID: URL) { @@ -499,6 +517,18 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer ) } + private func rebuildLookupIndexes() { + sourceIndexByID = Dictionary(uniqueKeysWithValues: sources.enumerated().map { ($0.element.id, $0.offset) }) + + var itemIndex: [URL: URL] = [:] + for source in sources { + for item in source.items { + itemIndex[item.id] = source.id + } + } + sourceIDByItemID = itemIndex + } + @discardableResult func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool) { SourceSyncRuntime.updateAvailability(for: sourceID, to: newAvailability, on: self) diff --git a/World Manager for Minecraft/UI/List/ItemListColumnViews.swift b/World Manager for Minecraft/UI/List/ItemListColumnViews.swift index 036e868..5d69306 100644 --- a/World Manager for Minecraft/UI/List/ItemListColumnViews.swift +++ b/World Manager for Minecraft/UI/List/ItemListColumnViews.swift @@ -33,7 +33,7 @@ struct ItemListColumnView: View { let subtitle: String let showsSubtitle: Bool let isRefreshing: Bool - let isUpdatingProjection: Bool + let showsProjectionLoadingState: Bool let items: [MinecraftContentItem] let searchPrompt: String let chooseFolderAction: () -> Void @@ -58,7 +58,7 @@ struct ItemListColumnView: View { } .listStyle(.inset) .overlay { - if isUpdatingProjection && items.isEmpty { + if showsProjectionLoadingState && items.isEmpty { ItemListLoadingOverlay() } } @@ -73,7 +73,7 @@ struct ItemListColumnView: View { subtitle: subtitle, showsSubtitle: showsSubtitle, isRefreshing: isRefreshing, - isUpdatingProjection: isUpdatingProjection + showsProjectionLoadingState: showsProjectionLoadingState ) } } @@ -106,7 +106,7 @@ private struct ItemListHeaderView: View { let subtitle: String let showsSubtitle: Bool let isRefreshing: Bool - let isUpdatingProjection: Bool + let showsProjectionLoadingState: Bool var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -121,13 +121,13 @@ private struct ItemListHeaderView: View { .font(.title2.weight(.semibold)) .lineLimit(2) - if isRefreshing || isUpdatingProjection { + if isRefreshing || showsProjectionLoadingState { ProgressView() .appActivityIndicatorStyle(.small) } } - if showsSubtitle || isUpdatingProjection { + if showsSubtitle || showsProjectionLoadingState { Text(displaySubtitle) .appTextStyle(.supporting) } @@ -140,7 +140,7 @@ private struct ItemListHeaderView: View { } private var displaySubtitle: String { - if isUpdatingProjection { + if showsProjectionLoadingState { return "Loading items..." } diff --git a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift index d3c76b5..cc77501 100644 --- a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift +++ b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift @@ -323,7 +323,7 @@ struct ItemListColumnPreviewContainer: View { subtitle: "5 items in Kid iPad Imports", showsSubtitle: false, isRefreshing: false, - isUpdatingProjection: false, + showsProjectionLoadingState: false, items: PreviewFixtures.primarySource.displayItems, searchPrompt: "Search Worlds", chooseFolderAction: {}, diff --git a/World Manager for Minecraft/UI/Root/ContentView.swift b/World Manager for Minecraft/UI/Root/ContentView.swift index eae664f..6d5480e 100644 --- a/World Manager for Minecraft/UI/Root/ContentView.swift +++ b/World Manager for Minecraft/UI/Root/ContentView.swift @@ -20,6 +20,7 @@ struct ContentView: View { @State private var isShowingDeviceSourceSheet = false @State private var sortMode: ItemSortMode = .name @State private var directoryPreviewContents: [DirectoryEntry] = [] + @State private var showsProjectionLoadingState = false @State private var itemListProjection = ItemCollectionProjection.placeholder( for: ItemCollectionProjectionRequest( selection: nil, @@ -33,6 +34,7 @@ struct ContentView: View { private let deviceSourceFactory: ConnectedDeviceSourceFactory private let itemActionService: ContentItemActionService private let directoryPreviewLimit = 12 + private let projectionLoadingDelay: Duration = .milliseconds(150) init() { let dependencies = ContentViewDependencies.makeDefault() @@ -53,15 +55,25 @@ struct ContentView: View { sortMode: sortMode, source: resolvedCurrentSource ) + let fastPathProjection = fastProjection(for: currentProjectionRequest, previousRequest: itemListProjection.request) let reusesCurrentProjectedItems = itemListProjection.request.selection == currentProjectionRequest.selection && itemListProjection.request.source?.id == currentProjectionRequest.source?.id - let isUpdatingItemListProjection = itemListProjection.request != currentProjectionRequest - let resolvedItemListProjection = itemListProjection.request == currentProjectionRequest - ? itemListProjection - : ItemCollectionProjection.placeholder(for: currentProjectionRequest) + let resolvedItemListProjection = if let fastPathProjection { + fastPathProjection + } else if itemListProjection.request == currentProjectionRequest { + itemListProjection + } else { + ItemCollectionProjection.placeholder(for: currentProjectionRequest) + } let resolvedCurrentSelectedItem = currentSelectedItem(in: resolvedCurrentSource) - let resolvedDisplayedItems = reusesCurrentProjectedItems ? itemListProjection.items : [] + let resolvedDisplayedItems: [MinecraftContentItem] = if let fastPathProjection { + fastPathProjection.items + } else if reusesCurrentProjectedItems { + itemListProjection.items + } else { + [] + } NavigationSplitView(columnVisibility: $columnVisibility) { SourcesSidebarView( @@ -94,9 +106,9 @@ struct ContentView: View { showsSourceName: !isSidebarVisible, title: resolvedItemListProjection.title, subtitle: resolvedItemListProjection.subtitle, - showsSubtitle: isSearching || isUpdatingItemListProjection, + showsSubtitle: isSearching || showsProjectionLoadingState, isRefreshing: resolvedCurrentSource?.isScanning == true, - isUpdatingProjection: isUpdatingItemListProjection, + showsProjectionLoadingState: showsProjectionLoadingState, items: resolvedDisplayedItems, searchPrompt: resolvedItemListProjection.searchPrompt, chooseFolderAction: pickFolder, @@ -180,14 +192,41 @@ struct ContentView: View { } .task(id: currentProjectionRequest) { let request = currentProjectionRequest + if let fastPathProjection = fastProjection(for: request, previousRequest: itemListProjection.request) { + showsProjectionLoadingState = false + itemListProjection = fastPathProjection + return + } + let projection = await Task.detached(priority: .userInitiated) { ItemCollectionProjector.makeProjection(for: request) }.value guard !Task.isCancelled else { return } + showsProjectionLoadingState = false itemListProjection = projection } + .task(id: currentProjectionRequest) { + showsProjectionLoadingState = false + + guard itemListProjection.request != currentProjectionRequest else { + return + } + + guard fastProjection(for: currentProjectionRequest, previousRequest: itemListProjection.request) == nil else { + return + } + + try? await Task.sleep(for: projectionLoadingDelay) + guard !Task.isCancelled else { + return + } + + if itemListProjection.request != currentProjectionRequest { + showsProjectionLoadingState = true + } + } .task(id: resolvedCurrentSelectedItem?.id) { await refreshDirectoryPreviewContents() } @@ -224,10 +263,9 @@ struct ContentView: View { return item } - for source in library.visibleSources { - if let item = source.items.first(where: { $0.id == selectedItemID }) { - return item - } + if let sourceID = library.sourceID(forItemID: selectedItemID), + let source = library.source(withID: sourceID) { + return source.items.first(where: { $0.id == selectedItemID }) } return nil @@ -558,18 +596,17 @@ struct ContentView: View { } if let selectedItemID { - let itemStillExists = library.visibleSources - .flatMap(\.items) - .contains(where: { $0.id == selectedItemID }) - - if !itemStillExists { + if !library.containsItem(withID: selectedItemID) { self.selectedItemID = nil } } } private func areFileActionsEnabled(for item: MinecraftContentItem) -> Bool { - guard let source = library.visibleSources.first(where: { $0.items.contains(where: { $0.id == item.id }) }) else { + guard + let sourceID = library.sourceID(forItemID: item.id), + let source = library.source(withID: sourceID) + else { return false } @@ -577,9 +614,28 @@ struct ContentView: View { } private func sourceForItem(_ item: MinecraftContentItem) -> MinecraftSource? { - library.visibleSources.first(where: { source in - source.items.contains(where: { $0.id == item.id }) - }) + guard let sourceID = library.sourceID(forItemID: item.id) else { + return nil + } + + return library.source(withID: sourceID) + } + + private func fastProjection( + for request: ItemCollectionProjectionRequest, + previousRequest: ItemCollectionProjectionRequest + ) -> ItemCollectionProjection? { + guard + let sourceID = request.source?.id, + sourceID == previousRequest.source?.id, + request.searchText == previousRequest.searchText, + request.sortMode == previousRequest.sortMode, + request.selection != previousRequest.selection + else { + return nil + } + + return ItemCollectionProjector.makeProjection(for: request) } private func saveItem(_ item: MinecraftContentItem) {