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"
)
@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<URL> = []
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)

View File

@ -33,7 +33,7 @@ struct ItemListColumnView<MenuContent: View>: 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<MenuContent: View>: View {
}
.listStyle(.inset)
.overlay {
if isUpdatingProjection && items.isEmpty {
if showsProjectionLoadingState && items.isEmpty {
ItemListLoadingOverlay()
}
}
@ -73,7 +73,7 @@ struct ItemListColumnView<MenuContent: View>: 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..."
}

View File

@ -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: {},

View File

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