diff --git a/Outpost/Features/Account/AvatarCropperView.swift b/Outpost/Features/Account/AvatarCropperView.swift index a66e20f..3ba4c37 100644 --- a/Outpost/Features/Account/AvatarCropperView.swift +++ b/Outpost/Features/Account/AvatarCropperView.swift @@ -74,8 +74,10 @@ struct AvatarCropperView: View { Button("Cancel", role: .cancel, action: onCancel) Spacer() Button("Use Photo") { - if let data = renderFinalImage() { - onConfirm(data) + Task { + if let data = await renderFinalImage() { + onConfirm(data) + } } } .buttonStyle(.borderedProminent) @@ -100,15 +102,26 @@ struct AvatarCropperView: View { .clipped() } + /// `ImageRenderer` itself has to run on the main actor (it captures live + /// SwiftUI view state), but JPEG compression on the bitmap it produces + /// is pure CPU work with no SwiftUI dependency left — hopping off for + /// just that part avoids a visible hitch on tapping "Use Photo". + /// `tiffRepresentation` (plain `Data`, unlike `NSImage` itself) is what + /// actually crosses the actor boundary; mirrors `NSImage.jpegData( + /// compressionQuality:)`'s own logic rather than calling it directly, so + /// crossing doesn't require handing a non-Sendable `NSImage` to a + /// detached task. @MainActor - private func renderFinalImage() -> Data? { + private func renderFinalImage() async -> Data? { let content = avatarContent .clipShape(Circle()) .frame(width: diameter, height: diameter) let renderer = ImageRenderer(content: content) renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays - guard let nsImage = renderer.nsImage else { return nil } - return nsImage.jpegData(compressionQuality: 0.9) + guard let tiffData = renderer.nsImage?.tiffRepresentation else { return nil } + return await Task.detached(priority: .userInitiated) { + NSBitmapImageRep(data: tiffData)?.representation(using: .jpeg, properties: [.compressionFactor: 0.9]) + }.value } } #endif diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index f05baf5..259bdb4 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -30,8 +30,15 @@ struct CollectionDocumentsOutline: View { /// every document in the tree. @State private var pinsByDocumentID: [String: OutlinePin] = [:] - private var tree: [DocumentNode] { - buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) + /// Recomputed only when `viewModel.documents`/`sortOption` actually change + /// (below) instead of being a computed property — this rebuilt the whole + /// dictionary-grouped, recursively-sorted tree on every `body` evaluation, + /// including renders triggered by unrelated state (selection, hover, + /// pins) that don't change the tree's shape at all. + @State private var tree: [DocumentNode] = [] + + private func rebuildTree() { + tree = buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) } init( @@ -86,7 +93,10 @@ struct CollectionDocumentsOutline: View { .task(id: "\(refreshToken)-\(externalRefreshToken)") { await viewModel.load() await loadPins() + rebuildTree() } + .onChange(of: viewModel.documents) { rebuildTree() } + .onChange(of: sortOption) { rebuildTree() } } private func loadPins() async { diff --git a/Outpost/Features/Collections/CollectionOverviewView.swift b/Outpost/Features/Collections/CollectionOverviewView.swift index d37a8ce..9c1c013 100644 --- a/Outpost/Features/Collections/CollectionOverviewView.swift +++ b/Outpost/Features/Collections/CollectionOverviewView.swift @@ -34,8 +34,15 @@ struct CollectionOverviewView: View { self.onOpenDocument = onOpenDocument } - private var sortedDocuments: [OutlineDocument] { - selectedTab.sorted(viewModel.documents) + /// Recomputed only when `viewModel.documents`/`selectedTab` actually + /// change (below) instead of being a computed property re-sorted on + /// every render — capped at 100 documents per collection page, so lower + /// blast radius than the sidebar/command-palette versions of this same + /// pattern, but the same fix. + @State private var sortedDocuments: [OutlineDocument] = [] + + private func resortDocuments() { + sortedDocuments = selectedTab.sorted(viewModel.documents) } var body: some View { @@ -94,6 +101,8 @@ struct CollectionOverviewView: View { await viewModel.checkForRemoteChanges() } } + .onChange(of: viewModel.documents) { resortDocuments() } + .onChange(of: selectedTab) { resortDocuments() } } // Spans the full window width, centered, directly under the toolbar — diff --git a/Outpost/Features/Collections/CommandPaletteView.swift b/Outpost/Features/Collections/CommandPaletteView.swift index fc7d5e7..be17186 100644 --- a/Outpost/Features/Collections/CommandPaletteView.swift +++ b/Outpost/Features/Collections/CommandPaletteView.swift @@ -41,22 +41,31 @@ struct CommandPaletteView: View { } } - private var results: [Result] { + /// Recomputed only when `query`/`collections`/`documents` actually change + /// (below) instead of being a computed property — Full Workspace mode's + /// index can be large (every document in the local cache, sub-documents + /// included), and this was re-scanning + re-sorting the entire thing on + /// every render, including ones triggered by unrelated state like + /// `selectedIndex` changing as arrow keys move the selection. + @State private var results: [Result] = [] + + private func recomputeResults() { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { // No query yet: surface collections first, then the most // recent/full-workspace documents as-is, capped so the panel // doesn't dump the entire workspace with nothing typed. - return (collections.map(Result.collection) + documents.map(Result.document)) + results = (collections.map(Result.collection) + documents.map(Result.document)) .prefix(20) .map { $0 } + return } let scored: [(Result, Int)] = collections.compactMap { collection in matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) } } + documents.compactMap { document in matchScore(document.title, query: trimmed).map { (Result.document(document), $0) } } - return scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0) + results = scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0) } /// Lower is better — exact match, then prefix match, then earliest @@ -100,7 +109,10 @@ struct CommandPaletteView: View { .textFieldStyle(.plain) .font(.title3) .focused($isSearchFieldFocused) - .onChange(of: query) { selectedIndex = 0 } + .onChange(of: query) { + selectedIndex = 0 + recomputeResults() + } .onSubmit { selectCurrent() } // Attached directly on the field itself, not an // ancestor — confirmed live that .onKeyPress on the @@ -169,6 +181,8 @@ struct CommandPaletteView: View { isSearchFieldFocused = true await loadResults() } + .onChange(of: collections) { recomputeResults() } + .onChange(of: documents) { recomputeResults() } } private func resultRow(_ result: Result, isSelected: Bool) -> some View { diff --git a/Outpost/Features/Collections/DocumentSearchSheet.swift b/Outpost/Features/Collections/DocumentSearchSheet.swift index 849e8cc..1ec14dd 100644 --- a/Outpost/Features/Collections/DocumentSearchSheet.swift +++ b/Outpost/Features/Collections/DocumentSearchSheet.swift @@ -22,9 +22,26 @@ struct DocumentSearchSheet: View { @State private var currentMatchIndex = 0 @FocusState private var isSearchFieldFocused: Bool - private var matchingLineIndices: [Int] { - guard !query.isEmpty else { return [] } - return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) } + /// Recomputed only when `query`/`lines` actually change (`recomputeMatches()`) + /// instead of being a computed property — this used to re-scan the whole + /// document on every access, and it's read multiple times per row + /// (`isCurrentMatch`, the highlight check) on every SwiftUI re-render, so a + /// large document turned into an O(n²) case-insensitive scan per frame. + @State private var matchingLineIndices: [Int] = [] + /// O(1) membership for the per-row highlight check below — `matchingLineIndices` + /// stays an ordered array (needed for `currentMatchIndex`/stepping), this is + /// just a parallel lookup so a common search term with many matches doesn't + /// make every row's highlight check an O(k) linear scan. + @State private var matchingLineIndexSet: Set = [] + + private func recomputeMatches() { + guard !query.isEmpty else { + matchingLineIndices = [] + matchingLineIndexSet = [] + return + } + matchingLineIndices = lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) } + matchingLineIndexSet = Set(matchingLineIndices) } var body: some View { @@ -35,7 +52,10 @@ struct DocumentSearchSheet: View { TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query) .textFieldStyle(.plain) .focused($isSearchFieldFocused) - .onChange(of: query) { currentMatchIndex = 0 } + .onChange(of: query) { + currentMatchIndex = 0 + recomputeMatches() + } if !matchingLineIndices.isEmpty { Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)") @@ -83,7 +103,7 @@ struct DocumentSearchSheet: View { .padding(.horizontal, 4) .background( isCurrentMatch(index) ? Color.yellow.opacity(0.4) - : matchingLineIndices.contains(index) ? Color.yellow.opacity(0.15) + : matchingLineIndexSet.contains(index) ? Color.yellow.opacity(0.15) : Color.clear ) .id(index) @@ -131,6 +151,7 @@ struct DocumentSearchSheet: View { do { let text = try await apiClient.documentInfo(id: document.id).text lines = text.components(separatedBy: "\n") + recomputeMatches() } catch { errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.") } diff --git a/Outpost/Features/Home/HomeViewModel.swift b/Outpost/Features/Home/HomeViewModel.swift index 01f6ae8..825526b 100644 --- a/Outpost/Features/Home/HomeViewModel.swift +++ b/Outpost/Features/Home/HomeViewModel.swift @@ -83,16 +83,25 @@ final class HomeViewModel { /// `pins.list` only returns pin records, not the documents themselves — /// fetches each pinned document individually. Pins are a small curated /// set (unlike a full collection tree), so the N+1 here is acceptable - /// where it wouldn't be in the sidebar. + /// where it wouldn't be in the sidebar — but they're fetched concurrently + /// (a `TaskGroup`, not a serial loop) so latency doesn't scale with pin + /// count; `documentInfo` already goes through `CachingOutlineAPIClient`'s + /// own cached-read/retry path either way. private func fetchPinnedThrowing() async throws -> [OutlineDocument] { let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) } - var documents: [OutlineDocument] = [] - for pin in pins { - if let document = try? await apiClient.documentInfo(id: pin.documentId) { - documents.append(document) + let client = apiClient + let documentsByID: [String: OutlineDocument] = await withTaskGroup(of: (String, OutlineDocument?).self) { group in + for pin in pins { + group.addTask { (pin.documentId, try? await client.documentInfo(id: pin.documentId)) } } + var result: [String: OutlineDocument] = [:] + for await (id, document) in group { + if let document { result[id] = document } + } + return result } - return documents + // Preserve pins.list's own order rather than task-completion order. + return pins.compactMap { documentsByID[$0.documentId] } } private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {