Improve projection performance

This commit is contained in:
John Burwell 2026-06-01 08:48:50 -05:00
parent d2617a25ff
commit b5046a16c2
4 changed files with 116 additions and 30 deletions

View File

@ -26,7 +26,11 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
category: "ConnectedDevicePerformance" category: "ConnectedDevicePerformance"
) )
@Published var sources: [MinecraftSource] = [] @Published var sources: [MinecraftSource] = [] {
didSet {
rebuildLookupIndexes()
}
}
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = [] @Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
@Published var isRestoringPersistedSources = true @Published var isRestoringPersistedSources = true
@ -40,6 +44,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
private let notificationService: ScanNotificationServicing private let notificationService: ScanNotificationServicing
private let itemActionService: ContentItemActionService private let itemActionService: ContentItemActionService
private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory() private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory()
private var sourceIndexByID: [URL: Int] = [:]
private var sourceIDByItemID: [URL: URL] = [:]
var lastMatchedConnectedSourceIDs: Set<URL> = [] var lastMatchedConnectedSourceIDs: Set<URL> = []
var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:] var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:]
var isShuttingDown = false var isShuttingDown = false
@ -90,6 +96,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
visibleSources visibleSources
} }
func sourceID(forItemID itemID: URL) -> URL? {
sourceIDByItemID[itemID]
}
func containsItem(withID itemID: URL) -> Bool {
sourceIDByItemID[itemID] != nil
}
func shutdown() { func shutdown() {
guard !isShuttingDown else { guard !isShuttingDown else {
return return
@ -189,7 +203,11 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
} }
func source(withID sourceID: URL) -> MinecraftSource? { 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) { 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 @discardableResult
func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool) { func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool) {
SourceSyncRuntime.updateAvailability(for: sourceID, to: newAvailability, on: self) SourceSyncRuntime.updateAvailability(for: sourceID, to: newAvailability, on: self)

View File

@ -33,7 +33,7 @@ struct ItemListColumnView<MenuContent: View>: View {
let subtitle: String let subtitle: String
let showsSubtitle: Bool let showsSubtitle: Bool
let isRefreshing: Bool let isRefreshing: Bool
let isUpdatingProjection: Bool let showsProjectionLoadingState: Bool
let items: [MinecraftContentItem] let items: [MinecraftContentItem]
let searchPrompt: String let searchPrompt: String
let chooseFolderAction: () -> Void let chooseFolderAction: () -> Void
@ -58,7 +58,7 @@ struct ItemListColumnView<MenuContent: View>: View {
} }
.listStyle(.inset) .listStyle(.inset)
.overlay { .overlay {
if isUpdatingProjection && items.isEmpty { if showsProjectionLoadingState && items.isEmpty {
ItemListLoadingOverlay() ItemListLoadingOverlay()
} }
} }
@ -73,7 +73,7 @@ struct ItemListColumnView<MenuContent: View>: View {
subtitle: subtitle, subtitle: subtitle,
showsSubtitle: showsSubtitle, showsSubtitle: showsSubtitle,
isRefreshing: isRefreshing, isRefreshing: isRefreshing,
isUpdatingProjection: isUpdatingProjection showsProjectionLoadingState: showsProjectionLoadingState
) )
} }
} }
@ -106,7 +106,7 @@ private struct ItemListHeaderView: View {
let subtitle: String let subtitle: String
let showsSubtitle: Bool let showsSubtitle: Bool
let isRefreshing: Bool let isRefreshing: Bool
let isUpdatingProjection: Bool let showsProjectionLoadingState: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@ -121,13 +121,13 @@ private struct ItemListHeaderView: View {
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
.lineLimit(2) .lineLimit(2)
if isRefreshing || isUpdatingProjection { if isRefreshing || showsProjectionLoadingState {
ProgressView() ProgressView()
.appActivityIndicatorStyle(.small) .appActivityIndicatorStyle(.small)
} }
} }
if showsSubtitle || isUpdatingProjection { if showsSubtitle || showsProjectionLoadingState {
Text(displaySubtitle) Text(displaySubtitle)
.appTextStyle(.supporting) .appTextStyle(.supporting)
} }
@ -140,7 +140,7 @@ private struct ItemListHeaderView: View {
} }
private var displaySubtitle: String { private var displaySubtitle: String {
if isUpdatingProjection { if showsProjectionLoadingState {
return "Loading items..." return "Loading items..."
} }

View File

@ -323,7 +323,7 @@ struct ItemListColumnPreviewContainer: View {
subtitle: "5 items in Kid iPad Imports", subtitle: "5 items in Kid iPad Imports",
showsSubtitle: false, showsSubtitle: false,
isRefreshing: false, isRefreshing: false,
isUpdatingProjection: false, showsProjectionLoadingState: false,
items: PreviewFixtures.primarySource.displayItems, items: PreviewFixtures.primarySource.displayItems,
searchPrompt: "Search Worlds", searchPrompt: "Search Worlds",
chooseFolderAction: {}, chooseFolderAction: {},

View File

@ -20,6 +20,7 @@ struct ContentView: View {
@State private var isShowingDeviceSourceSheet = false @State private var isShowingDeviceSourceSheet = false
@State private var sortMode: ItemSortMode = .name @State private var sortMode: ItemSortMode = .name
@State private var directoryPreviewContents: [DirectoryEntry] = [] @State private var directoryPreviewContents: [DirectoryEntry] = []
@State private var showsProjectionLoadingState = false
@State private var itemListProjection = ItemCollectionProjection.placeholder( @State private var itemListProjection = ItemCollectionProjection.placeholder(
for: ItemCollectionProjectionRequest( for: ItemCollectionProjectionRequest(
selection: nil, selection: nil,
@ -33,6 +34,7 @@ struct ContentView: View {
private let deviceSourceFactory: ConnectedDeviceSourceFactory private let deviceSourceFactory: ConnectedDeviceSourceFactory
private let itemActionService: ContentItemActionService private let itemActionService: ContentItemActionService
private let directoryPreviewLimit = 12 private let directoryPreviewLimit = 12
private let projectionLoadingDelay: Duration = .milliseconds(150)
init() { init() {
let dependencies = ContentViewDependencies.makeDefault() let dependencies = ContentViewDependencies.makeDefault()
@ -53,15 +55,25 @@ struct ContentView: View {
sortMode: sortMode, sortMode: sortMode,
source: resolvedCurrentSource source: resolvedCurrentSource
) )
let fastPathProjection = fastProjection(for: currentProjectionRequest, previousRequest: itemListProjection.request)
let reusesCurrentProjectedItems = let reusesCurrentProjectedItems =
itemListProjection.request.selection == currentProjectionRequest.selection && itemListProjection.request.selection == currentProjectionRequest.selection &&
itemListProjection.request.source?.id == currentProjectionRequest.source?.id itemListProjection.request.source?.id == currentProjectionRequest.source?.id
let isUpdatingItemListProjection = itemListProjection.request != currentProjectionRequest let resolvedItemListProjection = if let fastPathProjection {
let resolvedItemListProjection = itemListProjection.request == currentProjectionRequest fastPathProjection
? itemListProjection } else if itemListProjection.request == currentProjectionRequest {
: ItemCollectionProjection.placeholder(for: currentProjectionRequest) itemListProjection
} else {
ItemCollectionProjection.placeholder(for: currentProjectionRequest)
}
let resolvedCurrentSelectedItem = currentSelectedItem(in: resolvedCurrentSource) 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) { NavigationSplitView(columnVisibility: $columnVisibility) {
SourcesSidebarView( SourcesSidebarView(
@ -94,9 +106,9 @@ struct ContentView: View {
showsSourceName: !isSidebarVisible, showsSourceName: !isSidebarVisible,
title: resolvedItemListProjection.title, title: resolvedItemListProjection.title,
subtitle: resolvedItemListProjection.subtitle, subtitle: resolvedItemListProjection.subtitle,
showsSubtitle: isSearching || isUpdatingItemListProjection, showsSubtitle: isSearching || showsProjectionLoadingState,
isRefreshing: resolvedCurrentSource?.isScanning == true, isRefreshing: resolvedCurrentSource?.isScanning == true,
isUpdatingProjection: isUpdatingItemListProjection, showsProjectionLoadingState: showsProjectionLoadingState,
items: resolvedDisplayedItems, items: resolvedDisplayedItems,
searchPrompt: resolvedItemListProjection.searchPrompt, searchPrompt: resolvedItemListProjection.searchPrompt,
chooseFolderAction: pickFolder, chooseFolderAction: pickFolder,
@ -180,14 +192,41 @@ struct ContentView: View {
} }
.task(id: currentProjectionRequest) { .task(id: currentProjectionRequest) {
let request = 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) { let projection = await Task.detached(priority: .userInitiated) {
ItemCollectionProjector.makeProjection(for: request) ItemCollectionProjector.makeProjection(for: request)
}.value }.value
guard !Task.isCancelled else { guard !Task.isCancelled else {
return return
} }
showsProjectionLoadingState = false
itemListProjection = projection 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) { .task(id: resolvedCurrentSelectedItem?.id) {
await refreshDirectoryPreviewContents() await refreshDirectoryPreviewContents()
} }
@ -224,10 +263,9 @@ struct ContentView: View {
return item return item
} }
for source in library.visibleSources { if let sourceID = library.sourceID(forItemID: selectedItemID),
if let item = source.items.first(where: { $0.id == selectedItemID }) { let source = library.source(withID: sourceID) {
return item return source.items.first(where: { $0.id == selectedItemID })
}
} }
return nil return nil
@ -558,18 +596,17 @@ struct ContentView: View {
} }
if let selectedItemID { if let selectedItemID {
let itemStillExists = library.visibleSources if !library.containsItem(withID: selectedItemID) {
.flatMap(\.items)
.contains(where: { $0.id == selectedItemID })
if !itemStillExists {
self.selectedItemID = nil self.selectedItemID = nil
} }
} }
} }
private func areFileActionsEnabled(for item: MinecraftContentItem) -> Bool { 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 return false
} }
@ -577,9 +614,28 @@ struct ContentView: View {
} }
private func sourceForItem(_ item: MinecraftContentItem) -> MinecraftSource? { private func sourceForItem(_ item: MinecraftContentItem) -> MinecraftSource? {
library.visibleSources.first(where: { source in guard let sourceID = library.sourceID(forItemID: item.id) else {
source.items.contains(where: { $0.id == item.id }) 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) { private func saveItem(_ item: MinecraftContentItem) {