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:
@@ -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
|
||||
Reference in New Issue
Block a user