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.
263 lines
12 KiB
Swift
263 lines
12 KiB
Swift
#if os(macOS)
|
|
import SwiftUI
|
|
import OutlineKit
|
|
|
|
/// ⌘K. Settings → Editor → Command Palette. Always searches locally, never a
|
|
/// per-keystroke network request. Two data-source modes:
|
|
///
|
|
/// - Lightweight (default): a live `listCollections` + `listViewedDocuments`
|
|
/// fetch once when the palette opens — two small requests, near-instant,
|
|
/// works with no setup.
|
|
/// - Full Workspace: reads `CachingOutlineAPIClient`'s local SwiftData cache
|
|
/// directly (`cachedDocumentsIndex()`/`cachedCollectionsIndex()`) — zero
|
|
/// network calls at all, and includes every nested sub-document, not just
|
|
/// collection roots. Requires Full Local Sync to actually have populated
|
|
/// that cache first (gated in Settings — the toggle here is disabled
|
|
/// without it); this view doesn't trigger a sync itself.
|
|
struct CommandPaletteView: View {
|
|
let apiClient: OutlineAPIClient
|
|
let cachingClient: CachingOutlineAPIClient?
|
|
let fullWorkspaceSearch: Bool
|
|
let onSelectDocument: (OutlineDocument) -> Void
|
|
let onSelectCollection: (OutlineCollection) -> Void
|
|
let onDismiss: () -> Void
|
|
|
|
@State private var query = ""
|
|
@State private var collections: [OutlineCollection] = []
|
|
@State private var documents: [OutlineDocument] = []
|
|
@State private var isLoading = true
|
|
@State private var selectedIndex = 0
|
|
@FocusState private var isSearchFieldFocused: Bool
|
|
|
|
private enum Result: Identifiable {
|
|
case collection(OutlineCollection)
|
|
case document(OutlineDocument)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .collection(let collection): return "collection-\(collection.id)"
|
|
case .document(let document): return "document-\(document.id)"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
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) }
|
|
}
|
|
results = scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
|
|
}
|
|
|
|
/// Lower is better — exact match, then prefix match, then earliest
|
|
/// contiguous-substring position, then (for multi-word queries) every
|
|
/// word present somewhere in the title in any order. That last tier is
|
|
/// what makes "test document" find a title like "Test Plan Document" —
|
|
/// requiring the exact phrase contiguously (the previous behavior)
|
|
/// meant a title with anything between the words never matched at all,
|
|
/// which looked like "documents never show up, only collections" any
|
|
/// time the real title didn't happen to contain the typed phrase
|
|
/// verbatim. `nil` means no match at all. Still deliberately not a full
|
|
/// fuzzy/Levenshtein algorithm — good enough for document/collection
|
|
/// titles without the unpredictability that brings.
|
|
private func matchScore(_ title: String, query: String) -> Int? {
|
|
let haystack = title.lowercased()
|
|
let needle = query.lowercased()
|
|
if haystack == needle { return 0 }
|
|
if haystack.hasPrefix(needle) { return 1 }
|
|
if let range = haystack.range(of: needle) {
|
|
return 2 + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
|
|
}
|
|
let words = needle.split(separator: " ").map(String.init)
|
|
guard words.count > 1, words.allSatisfy({ haystack.contains($0) }) else { return nil }
|
|
let totalPosition = words.reduce(0) { partial, word in
|
|
guard let range = haystack.range(of: word) else { return partial }
|
|
return partial + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
|
|
}
|
|
return 100 + totalPosition
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.black.opacity(0.001) // catches clicks outside the card to dismiss
|
|
.onTapGesture { onDismiss() }
|
|
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundStyle(.secondary)
|
|
TextField("Search documents and collections…", text: $query)
|
|
.textFieldStyle(.plain)
|
|
.font(.title3)
|
|
.focused($isSearchFieldFocused)
|
|
.onChange(of: query) {
|
|
selectedIndex = 0
|
|
recomputeResults()
|
|
}
|
|
.onSubmit { selectCurrent() }
|
|
// Attached directly on the field itself, not an
|
|
// ancestor — confirmed live that .onKeyPress on the
|
|
// outer card never saw arrow-key events at all while
|
|
// this TextField actually held focus, the up/down
|
|
// presses just went nowhere. Escape still needs its
|
|
// own handler below since this one only covers
|
|
// whichever view is actually focused.
|
|
.onKeyPress(.downArrow) { moveSelection(by: 1); return .handled }
|
|
.onKeyPress(.upArrow) { moveSelection(by: -1); return .handled }
|
|
.onKeyPress(.escape) { onDismiss(); return .handled }
|
|
if isLoading {
|
|
ProgressView().controlSize(.small)
|
|
}
|
|
}
|
|
.padding(14)
|
|
|
|
Divider()
|
|
|
|
if results.isEmpty {
|
|
ContentUnavailableView(
|
|
isLoading ? "Loading…" : "No Results",
|
|
systemImage: isLoading ? "ellipsis" : "magnifyingglass"
|
|
)
|
|
.frame(height: 160)
|
|
} else {
|
|
ScrollViewReader { scrollProxy in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
ForEach(Array(results.enumerated()), id: \.element.id) { index, result in
|
|
resultRow(result, isSelected: index == selectedIndex)
|
|
.id(index)
|
|
.contentShape(Rectangle())
|
|
.pointerCursorOnHover()
|
|
.onTapGesture {
|
|
selectedIndex = index
|
|
selectCurrent()
|
|
}
|
|
}
|
|
}
|
|
.padding(6)
|
|
}
|
|
.frame(maxHeight: 360)
|
|
.onChange(of: selectedIndex) { _, newValue in
|
|
scrollProxy.scrollTo(newValue, anchor: .center)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(.separator))
|
|
.frame(width: 560)
|
|
.shadow(color: .black.opacity(0.3), radius: 24, y: 12)
|
|
}
|
|
.task {
|
|
// The window/responder chain isn't always ready to accept a
|
|
// first-responder change in the same instant this view is
|
|
// inserted — confirmed live: setting this synchronously on
|
|
// appear left the field unfocused until manually clicked
|
|
// (also the likely source of several "entangle context after
|
|
// pre-commit" / CA-transaction warnings in the console, which
|
|
// are exactly what fighting AppKit for first-responder status
|
|
// mid-commit looks like). A one-frame-ish delay is enough for
|
|
// the overlay's insertion to settle first.
|
|
try? await Task.sleep(for: .milliseconds(50))
|
|
isSearchFieldFocused = true
|
|
await loadResults()
|
|
}
|
|
.onChange(of: collections) { recomputeResults() }
|
|
.onChange(of: documents) { recomputeResults() }
|
|
}
|
|
|
|
private func resultRow(_ result: Result, isSelected: Bool) -> some View {
|
|
HStack(spacing: 10) {
|
|
switch result {
|
|
case .collection(let collection):
|
|
// Reuses the sidebar's own icon logic (emoji vs Outline's
|
|
// icon-key-to-SF-Symbol mapping vs fallback) instead of
|
|
// guessing — `collection.icon` isn't a raw SF Symbol name.
|
|
CollectionRowView(collection: collection)
|
|
.labelStyle(.iconOnly)
|
|
.frame(width: 20)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(collection.name)
|
|
.lineLimit(1)
|
|
Text("Collection")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
case .document(let document):
|
|
if let emoji = document.emoji {
|
|
Text(emoji).frame(width: 20)
|
|
} else {
|
|
Image(systemName: "doc.text")
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 20)
|
|
}
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(document.title.isEmpty ? "Untitled" : document.title)
|
|
.lineLimit(1)
|
|
Text("Document")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
}
|
|
|
|
private func moveSelection(by delta: Int) {
|
|
guard !results.isEmpty else { return }
|
|
selectedIndex = max(0, min(results.count - 1, selectedIndex + delta))
|
|
}
|
|
|
|
private func selectCurrent() {
|
|
guard results.indices.contains(selectedIndex) else { return }
|
|
switch results[selectedIndex] {
|
|
case .collection(let collection): onSelectCollection(collection)
|
|
case .document(let document): onSelectDocument(document)
|
|
}
|
|
onDismiss()
|
|
}
|
|
|
|
private func loadResults() async {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
if fullWorkspaceSearch {
|
|
// Purely local SwiftData reads — no network at all, and (since
|
|
// Full Local Sync now recurses into every document's children)
|
|
// this includes nested sub-documents the live per-collection
|
|
// fetch never could. Empty if a sync has never actually run.
|
|
collections = await cachingClient?.cachedCollectionsIndex() ?? []
|
|
documents = await cachingClient?.cachedDocumentsIndex() ?? []
|
|
return
|
|
}
|
|
|
|
async let fetchedCollections = (try? apiClient.listCollections(offset: 0, limit: 250)) ?? []
|
|
async let fetchedRecent = (try? apiClient.listViewedDocuments(offset: 0, limit: 30)) ?? []
|
|
collections = await fetchedCollections
|
|
documents = await fetchedRecent
|
|
}
|
|
}
|
|
#endif
|