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 searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument]
|
||||||
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument
|
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument
|
||||||
func updateDocument(_ request: UpdateDocumentRequest) 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 listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
|
||||||
func collectionInfo(id: String) 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)
|
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] {
|
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||||
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
|
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
|
||||||
}
|
}
|
||||||
@@ -188,6 +229,15 @@ private struct ExportCollectionResponse: Decodable {
|
|||||||
let fileOperation: OutlineFileOperation
|
let fileOperation: OutlineFileOperation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct DuplicateDocumentResponse: Decodable {
|
||||||
|
let documents: [OutlineDocument]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MoveDocumentResponse: Decodable {
|
||||||
|
let documents: [OutlineDocument]?
|
||||||
|
let collections: [OutlineCollection]?
|
||||||
|
}
|
||||||
|
|
||||||
private struct SuccessEnvelope: Decodable {
|
private struct SuccessEnvelope: Decodable {
|
||||||
let success: Bool
|
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 {
|
func testMissingTokenThrowsTokenUnavailable() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
let client = LiveOutlineAPIClient(
|
let client = LiveOutlineAPIClient(
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Nested document outline under an expanded collection in the sidebar tree —
|
/// Nested document outline under an expanded collection in the sidebar tree —
|
||||||
/// real hierarchy via `parentDocumentId`, see DocumentNode for why that's
|
/// real hierarchy via `parentDocumentId`, see DocumentNode for why that's
|
||||||
/// client-side rather than `collections.documents`.
|
/// client-side rather than `collections.documents`.
|
||||||
struct CollectionDocumentsOutline: View {
|
struct CollectionDocumentsOutline: View {
|
||||||
|
let apiClient: OutlineAPIClient
|
||||||
@State private var viewModel: DocumentsViewModel
|
@State private var viewModel: DocumentsViewModel
|
||||||
let sortOption: SidebarSortOption
|
let sortOption: SidebarSortOption
|
||||||
let refreshToken: Int
|
let refreshToken: Int
|
||||||
@@ -26,6 +29,7 @@ struct CollectionDocumentsOutline: View {
|
|||||||
selectedDocumentID: String?,
|
selectedDocumentID: String?,
|
||||||
onSelectDocument: @escaping ([OutlineDocument]) -> Void
|
onSelectDocument: @escaping ([OutlineDocument]) -> Void
|
||||||
) {
|
) {
|
||||||
|
self.apiClient = apiClient
|
||||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||||
self.sortOption = sortOption
|
self.sortOption = sortOption
|
||||||
self.refreshToken = refreshToken
|
self.refreshToken = refreshToken
|
||||||
@@ -47,11 +51,13 @@ struct CollectionDocumentsOutline: View {
|
|||||||
} else {
|
} else {
|
||||||
ForEach(tree) { node in
|
ForEach(tree) { node in
|
||||||
DocumentNodeRow(
|
DocumentNodeRow(
|
||||||
|
apiClient: apiClient,
|
||||||
node: node,
|
node: node,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
ancestors: [],
|
ancestors: [],
|
||||||
selectedDocumentID: selectedDocumentID,
|
selectedDocumentID: selectedDocumentID,
|
||||||
onSelectDocument: onSelectDocument
|
onSelectDocument: onSelectDocument,
|
||||||
|
onDocumentsChanged: { await viewModel.load() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,15 +67,29 @@ struct CollectionDocumentsOutline: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct DocumentNodeRow: View {
|
private struct DocumentNodeRow: View {
|
||||||
|
let apiClient: OutlineAPIClient
|
||||||
let node: DocumentNode
|
let node: DocumentNode
|
||||||
let depth: Int
|
let depth: Int
|
||||||
/// Chain from root down to (not including) this node.
|
/// Chain from root down to (not including) this node.
|
||||||
let ancestors: [OutlineDocument]
|
let ancestors: [OutlineDocument]
|
||||||
let selectedDocumentID: String?
|
let selectedDocumentID: String?
|
||||||
let onSelectDocument: ([OutlineDocument]) -> Void
|
let onSelectDocument: ([OutlineDocument]) -> Void
|
||||||
|
let onDocumentsChanged: () async -> Void
|
||||||
|
|
||||||
@State private var isExpanded = false
|
@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 {
|
private var isSelected: Bool {
|
||||||
node.id == selectedDocumentID
|
node.id == selectedDocumentID
|
||||||
}
|
}
|
||||||
@@ -128,15 +148,18 @@ private struct DocumentNodeRow: View {
|
|||||||
isSelected ? Color.accentColor.opacity(0.15) : Color.clear,
|
isSelected ? Color.accentColor.opacity(0.15) : Color.clear,
|
||||||
in: RoundedRectangle(cornerRadius: 6)
|
in: RoundedRectangle(cornerRadius: 6)
|
||||||
)
|
)
|
||||||
|
.contextMenu { contextMenuContent }
|
||||||
|
|
||||||
if isExpanded {
|
if isExpanded {
|
||||||
ForEach(node.children) { child in
|
ForEach(node.children) { child in
|
||||||
DocumentNodeRow(
|
DocumentNodeRow(
|
||||||
|
apiClient: apiClient,
|
||||||
node: child,
|
node: child,
|
||||||
depth: depth + 1,
|
depth: depth + 1,
|
||||||
ancestors: ancestors + [node.document],
|
ancestors: ancestors + [node.document],
|
||||||
selectedDocumentID: selectedDocumentID,
|
selectedDocumentID: selectedDocumentID,
|
||||||
onSelectDocument: onSelectDocument
|
onSelectDocument: onSelectDocument,
|
||||||
|
onDocumentsChanged: onDocumentsChanged
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,6 +173,285 @@ private struct DocumentNodeRow: View {
|
|||||||
isExpanded = true
|
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
|
#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