Files
Outpost/Outpost/Features/Collections/CollectionOverviewView.swift
T
Puranjay Savar Mattas b8ce517b60 perf: stop recomputing derived state on every SwiftUI render
An audit for v0.1.0 turned up the same pattern in four places: a
computed property doing real work (filtering/sorting/scoring a
collection), read multiple times per render including from
unrelated state changes (selection, hover, scroll), so the work
reran far more often than the underlying data actually changed.
Converted each to a @State cache recomputed only via onChange of its
real inputs:

- DocumentSearchSheet: matchingLineIndices re-scanned the whole
  document per access, read once per visible row plus twice more in
  the header/step logic - O(n^2) case-insensitive scan per frame on
  a large document. Also split into an ordered array (for
  currentMatchIndex/stepping) plus a parallel Set for the per-row
  highlight check, which was an O(k) linear .contains before.
- CollectionDocumentsOutline: tree rebuilt the whole dictionary-
  grouped, recursively-sorted document tree on every body
  evaluation, not just when documents/sortOption actually changed.
- CommandPaletteView: results re-scored and re-sorted the entire
  index (up to the whole local workspace cache in Full Workspace
  mode) on every render, including ones from selectedIndex moving as
  arrow keys are pressed.
- CollectionOverviewView: sortedDocuments re-sorted on every render;
  same pattern, smaller blast radius (capped at 100 docs).

Also:
- HomeViewModel.fetchPinnedThrowing fetched each pinned document
  serially in a for loop (one round trip at a time) - switched to a
  TaskGroup so latency doesn't scale with pin count, results
  reordered back to pins.list's own order since task completion
  order isn't submission order.
- AvatarCropperView.renderFinalImage ran ImageRenderer + JPEG
  compression synchronously on the main actor from the "Use Photo"
  button tap. ImageRenderer itself has to stay on the main actor (it
  captures live SwiftUI state), but JPEG compression on the already-
  rendered bitmap has no SwiftUI dependency left - hopped that part
  to a detached Task via tiffRepresentation (plain Data, unlike
  NSImage itself isn't Sendable) so it doesn't hitch the UI.

No crash risks or retain cycles found in the same audit (no try!/
as!, force-unwraps essentially absent outside a hardcoded URL
literal, weak self already used where it matters) - this is purely
the perf half of the findings.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:48:20 +01:00

204 lines
7.9 KiB
Swift

#if os(macOS)
import SwiftUI
import OutlineKit
struct CollectionOverviewView: View {
let apiClient: OutlineAPIClient
let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel
@State private var selectedTab: CollectionTab = .overview
// Contextual search — "search what you're looking at" — scoped to this
// collection's documents. The field itself lives on the parent
// NavigationStack (so it survives pushing into a document); this owns
// only the resulting search state.
@State private var searchViewModel: DocumentTitleSearchViewModel
@Binding var searchQuery: String
let onOpenDocument: (OutlineDocument) -> Void
private var trimmedSearchQuery: String {
searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
}
init(
apiClient: OutlineAPIClient,
collection: OutlineCollection,
searchQuery: Binding<String>,
onOpenDocument: @escaping (OutlineDocument) -> Void
) {
self.apiClient = apiClient
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
_searchQuery = searchQuery
self.onOpenDocument = onOpenDocument
}
/// 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 {
VStack(alignment: .leading, spacing: 0) {
// No in-content header — the collection's icon/title live in the
// window toolbar now (via `ContentView_macOS`), so this doesn't
// duplicate it directly below.
if viewModel.hasRemoteChanges {
RemoteChangesBanner {
Task { await viewModel.load() }
}
}
if trimmedSearchQuery.isEmpty {
tabBar
}
Divider()
// All branches get the same frame so switching tabs/search doesn't
// visibly resize/jump the content area to its own ideal size.
Group {
if !trimmedSearchQuery.isEmpty {
searchResultsList
} else if selectedTab == .overview {
CollectionOverviewContent(apiClient: apiClient, collection: collection)
} else {
documentList
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// Corner spinner for reloads with stale data already on screen
// (remote-changes refresh, tab switch after the first load) —
// `documentList`'s own spinner only covers the empty-state case.
.overlay(alignment: .topTrailing) {
if viewModel.isLoading && !viewModel.documents.isEmpty {
ProgressView()
.controlSize(.small)
.padding(12)
}
}
}
// No `.navigationTitle` here — it renders its own native title bubble,
// duplicating the leading toolbar item `ContentView_macOS` already
// shows (which also carries the collection > document hierarchy).
.task { await viewModel.load() }
.task(id: trimmedSearchQuery) {
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled else { return }
await searchViewModel.search(query: trimmedSearchQuery)
}
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(45))
guard !Task.isCancelled else { break }
await viewModel.checkForRemoteChanges()
}
}
.onChange(of: viewModel.documents) { resortDocuments() }
.onChange(of: selectedTab) { resortDocuments() }
}
// Spans the full window width, centered, directly under the toolbar —
// not a scrolling, left-aligned row anymore.
private var tabBar: some View {
HStack(spacing: 4) {
Spacer(minLength: 0)
ForEach(CollectionTab.allCases) { tab in
Button {
selectedTab = tab
} label: {
Text(tab.label)
.font(.callout.weight(selectedTab == tab ? .semibold : .regular))
.foregroundStyle(selectedTab == tab ? Color.primary : Color.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
Spacer(minLength: 0)
}
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
}
@ViewBuilder
private var documentList: some View {
if viewModel.isLoading && viewModel.documents.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage = viewModel.errorMessage, viewModel.documents.isEmpty {
ContentUnavailableView {
Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.load() }
}
}
} else if viewModel.documents.isEmpty {
ContentUnavailableView(
"No Documents",
systemImage: "doc.text",
description: Text("Documents in \(collection.name) will appear here.")
)
} else {
List(sortedDocuments) { document in
Button {
onOpenDocument(document)
} label: {
DocumentRowView(document: document)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
@ViewBuilder
private var searchResultsList: some View {
if searchViewModel.isSearching && searchViewModel.results.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage = searchViewModel.errorMessage {
ContentUnavailableView {
Label("Search Failed", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
}
} else if searchViewModel.results.isEmpty {
ContentUnavailableView.search(text: trimmedSearchQuery)
} else {
List(searchViewModel.results) { result in
Button {
onOpenDocument(result.document)
} label: {
VStack(alignment: .leading, spacing: 4) {
DocumentRowView(document: result.document)
Text(result.context)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
}
#endif