Files
Outpost/Outpost/Features/Collections/CollectionDocumentsOutline.swift
T
Puranjay Savar Mattas ed9fd26c85 fix(sidebar): auto-refresh after creating a doc from the reader toolbar
The reader's inline "New Document" button (createChildDocument())
only navigated to the new document — no handle on the sidebar row it
landed under, so the tree stayed stale until the periodic
remote-changes poll surfaced the reload banner.

Right-click "New Document" on a collection already refreshed correctly
(bumps its own documentsRefreshToken) and is untouched.

Threads a documentsChangedToken from ContentView_macOS down through
CollectionsTreeView -> CollectionTreeRow -> CollectionDocumentsOutline
as externalRefreshToken; every expanded row reloads itself when it
bumps, since the reader doesn't know which row (if any) corresponds to
where the new document landed.
2026-08-14 16:50:09 +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, specifically, which doesn't know which
/// (if any) sidebar row corresponds to the collection its new document
/// landed in, 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] = [:]
private var tree: [DocumentNode] {
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()
}
}
private func loadPins() async {
guard let pins = 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 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)
}
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)
}
.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)
}
}
@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
}
// Sharing/membership management is its own subsystem, not a one-off
// action — deferred rather than half-built here.
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") {
Task { await createChildDocument() }
}
// 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 createChildDocument() async {
guard let collectionId = node.document.collectionId else {
actionErrorMessage = "This document isn't in a collection."
return
}
do {
_ = try await apiClient.createDocument(
CreateDocumentRequest(
title: "Untitled",
text: "",
collectionId: collectionId,
parentDocumentId: node.document.id
)
)
await onDocumentsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new 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