feat(collections): document right-click context menu

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).
This commit is contained in:
2026-08-14 02:19:10 +01:00
parent a665eac9b7
commit 52dacb26b3
21 changed files with 1244 additions and 2 deletions
@@ -15,6 +15,19 @@ public protocol OutlineAPIClient: Sendable {
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument]
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument
func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar
func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate
func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument]
func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument
func archiveDocument(id: String) async throws -> OutlineDocument
func moveDocument(_ request: MoveDocumentRequest) async throws
func deleteDocument(_ request: DeleteDocumentRequest) async throws
/// Requires insights to be enabled on the document server-side. Backed by `documents.insights`.
func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight]
/// Backed by `revisions.list` omits full body content for performance.
func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision]
/// Returns the document's markdown source directly (not a file operation job). Backed by `documents.export`.
func exportDocument(id: String) async throws -> String
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
func collectionInfo(id: String) async throws -> OutlineCollection
@@ -67,6 +67,47 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("documents.update", body: request)
}
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
try await post("stars.create", body: request)
}
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
try await post("documents.templatize", body: request)
}
public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] {
let result: DuplicateDocumentResponse = try await post("documents.duplicate", body: request)
return result.documents
}
public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument {
try await post("documents.unpublish", body: request)
}
public func archiveDocument(id: String) async throws -> OutlineDocument {
try await post("documents.archive", body: DocumentIDRequest(id: id))
}
public func moveDocument(_ request: MoveDocumentRequest) async throws {
let _: MoveDocumentResponse = try await post("documents.move", body: request)
}
public func deleteDocument(_ request: DeleteDocumentRequest) async throws {
try await postForSuccess("documents.delete", body: request)
}
public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] {
try await post("documents.insights", body: request)
}
public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] {
try await post("revisions.list", body: request)
}
public func exportDocument(id: String) async throws -> String {
try await post("documents.export", body: DocumentIDRequest(id: id))
}
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
}
@@ -188,6 +229,15 @@ private struct ExportCollectionResponse: Decodable {
let fileOperation: OutlineFileOperation
}
private struct DuplicateDocumentResponse: Decodable {
let documents: [OutlineDocument]
}
private struct MoveDocumentResponse: Decodable {
let documents: [OutlineDocument]?
let collections: [OutlineCollection]?
}
private struct SuccessEnvelope: Decodable {
let success: Bool
}
@@ -0,0 +1,15 @@
import Foundation
/// A daily/weekly activity rollup for a document, backed by `documents.insights`.
public struct OutlineDocumentInsight: Decodable, Sendable {
/// Plain `YYYY-MM-DD` (format: date, not date-time) kept as `String` since
/// the client's `.iso8601` decoding strategy can't parse it.
public let date: String
public let period: String
public let viewCount: Int
public let viewerCount: Int
public let commentCount: Int
public let reactionCount: Int
public let revisionCount: Int
public let editorCount: Int
}
@@ -0,0 +1,14 @@
import Foundation
/// A historical snapshot of a document, backed by `revisions.list`/`revisions.info`.
/// `revisions.list` omits `data`/`text` for performance this model only
/// carries what's listed, not the full revision body.
public struct OutlineRevision: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String
public let title: String
public let name: String?
public let icon: String?
public let collaborators: [OutlineUser]
public let createdAt: Date
}
@@ -0,0 +1,8 @@
import Foundation
/// Result of `documents.templatize` the app doesn't browse/manage templates
/// yet, so this only carries enough to confirm the action succeeded.
public struct OutlineTemplate: Decodable, Identifiable, Sendable {
public let id: String
public let title: String
}
@@ -0,0 +1,11 @@
import Foundation
public struct DeleteDocumentRequest: Encodable, Sendable {
public let id: String
public let permanent: Bool
public init(id: String, permanent: Bool = false) {
self.id = id
self.permanent = permanent
}
}
@@ -0,0 +1,11 @@
import Foundation
/// Shared body shape for endpoints that take only `{ "id": ... }`
/// `documents.archive`, `documents.export`.
public struct DocumentIDRequest: Encodable, Sendable {
public let id: String
public init(id: String) {
self.id = id
}
}
@@ -0,0 +1,13 @@
import Foundation
public struct DocumentInsightsRequest: Encodable, Sendable {
public let id: String
public let startDate: Date?
public let endDate: Date?
public init(id: String, startDate: Date? = nil, endDate: Date? = nil) {
self.id = id
self.startDate = startDate
self.endDate = endDate
}
}
@@ -0,0 +1,15 @@
import Foundation
public struct DuplicateDocumentRequest: Encodable, Sendable {
public let id: String
public let title: String?
public let recursive: Bool
public let publish: Bool
public init(id: String, title: String? = nil, recursive: Bool = true, publish: Bool = true) {
self.id = id
self.title = title
self.recursive = recursive
self.publish = publish
}
}
@@ -0,0 +1,13 @@
import Foundation
public struct ListRevisionsRequest: Encodable, Sendable {
public let documentId: String
public let offset: Int
public let limit: Int
public init(documentId: String, offset: Int = 0, limit: Int = 25) {
self.documentId = documentId
self.offset = offset
self.limit = limit
}
}
@@ -0,0 +1,13 @@
import Foundation
public struct MoveDocumentRequest: Encodable, Sendable {
public let id: String
public let collectionId: String
public let parentDocumentId: String?
public init(id: String, collectionId: String, parentDocumentId: String? = nil) {
self.id = id
self.collectionId = collectionId
self.parentDocumentId = parentDocumentId
}
}
@@ -0,0 +1,9 @@
import Foundation
public struct StarDocumentRequest: Encodable, Sendable {
public let documentId: String
public init(documentId: String) {
self.documentId = documentId
}
}
@@ -0,0 +1,13 @@
import Foundation
public struct TemplatizeDocumentRequest: Encodable, Sendable {
public let id: String
public let collectionId: String?
public let publish: Bool
public init(id: String, collectionId: String? = nil, publish: Bool = true) {
self.id = id
self.collectionId = collectionId
self.publish = publish
}
}
@@ -0,0 +1,11 @@
import Foundation
public struct UnpublishDocumentRequest: Encodable, Sendable {
public let id: String
public let detach: Bool
public init(id: String, detach: Bool = false) {
self.id = id
self.detach = detach
}
}
@@ -330,6 +330,215 @@ final class LiveOutlineAPIClientTests: XCTestCase {
}
}
func testStarDocumentDecodesStar() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "star-1",
"collectionId": null,
"documentId": "doc-1"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let star = try await client.starDocument(StarDocumentRequest(documentId: "doc-1"))
XCTAssertEqual(star.documentId, "doc-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.create")
}
func testDuplicateDocumentDecodesDocumentsArray() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"documents": [
{
"id": "doc-2",
"title": "Hello (copy)",
"text": "World",
"url": "/doc/hello-copy-doc-2",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
]
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let documents = try await client.duplicateDocument(DuplicateDocumentRequest(id: "doc-1"))
XCTAssertEqual(documents.first?.id, "doc-2")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.duplicate")
}
func testMoveDocumentSendsDestination() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": { "documents": [], "collections": [] } }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.moveDocument(MoveDocumentRequest(id: "doc-1", collectionId: "col-2", parentDocumentId: "doc-9"))
struct SentBody: Decodable {
let id: String
let collectionId: String
let parentDocumentId: String?
}
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody)
XCTAssertEqual(decodedBody.collectionId, "col-2")
XCTAssertEqual(decodedBody.parentDocumentId, "doc-9")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.move")
}
func testDeleteDocumentSucceedsOnSuccessTrue() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteDocument(DeleteDocumentRequest(id: "doc-1"))
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.delete")
}
func testArchiveDocumentDecodesDocument() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "doc-1",
"title": "Hello",
"text": "World",
"url": "/doc/hello-doc-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z",
"archivedAt": "2026-01-03T00:00:00.000Z"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let document = try await client.archiveDocument(id: "doc-1")
XCTAssertNotNil(document.archivedAt)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.archive")
}
func testExportDocumentReturnsMarkdownString() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": "# Hello\\n\\nWorld" }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let markdown = try await client.exportDocument(id: "doc-1")
XCTAssertEqual(markdown, "# Hello\n\nWorld")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.export")
}
func testListRevisionsDecodesRevisions() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "rev-1",
"documentId": "doc-1",
"title": "Hello",
"name": null,
"icon": null,
"collaborators": [
{ "id": "user-1", "name": "Jane Doe" }
],
"createdAt": "2026-01-01T00:00:00.000Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let revisions = try await client.listRevisions(ListRevisionsRequest(documentId: "doc-1"))
XCTAssertEqual(revisions.first?.id, "rev-1")
XCTAssertEqual(revisions.first?.collaborators.first?.name, "Jane Doe")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/revisions.list")
}
func testDocumentInsightsDecodesRollups() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"date": "2026-01-01",
"period": "day",
"viewCount": 3,
"viewerCount": 2,
"commentCount": 0,
"reactionCount": 0,
"revisionCount": 1,
"editorCount": 1
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let insights = try await client.documentInsights(DocumentInsightsRequest(id: "doc-1"))
XCTAssertEqual(insights.first?.viewCount, 3)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.insights")
}
func testMissingTokenThrowsTokenUnavailable() async throws {
let httpClient = MockHTTPClient()
let client = LiveOutlineAPIClient(
@@ -1,11 +1,14 @@
#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
@@ -26,6 +29,7 @@ struct CollectionDocumentsOutline: View {
selectedDocumentID: String?,
onSelectDocument: @escaping ([OutlineDocument]) -> Void
) {
self.apiClient = apiClient
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
self.sortOption = sortOption
self.refreshToken = refreshToken
@@ -47,11 +51,13 @@ struct CollectionDocumentsOutline: View {
} else {
ForEach(tree) { node in
DocumentNodeRow(
apiClient: apiClient,
node: node,
depth: 0,
ancestors: [],
selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument
onSelectDocument: onSelectDocument,
onDocumentsChanged: { await viewModel.load() }
)
}
}
@@ -61,15 +67,29 @@ struct CollectionDocumentsOutline: View {
}
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
}
@@ -128,15 +148,18 @@ private struct DocumentNodeRow: View {
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
onSelectDocument: onSelectDocument,
onDocumentsChanged: onDocumentsChanged
)
}
}
@@ -150,6 +173,285 @@ private struct DocumentNodeRow: View {
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
@@ -0,0 +1,75 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Read-only revision list, backed by `revisions.list`. No diff/restore view
/// `revisions.list` omits body content for performance, and restoring is a
/// bigger follow-up feature, not a one-off action.
struct DocumentHistorySheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let document: OutlineDocument
@State private var revisions: [OutlineRevision] = []
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("History")
.font(.headline)
Spacer()
Button("Done") { dismiss() }
}
.padding()
Divider()
Group {
if isLoading && revisions.isEmpty {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage {
ContentUnavailableView {
Label("Couldn't Load History", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
}
} else if revisions.isEmpty {
ContentUnavailableView("No Revisions Yet", systemImage: "clock")
} else {
List(revisions) { revision in
VStack(alignment: .leading, spacing: 2) {
Text(revision.title.isEmpty ? "Untitled" : revision.title)
.font(.body)
HStack(spacing: 4) {
Text(revision.createdAt, format: .dateTime.day().month().year().hour().minute())
if let author = revision.collaborators.first?.name {
Text("\u{2022} \(author)")
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(width: 420, height: 420)
.task { await load() }
}
private func load() async {
isLoading = true
defer { isLoading = false }
do {
revisions = try await apiClient.listRevisions(ListRevisionsRequest(documentId: document.id))
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load revision history.")
}
}
}
#endif
@@ -0,0 +1,99 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Backed by `documents.insights` server returns an error if insights
/// aren't enabled on the document, surfaced like any other load failure.
struct DocumentInsightsSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let document: OutlineDocument
@State private var rollups: [OutlineDocumentInsight] = []
@State private var isLoading = false
@State private var errorMessage: String?
private var totalViews: Int { rollups.reduce(0) { $0 + $1.viewCount } }
private var totalViewers: Int { rollups.reduce(0) { $0 + $1.viewerCount } }
private var totalComments: Int { rollups.reduce(0) { $0 + $1.commentCount } }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Insights")
.font(.headline)
Spacer()
Button("Done") { dismiss() }
}
.padding()
Divider()
Group {
if isLoading && rollups.isEmpty {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage {
ContentUnavailableView {
Label("Couldn't Load Insights", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
}
} else {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 24) {
statColumn("Views", totalViews)
statColumn("Viewers", totalViewers)
statColumn("Comments", totalComments)
}
Text("Last 30 days, by day")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
if rollups.isEmpty {
Text("No activity in this window.")
.font(.callout)
.foregroundStyle(.secondary)
} else {
List(rollups, id: \.date) { rollup in
HStack {
Text(rollup.date)
Spacer()
Text("\(rollup.viewCount) views \u{00B7} \(rollup.viewerCount) viewers")
.foregroundStyle(.secondary)
}
.font(.callout)
}
}
}
.padding()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(width: 420, height: 420)
.task { await load() }
}
private func statColumn(_ label: String, _ value: Int) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text("\(value)")
.font(.title2.weight(.semibold))
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func load() async {
isLoading = true
defer { isLoading = false }
do {
rollups = try await apiClient.documentInsights(DocumentInsightsRequest(id: document.id))
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load insights for this document.")
}
}
}
#endif
@@ -0,0 +1,82 @@
#if os(macOS)
import SwiftUI
import MarkdownEngine
import OutlineKit
/// Distraction-free reading view no toolbar/sidebar chrome, larger type.
/// Presentation is just a bigger render of the same markdown, not a real
/// slide-by-slide deck (Outline's own "Present" isn't slide-based either).
struct DocumentPresentSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let document: OutlineDocument
@State private var text: String
@State private var isLoading = false
@State private var errorMessage: String?
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
self.apiClient = apiClient
self.document = document
_text = State(initialValue: document.text)
}
var body: some View {
ZStack(alignment: .topTrailing) {
if isLoading && text.isEmpty {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
}
} else {
ScrollView {
VStack(alignment: .leading) {
if let emoji = document.emoji {
Text(emoji).font(.system(size: 48))
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.system(size: 34, weight: .bold))
.padding(.bottom, 8)
NativeTextViewWrapper(
text: $text,
configuration: .init(heightBehavior: .fitsContent),
isEditable: false
)
.font(.system(size: 18))
}
.frame(maxWidth: 720, alignment: .leading)
.padding(48)
.frame(maxWidth: .infinity)
}
}
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.padding(20)
}
.frame(minWidth: 800, minHeight: 600)
.background(.background)
.task { await load() }
}
private func load() async {
isLoading = true
defer { isLoading = false }
do {
text = try await apiClient.documentInfo(id: document.id).text
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
}
}
}
#endif
@@ -0,0 +1,144 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// In-document find. `MarkdownEngine`'s `NativeTextViewWrapper` doesn't expose
/// its underlying `NSTextView` (no `NSTextFinder`/coordinator access in its
/// public API), so this renders the raw markdown source as plain per-line
/// text instead of the styled reader view the tradeoff is losing rich
/// rendering in exchange for real match highlighting and scroll-to-match via
/// `ScrollViewReader`, which wouldn't be possible against an opaque view.
struct DocumentSearchSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let document: OutlineDocument
@State private var lines: [String] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var query = ""
@State private var currentMatchIndex = 0
@FocusState private var isSearchFieldFocused: Bool
private var matchingLineIndices: [Int] {
guard !query.isEmpty else { return [] }
return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
.textFieldStyle(.plain)
.focused($isSearchFieldFocused)
.onChange(of: query) { currentMatchIndex = 0 }
if !matchingLineIndices.isEmpty {
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
.font(.caption)
.foregroundStyle(.secondary)
Button {
step(-1)
} label: {
Image(systemName: "chevron.up")
}
Button {
step(1)
} label: {
Image(systemName: "chevron.down")
}
} else if !query.isEmpty {
Text("No matches")
.font(.caption)
.foregroundStyle(.secondary)
}
Button("Done") { dismiss() }
}
.padding()
Divider()
Group {
if isLoading && lines.isEmpty {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
}
} else {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 4) {
ForEach(Array(lines.enumerated()), id: \.offset) { index, line in
Text(line.isEmpty ? " " : line)
.font(.system(.body, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 4)
.background(
isCurrentMatch(index) ? Color.yellow.opacity(0.4)
: matchingLineIndices.contains(index) ? Color.yellow.opacity(0.15)
: Color.clear
)
.id(index)
}
}
.padding()
}
.onChange(of: currentMatchIndex) {
guard let target = matchingLineIndices[safe: currentMatchIndex] else { return }
withAnimation {
proxy.scrollTo(target, anchor: .center)
}
}
.onChange(of: query) {
guard let target = matchingLineIndices.first else { return }
withAnimation {
proxy.scrollTo(target, anchor: .center)
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(width: 640, height: 520)
.task {
await load()
isSearchFieldFocused = true
}
}
private func isCurrentMatch(_ index: Int) -> Bool {
matchingLineIndices[safe: currentMatchIndex] == index
}
private func step(_ delta: Int) {
guard !matchingLineIndices.isEmpty else { return }
let count = matchingLineIndices.count
currentMatchIndex = ((currentMatchIndex + delta) % count + count) % count
}
private func load() async {
isLoading = true
defer { isLoading = false }
do {
let text = try await apiClient.documentInfo(id: document.id).text
lines = text.components(separatedBy: "\n")
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
}
}
}
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
#endif
@@ -0,0 +1,122 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Destination picker for `documents.move` collection, then optionally a
/// root-level document in that collection to nest under. Doesn't offer
/// picking a destination deeper than one level (i.e. can't move under a
/// grandchild) kept intentionally shallow rather than mirroring the full
/// sidebar tree in a picker.
struct MoveDocumentSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let document: OutlineDocument
let onMoved: () async -> Void
@State private var collections: [OutlineCollection] = []
@State private var selectedCollectionID: String?
@State private var rootDocuments: [OutlineDocument] = []
@State private var selectedParentID: String?
@State private var isLoadingCollections = false
@State private var isLoadingDestinationDocuments = false
@State private var isMoving = false
@State private var errorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Move \"\(document.title.isEmpty ? "Untitled" : document.title)\"")
.font(.headline)
if isLoadingCollections {
ProgressView().frame(maxWidth: .infinity)
} else {
Picker("Collection", selection: $selectedCollectionID) {
Text("Choose a collection").tag(String?.none)
ForEach(collections) { collection in
Text(collection.name).tag(Optional(collection.id))
}
}
.labelsHidden()
Picker("Location", selection: $selectedParentID) {
Text("Collection root").tag(String?.none)
ForEach(rootDocuments.filter { $0.id != document.id }) { candidate in
Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id))
}
}
.labelsHidden()
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
}
if let errorMessage {
Text(errorMessage)
.font(.callout)
.foregroundStyle(.red)
}
HStack {
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
Button("Move") {
Task { await move() }
}
.disabled(selectedCollectionID == nil || isMoving)
}
}
.padding(20)
.frame(width: 360)
.task {
await loadCollections()
selectedCollectionID = document.collectionId
}
.task(id: selectedCollectionID) {
await loadRootDocuments()
}
}
private func loadCollections() async {
isLoadingCollections = true
defer { isLoadingCollections = false }
do {
collections = try await apiClient.listCollections(offset: 0, limit: 100)
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.")
}
}
private func loadRootDocuments() async {
guard let selectedCollectionID else {
rootDocuments = []
return
}
isLoadingDestinationDocuments = true
defer { isLoadingDestinationDocuments = false }
do {
rootDocuments = try await apiClient.listDocuments(
collectionId: selectedCollectionID,
parentDocumentId: nil,
offset: 0,
limit: 100
)
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.")
}
}
private func move() async {
guard let selectedCollectionID else { return }
isMoving = true
defer { isMoving = false }
do {
try await apiClient.moveDocument(
MoveDocumentRequest(id: document.id, collectionId: selectedCollectionID, parentDocumentId: selectedParentID)
)
await onMoved()
dismiss()
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't move this document.")
}
}
}
#endif