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.
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, 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
|