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.
523 lines
20 KiB
Swift
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
|