Wires up the full web-parity context menu for sidebar document rows, each action mapped against the vendored OpenAPI spec: - OutlineKit: star/templatize/duplicate/unpublish/archive/move/delete/ export/insights/revisions.list for documents, plus request/response models, with test coverage mirroring the existing client test style. - Star, Rename, Templatize, Duplicate, Unpublish, Archive, Move, New Document, Delete: direct API actions with confirmation dialogs where destructive. - History, Insights, Present: read-only sheets (revisions.list, documents.insights, distraction-free render). - Download, Copy, Print: local actions against documents.export / documents.info (save panel, pasteboard, NSPrintOperation against a plain-text NSTextView since MarkdownEngine doesn't expose its underlying NSTextView). - Search in Document: line-based find/highlight/scroll-to over the raw markdown source, same NSTextView-exposure limitation. - Unsubscribe, Pin: disabled — no subscriptions.*/pins.* endpoints exist in the API. - Edit, Permissions, Import Document: disabled — out of scope for this pass (editing is off project-wide; permissions and import are their own subsystems, not one-off actions).
458 lines
16 KiB
Swift
458 lines
16 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
|
|
@State private var viewModel: DocumentsViewModel
|
|
let sortOption: SidebarSortOption
|
|
let refreshToken: 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
|
|
|
|
private var tree: [DocumentNode] {
|
|
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
|
}
|
|
|
|
init(
|
|
apiClient: OutlineAPIClient,
|
|
collection: OutlineCollection,
|
|
sortOption: SidebarSortOption,
|
|
refreshToken: Int,
|
|
selectedDocumentID: String?,
|
|
onSelectDocument: @escaping ([OutlineDocument]) -> Void
|
|
) {
|
|
self.apiClient = apiClient
|
|
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
|
self.sortOption = sortOption
|
|
self.refreshToken = refreshToken
|
|
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,
|
|
onSelectDocument: onSelectDocument,
|
|
onDocumentsChanged: { await viewModel.load() }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.task(id: refreshToken) { await viewModel.load() }
|
|
}
|
|
}
|
|
|
|
private struct DocumentNodeRow: View {
|
|
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 onSelectDocument: ([OutlineDocument]) -> Void
|
|
let onDocumentsChanged: () 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)
|
|
}
|
|
.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,
|
|
onSelectDocument: onSelectDocument,
|
|
onDocumentsChanged: onDocumentsChanged
|
|
)
|
|
}
|
|
}
|
|
}
|
|
// 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("Star") {
|
|
Task { await star() }
|
|
}
|
|
// No `subscriptions.*` endpoint in the API — nothing to back this with.
|
|
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() }
|
|
}
|
|
// No `pins.*` endpoint in the API — nothing to back this with.
|
|
Button("Pin") {}
|
|
.disabled(true)
|
|
|
|
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 apiClient.starDocument(StarDocumentRequest(documentId: node.document.id))
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't star this document.")
|
|
}
|
|
}
|
|
|
|
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
|