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.
204 lines
7.9 KiB
Swift
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
|