Files
Outpost/Outpost/Features/Collections/CollectionDocumentsOutline.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

523 lines
20 KiB
Swift

#if os(macOS)
import AppKit
import SwiftUI
import UniformTypeIdentifiers
import OutlineKit
/// Nested document outline under an expanded collection in the sidebar tree —
/// real hierarchy via `parentDocumentId`, see DocumentNode for why that's
/// client-side rather than `collections.documents`.
struct CollectionDocumentsOutline: View {
let apiClient: OutlineAPIClient
let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel
let sortOption: SidebarSortOption
let refreshToken: Int
/// Bumped from `ContentView_macOS` whenever a document is created from
/// somewhere that has no direct handle on this row — the reader's
/// toolbar "New Document" button and Home's, specifically. Those can't
/// call `onDocumentsChanged()` the way a same-row sheet does, since they
/// don't know which (if any) sidebar row corresponds to where the new
/// document landed, so every expanded row just reloads itself.
let externalRefreshToken: Int
let selectedDocumentID: String?
/// Full chain from root to the clicked document (inclusive) — lets the
/// toolbar render the real hierarchy instead of just the leaf title.
let onSelectDocument: ([OutlineDocument]) -> Void
/// Loaded once per collection (`pins.list` is collection-scoped) rather
/// than per-row — a per-row `pins.list`/lookup would be an N+1 call for
/// every document in the tree.
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
/// 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(
apiClient: OutlineAPIClient,
collection: OutlineCollection,
sortOption: SidebarSortOption,
refreshToken: Int,
externalRefreshToken: Int,
selectedDocumentID: String?,
onSelectDocument: @escaping ([OutlineDocument]) -> Void
) {
self.apiClient = apiClient
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
self.sortOption = sortOption
self.refreshToken = refreshToken
self.externalRefreshToken = externalRefreshToken
self.selectedDocumentID = selectedDocumentID
self.onSelectDocument = onSelectDocument
}
var body: some View {
Group {
if viewModel.isLoading && viewModel.documents.isEmpty {
ProgressView()
.controlSize(.small)
.padding(.vertical, 6)
} else if viewModel.documents.isEmpty {
Text("No documents")
.font(.callout)
.foregroundStyle(.secondary)
.padding(.vertical, 6)
} else {
ForEach(tree) { node in
DocumentNodeRow(
apiClient: apiClient,
node: node,
depth: 0,
ancestors: [],
selectedDocumentID: selectedDocumentID,
pinsByDocumentID: pinsByDocumentID,
onSelectDocument: onSelectDocument,
onDocumentsChanged: { await viewModel.load() },
onPinsChanged: { await loadPins() }
)
}
}
}
// Combined into one identity rather than two separate `.task(id:)`
// modifiers — each of those fires once unconditionally on first
// appear, so two of them would double the initial load.
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
await viewModel.load()
await loadPins()
rebuildTree()
}
.onChange(of: viewModel.documents) { rebuildTree() }
.onChange(of: sortOption) { rebuildTree() }
}
private func loadPins() async {
guard let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) }) else { return }
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
}
}
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
/// below must run on the main thread — without pinning this to `@MainActor`,
/// a `Task` launched from a menu action can resume on a background executor
/// after its first `await`, and calling those APIs off-main is what produced
/// the ViewBridge/`nw_connection` console spam (and worse, silent failures).
@MainActor
private struct DocumentNodeRow: View {
@Environment(StarStore.self) private var starStore
let apiClient: OutlineAPIClient
let node: DocumentNode
let depth: Int
/// Chain from root down to (not including) this node.
let ancestors: [OutlineDocument]
let selectedDocumentID: String?
let pinsByDocumentID: [String: OutlinePin]
let onSelectDocument: ([OutlineDocument]) -> Void
let onDocumentsChanged: () async -> Void
let onPinsChanged: () async -> Void
@State private var isExpanded = false
@State private var isShowingRenameAlert = false
@State private var renameText = ""
@State private var isShowingUnpublishConfirmation = false
@State private var isShowingArchiveConfirmation = false
@State private var isShowingDeleteConfirmation = false
@State private var isShowingMoveSheet = false
@State private var isShowingHistorySheet = false
@State private var isShowingInsightsSheet = false
@State private var isShowingPresentSheet = false
@State private var isShowingSearchSheet = false
@State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String?
private var isSelected: Bool {
node.id == selectedDocumentID
}
private func containsSelected(_ node: DocumentNode) -> Bool {
guard let selectedDocumentID else { return false }
return node.children.contains {
$0.id == selectedDocumentID || containsSelected($0)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
if node.children.isEmpty {
Color.clear.frame(width: 12)
} else {
Button {
isExpanded.toggle()
} label: {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 12)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
Button {
onSelectDocument(ancestors + [node.document])
} label: {
HStack(spacing: 8) {
if let emoji = node.document.emoji {
Text(emoji)
.font(.body)
} else {
Image(systemName: "doc.text")
.font(.callout)
.foregroundStyle(.secondary)
}
Text(node.document.title.isEmpty ? "Untitled" : node.document.title)
.font(.body)
.lineLimit(1)
Spacer(minLength: 0)
if starStore.isStarred(documentId: node.document.id) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
.padding(.vertical, 6)
.padding(.horizontal, 4)
.padding(.leading, CGFloat(depth) * 16)
.background(
isSelected ? Color.accentColor.opacity(0.15) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
.contextMenu { contextMenuContent }
if isExpanded {
ForEach(node.children) { child in
DocumentNodeRow(
apiClient: apiClient,
node: child,
depth: depth + 1,
ancestors: ancestors + [node.document],
selectedDocumentID: selectedDocumentID,
pinsByDocumentID: pinsByDocumentID,
onSelectDocument: onSelectDocument,
onDocumentsChanged: onDocumentsChanged,
onPinsChanged: onPinsChanged
)
}
}
}
// Reveals the selected document if it's nested under this node —
// both on first appearance and whenever the selection changes — but
// never auto-collapses on the way out, so manual expand/collapse
// elsewhere in the tree isn't fought.
.task(id: selectedDocumentID) {
if containsSelected(node) {
isExpanded = true
}
}
.alert("Rename Document", isPresented: $isShowingRenameAlert) {
TextField("Title", text: $renameText)
Button("Cancel", role: .cancel) {}
Button("Rename") {
Task { await rename() }
}
}
.confirmationDialog(
"Unpublish \"\(node.document.title.isEmpty ? "Untitled" : node.document.title)\"?",
isPresented: $isShowingUnpublishConfirmation,
titleVisibility: .visible
) {
Button("Unpublish", role: .destructive) {
Task { await unpublish() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This moves the document back to a draft and out of the collection.")
}
.confirmationDialog(
"Archive \"\(node.document.title.isEmpty ? "Untitled" : node.document.title)\"?",
isPresented: $isShowingArchiveConfirmation,
titleVisibility: .visible
) {
Button("Archive", role: .destructive) {
Task { await archive() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Archived documents are hidden from the collection but can be restored later.")
}
.confirmationDialog(
"Delete \"\(node.document.title.isEmpty ? "Untitled" : node.document.title)\"?",
isPresented: $isShowingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
Task { await delete() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This moves the document to the trash. If not restored within 30 days it's permanently deleted.")
}
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
Button("OK") { actionErrorMessage = nil }
} message: {
Text(actionErrorMessage ?? "")
}
.sheet(isPresented: $isShowingMoveSheet) {
MoveDocumentSheet(apiClient: apiClient, document: node.document) {
await onDocumentsChanged()
}
}
.sheet(isPresented: $isShowingHistorySheet) {
DocumentHistorySheet(apiClient: apiClient, document: node.document)
}
.sheet(isPresented: $isShowingInsightsSheet) {
DocumentInsightsSheet(apiClient: apiClient, document: node.document)
}
.sheet(isPresented: $isShowingPresentSheet) {
DocumentPresentSheet(apiClient: apiClient, document: node.document)
}
.sheet(isPresented: $isShowingSearchSheet) {
DocumentSearchSheet(apiClient: apiClient, document: node.document)
}
.sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient, initialParentDocument: node.document) { _ in
Task { await onDocumentsChanged() }
}
}
}
@ViewBuilder
private var contextMenuContent: some View {
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
Task { await star() }
}
// subscriptions.* does exist and works (confirmed against a live
// server via the reader's menu) — not shown here because
// subscriptions.list is per-document, so reflecting accurate
// per-row state for every document in the tree would mean an N+1
// call storm. Use the reader's ⋯ menu instead.
Button("Unsubscribe") {}
.disabled(true)
Divider()
// Editing isn't wired up yet anywhere in the app (Phase 1 is
// read-only) — this stays disabled rather than opening a half-built path.
Button("Edit") {}
.disabled(true)
Button("Rename…") {
renameText = node.document.title
isShowingRenameAlert = true
}
// DocumentShareSheet now has a real "People with access" section —
// this row just doesn't have a sheet wired up to present it yet
// (only the reader toolbar does). Use the reader's ⋯ menu instead.
Button("Permissions…") {}
.disabled(true)
Divider()
Button("Templatize") {
Task { await templatize() }
}
Button("Duplicate") {
Task { await duplicate() }
}
Button("Unpublish") {
isShowingUnpublishConfirmation = true
}
Button("Archive…") {
isShowingArchiveConfirmation = true
}
Divider()
Button("Move") {
isShowingMoveSheet = true
}
// Multipart file upload is its own subsystem (file picker, format
// detection, progress) — deferred rather than half-built here.
Button("Import Document…") {}
.disabled(true)
Button("New Document") {
isShowingNewDocumentSheet = true
}
// Scoped to this collection (`collection.id`) — "Pin to Collection",
// distinct from the reader toolbar's "Pin to Home" (collectionId: nil).
Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") {
Task { await togglePin() }
}
Divider()
Button("History") {
isShowingHistorySheet = true
}
Button("Insights") {
isShowingInsightsSheet = true
}
Button("Present") {
isShowingPresentSheet = true
}
Divider()
Button("Download") {
Task { await download() }
}
Button("Copy") {
Task { await copyMarkdown() }
}
Button("Print") {
Task { await printDocument() }
}
Button("Search in Document") {
isShowingSearchSheet = true
}
Divider()
Button("Delete…", role: .destructive) {
isShowingDeleteConfirmation = true
}
}
private func star() async {
do {
try await starStore.toggleDocument(node.document.id, apiClient: apiClient)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this document.")
}
}
private func togglePin() async {
do {
if let pin = pinsByDocumentID[node.document.id] {
try await apiClient.deletePin(id: pin.id)
} else {
_ = try await apiClient.createPin(CreatePinRequest(documentId: node.document.id, collectionId: node.document.collectionId))
}
await onPinsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.")
}
}
private func rename() async {
do {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText))
await onDocumentsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't rename this document.")
}
}
private func templatize() async {
do {
_ = try await apiClient.templatizeDocument(TemplatizeDocumentRequest(id: node.document.id))
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't templatize this document.")
}
}
private func duplicate() async {
do {
_ = try await apiClient.duplicateDocument(DuplicateDocumentRequest(id: node.document.id))
await onDocumentsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't duplicate this document.")
}
}
private func unpublish() async {
do {
_ = try await apiClient.unpublishDocument(UnpublishDocumentRequest(id: node.document.id))
await onDocumentsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't unpublish this document.")
}
}
private func archive() async {
do {
_ = try await apiClient.archiveDocument(id: node.document.id)
await onDocumentsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't archive this document.")
}
}
private func delete() async {
do {
try await apiClient.deleteDocument(DeleteDocumentRequest(id: node.document.id))
await onDocumentsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete this document.")
}
}
private func download() async {
do {
let markdown = try await apiClient.exportDocument(id: node.document.id)
let panel = NSSavePanel()
panel.nameFieldStringValue = "\(node.document.title.isEmpty ? "Untitled" : node.document.title).md"
panel.allowedContentTypes = [.text]
guard panel.runModal() == .OK, let url = panel.url else { return }
try markdown.write(to: url, atomically: true, encoding: .utf8)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't download this document.")
}
}
private func copyMarkdown() async {
do {
let full = try await apiClient.documentInfo(id: node.document.id)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(full.text, forType: .string)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't copy this document.")
}
}
/// No access to the reader's rendered `NSTextView` (MarkdownEngine
/// doesn't expose it), so this prints the raw markdown source as plain
/// monospaced text via a standalone `NSTextView` built just for the print
/// job, rather than a styled rendering of the document.
private func printDocument() async {
do {
let full = try await apiClient.documentInfo(id: node.document.id)
let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 612, height: 792))
textView.string = full.text
textView.font = .monospacedSystemFont(ofSize: 11, weight: .regular)
let operation = NSPrintOperation(view: textView)
operation.printInfo.horizontalPagination = .fit
operation.printInfo.verticalPagination = .automatic
operation.run()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't print this document.")
}
}
}
#endif