diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index 5f2f8fb..3604545 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -28,6 +28,19 @@ public protocol OutlineAPIClient: Sendable { 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 createShare(_ request: CreateShareRequest) async throws -> OutlineShare + func shareInfo(documentId: String) async throws -> OutlineShare? + func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare + /// See `OutlinePin` — best-effort, not in the vendored spec. + func createPin(_ request: CreatePinRequest) async throws -> OutlinePin + func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] + func deletePin(id: String) async throws + /// See `OutlineSubscription` — best-effort, not in the vendored spec. + func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription + func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] + func deleteSubscription(id: String) async throws + /// Historical view records, not live presence. Backed by `views.list`. + func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] func collectionInfo(id: String) async throws -> OutlineCollection diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 508f749..19a3725 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -108,6 +108,50 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("documents.export", body: DocumentIDRequest(id: id)) } + public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { + try await post("shares.create", body: request) + } + + public func shareInfo(documentId: String) async throws -> OutlineShare? { + do { + return try await post("shares.info", body: ShareInfoRequest(documentId: documentId)) + } catch OutlineAPIError.notFound { + return nil + } + } + + public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { + try await post("shares.update", body: request) + } + + public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { + try await post("pins.create", body: request) + } + + public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { + try await post("pins.list", body: request) + } + + public func deletePin(id: String) async throws { + try await postForSuccess("pins.delete", body: StarIDParams(id: id)) + } + + public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { + try await post("subscriptions.create", body: request) + } + + public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { + try await post("subscriptions.list", body: request) + } + + public func deleteSubscription(id: String) async throws { + try await postForSuccess("subscriptions.delete", body: StarIDParams(id: id)) + } + + public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { + try await post("views.list", body: request) + } + public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit)) } diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineDocument.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineDocument.swift index 596d165..8b44c6f 100644 --- a/OutlineKit/Sources/OutlineKit/Models/OutlineDocument.swift +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineDocument.swift @@ -9,6 +9,7 @@ public struct OutlineDocument: Codable, Identifiable, Hashable, Sendable { public let parentDocumentId: String? public let url: String public let revision: Int? + public let fullWidth: Bool? public let createdAt: Date public let updatedAt: Date public let publishedAt: Date? @@ -24,6 +25,7 @@ public struct OutlineDocument: Codable, Identifiable, Hashable, Sendable { parentDocumentId: String? = nil, url: String, revision: Int? = nil, + fullWidth: Bool? = nil, createdAt: Date, updatedAt: Date, publishedAt: Date? = nil, @@ -38,6 +40,7 @@ public struct OutlineDocument: Codable, Identifiable, Hashable, Sendable { self.parentDocumentId = parentDocumentId self.url = url self.revision = revision + self.fullWidth = fullWidth self.createdAt = createdAt self.updatedAt = updatedAt self.publishedAt = publishedAt diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift b/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift new file mode 100644 index 0000000..1637c40 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift @@ -0,0 +1,14 @@ +import Foundation + +/// A document pinned to the top of a collection (or the team home), backed by +/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`) +/// — that spec has no `Pins` tag at all — but the endpoint exists on Outline's +/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed +/// from general knowledge of Outline's API, not verified against this spec; +/// treat field names as best-effort until confirmed against a live server. +public struct OutlinePin: Decodable, Identifiable, Sendable { + public let id: String + public let documentId: String + public let collectionId: String? + public let index: String? +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift new file mode 100644 index 0000000..1cb4660 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift @@ -0,0 +1,10 @@ +import Foundation + +/// A public share link for a document or collection, backed by `shares.*`. +public struct OutlineShare: Decodable, Identifiable, Sendable { + public let id: String + public let documentId: String? + public let collectionId: String? + public let url: String + public let published: Bool +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineSubscription.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineSubscription.swift new file mode 100644 index 0000000..9731f1d --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineSubscription.swift @@ -0,0 +1,15 @@ +import Foundation + +/// A notification subscription on a document (or collection), backed by +/// `subscriptions.*`. Same caveat as `OutlinePin`: not in the vendored +/// OpenAPI spec — no `Subscriptions` tag exists there — but the endpoint +/// exists on Outline's actual server (`server/routes/api/subscriptions.ts` +/// upstream). Shape reconstructed from general knowledge, not verified +/// against this spec; treat field names as best-effort until confirmed +/// against a live server. +public struct OutlineSubscription: Decodable, Identifiable, Sendable { + public let id: String + public let documentId: String? + public let collectionId: String? + public let event: String +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineView.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineView.swift new file mode 100644 index 0000000..7e87283 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineView.swift @@ -0,0 +1,15 @@ +import Foundation + +/// A record of a user having viewed a document, backed by `views.list`. +/// This is historical/aggregated (`firstViewedAt`/`lastViewedAt`/`count`), +/// not live presence — there's no realtime "viewing right now" signal over +/// REST, only over the Hocuspocus collaboration socket's awareness protocol. +public struct OutlineView: Decodable, Identifiable, Sendable { + public let id: String + public let documentId: String + public let userId: String + public let user: OutlineUser + public let count: Int + public let firstViewedAt: Date? + public let lastViewedAt: Date? +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift new file mode 100644 index 0000000..e8bc0d7 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift @@ -0,0 +1,12 @@ +import Foundation + +/// See `OutlinePin` — best-effort shape, not in the vendored spec. +public struct CreatePinRequest: Encodable, Sendable { + public let documentId: String + public let collectionId: String? + + public init(documentId: String, collectionId: String? = nil) { + self.documentId = documentId + self.collectionId = collectionId + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateShareRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateShareRequest.swift new file mode 100644 index 0000000..d6e918b --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateShareRequest.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct CreateShareRequest: Encodable, Sendable { + public let documentId: String? + public let collectionId: String? + + public init(documentId: String? = nil, collectionId: String? = nil) { + self.documentId = documentId + self.collectionId = collectionId + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift new file mode 100644 index 0000000..c2cf5f2 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift @@ -0,0 +1,12 @@ +import Foundation + +/// See `OutlineSubscription` — best-effort shape, not in the vendored spec. +public struct CreateSubscriptionRequest: Encodable, Sendable { + public let documentId: String + public let event: String + + public init(documentId: String, event: String = "documents.update") { + self.documentId = documentId + self.event = event + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift new file mode 100644 index 0000000..29dc61f --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift @@ -0,0 +1,10 @@ +import Foundation + +/// See `OutlinePin` — best-effort shape, not in the vendored spec. +public struct ListPinsRequest: Encodable, Sendable { + public let collectionId: String? + + public init(collectionId: String? = nil) { + self.collectionId = collectionId + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListSubscriptionsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListSubscriptionsRequest.swift new file mode 100644 index 0000000..5348078 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListSubscriptionsRequest.swift @@ -0,0 +1,12 @@ +import Foundation + +/// See `OutlineSubscription` — best-effort shape, not in the vendored spec. +public struct ListSubscriptionsRequest: Encodable, Sendable { + public let documentId: String + public let event: String + + public init(documentId: String, event: String = "documents.update") { + self.documentId = documentId + self.event = event + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListViewsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListViewsRequest.swift new file mode 100644 index 0000000..b46e51e --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListViewsRequest.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct ListViewsRequest: Encodable, Sendable { + public let documentId: String + + public init(documentId: String) { + self.documentId = documentId + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ShareInfoRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ShareInfoRequest.swift new file mode 100644 index 0000000..37aa05a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ShareInfoRequest.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct ShareInfoRequest: Encodable, Sendable { + public let documentId: String? + + public init(documentId: String?) { + self.documentId = documentId + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift index cd3b733..b7a5611 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift @@ -5,16 +5,30 @@ public struct UpdateDocumentRequest: Encodable, Sendable { public let title: String? public let text: String? public let append: Bool? + public let fullWidth: Bool? + public let insightsEnabled: Bool? + /// Not in the vendored spec's `Document`/`documents.update` shape at all + /// (only a workspace-level `documentEmbeds` flag exists there) — included + /// speculatively since the field may exist on newer self-hosted servers. + /// Unrecognized fields are typically ignored server-side rather than + /// rejected, so this is low-risk even if unsupported. + public let documentEmbeds: Bool? public init( id: String, title: String? = nil, text: String? = nil, - append: Bool? = nil + append: Bool? = nil, + fullWidth: Bool? = nil, + insightsEnabled: Bool? = nil, + documentEmbeds: Bool? = nil ) { self.id = id self.title = title self.text = text self.append = append + self.fullWidth = fullWidth + self.insightsEnabled = insightsEnabled + self.documentEmbeds = documentEmbeds } } diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift new file mode 100644 index 0000000..9b9abc5 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct UpdateShareRequest: Encodable, Sendable { + public let id: String + public let published: Bool + + public init(id: String, published: Bool) { + self.id = id + self.published = published + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 7fcf1b6..82c518f 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -584,6 +584,116 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.delete") } + func testCreateShareDecodesShare() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "share-1", + "documentId": "doc-1", + "collectionId": null, + "url": "https://outline.example.com/s/share-1", + "published": false + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let share = try await client.createShare(CreateShareRequest(documentId: "doc-1")) + + XCTAssertEqual(share.url, "https://outline.example.com/s/share-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create") + } + + func testShareInfoReturnsNilOnNotFound() async throws { + let httpClient = MockHTTPClient() + httpClient.statusCode = 404 + httpClient.responseData = """ + { "ok": false, "error": "not_found", "message": "Not Found" } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let share = try await client.shareInfo(documentId: "doc-1") + + XCTAssertNil(share) + } + + func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { + "id": "view-1", + "documentId": "doc-1", + "userId": "user-1", + "user": { "id": "user-1", "name": "Jane Doe" }, + "count": 3, + "firstViewedAt": "2026-01-01T00:00:00.000Z", + "lastViewedAt": "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 views = try await client.listViews(ListViewsRequest(documentId: "doc-1")) + + XCTAssertEqual(views.first?.user.name, "Jane Doe") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list") + } + + func testCreatePinDecodesPin() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "data": { "id": "pin-1", "documentId": "doc-1", "collectionId": "col-1", "index": "a" } } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let pin = try await client.createPin(CreatePinRequest(documentId: "doc-1", collectionId: "col-1")) + + XCTAssertEqual(pin.id, "pin-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create") + } + + func testCreateSubscriptionDecodesSubscription() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "data": { "id": "sub-1", "documentId": "doc-1", "collectionId": null, "event": "documents.update" } } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let subscription = try await client.createSubscription(CreateSubscriptionRequest(documentId: "doc-1")) + + XCTAssertEqual(subscription.id, "sub-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create") + } + func testMissingTokenThrowsTokenUnavailable() async throws { let httpClient = MockHTTPClient() let client = LiveOutlineAPIClient( diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index dd361de..4d650f8 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -10,6 +10,7 @@ struct ContentView_macOS: View { @State private var documentPath: [OutlineDocument] = [] @State private var globalSearchQuery = "" @State private var contextualSearchQuery = "" + @State private var isContextualSearchExpanded = false @FocusState private var isContextualSearchFocused: Bool private var trimmedGlobalQuery: String { @@ -45,16 +46,56 @@ struct ContentView_macOS: View { ToolbarItem(placement: .navigation) { leadingToolbarContent } + // Custom instead of `.searchable`: that modifier always renders a + // full-width field, but this is meant to sit alongside the other + // per-document toolbar buttons as a plain icon that only expands + // into a field once clicked. + ToolbarItem(placement: .primaryAction) { + contextualSearchField + } + } + } + + @ViewBuilder + private var contextualSearchField: some View { + if isContextualSearchExpanded || !contextualSearchQuery.isEmpty { + HStack(spacing: 4) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField( + selectedCollection.map { "Search \($0.name)" } ?? "Search", + text: $contextualSearchQuery + ) + .textFieldStyle(.plain) + .focused($isContextualSearchFocused) + .frame(width: 180) + + if !contextualSearchQuery.isEmpty { + Button { + contextualSearchQuery = "" + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.fill.tertiary, in: Capsule()) + .onChange(of: isContextualSearchFocused) { _, focused in + if !focused && contextualSearchQuery.isEmpty { + isContextualSearchExpanded = false + } + } + } else { + Button { + isContextualSearchExpanded = true + Task { @MainActor in isContextualSearchFocused = true } + } label: { + Image(systemName: "magnifyingglass") + } } - // Attached at this level (not inside the detail's NavigationStack) so - // it reliably renders in the shared window toolbar instead of a - // per-screen one that can get lost when nested more deeply. - .searchable( - text: $contextualSearchQuery, - placement: .toolbar, - prompt: selectedCollection.map { "Search \($0.name)" } ?? "Search" - ) - .searchFocused($isContextualSearchFocused) } @ViewBuilder @@ -123,6 +164,7 @@ struct ContentView_macOS: View { private func searchInCollection(_ collection: OutlineCollection) { globalSearchQuery = "" selectedCollection = collection + isContextualSearchExpanded = true Task { @MainActor in isContextualSearchFocused = true } @@ -182,7 +224,12 @@ struct ContentView_macOS: View { DocumentReaderView( apiClient: apiClient, document: document, - onOpenChild: { child in documentPath.append(child) } + onOpenChild: { child in documentPath.append(child) }, + onDeleted: { + if !documentPath.isEmpty { + documentPath.removeLast() + } + } ) } } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 803c141..cbd8786 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -1,26 +1,62 @@ #if os(macOS) +import AppKit import SwiftUI +import UniformTypeIdentifiers import MarkdownEngine import OutlineKit -/// Read-only (`isEditable: false`), matching the Overview tab — document editing -/// isn't wired up yet, but link clicks and text selection still work. +/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions +/// below must run on the main thread — see the identical note on +/// `DocumentNodeRow` in `CollectionDocumentsOutline.swift`. +@MainActor struct DocumentReaderView: View { + @Environment(SessionStore.self) private var session + @Environment(StarStore.self) private var starStore + @State private var viewModel: DocumentReaderViewModel + let apiClient: OutlineAPIClient + let document: OutlineDocument /// Pushes a genuine new stack entry (not a reset+replace) — the back /// button then correctly returns to this document, not the collection. let onOpenChild: (OutlineDocument) -> Void + /// Called after Delete/Archive/Unpublish succeed — the document is no + /// longer visible in the collection it was opened from, so the reader + /// pops itself off the navigation stack. + let onDeleted: () -> Void - init(apiClient: OutlineAPIClient, document: OutlineDocument, onOpenChild: @escaping (OutlineDocument) -> Void) { + @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 isShowingShareSheet = false + @State private var actionErrorMessage: String? + + init( + apiClient: OutlineAPIClient, + document: OutlineDocument, + onOpenChild: @escaping (OutlineDocument) -> Void, + onDeleted: @escaping () -> Void + ) { + self.apiClient = apiClient + self.document = document _viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document)) self.onOpenChild = onOpenChild + self.onDeleted = onDeleted } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { - // No in-content header — the document's icon/title live in the - // window toolbar now (via `ContentView_macOS`). + if viewModel.isEditing { + TextField("Title", text: $viewModel.title) + .font(.largeTitle.weight(.bold)) + .textFieldStyle(.plain) + } + if viewModel.isLoading && viewModel.text.isEmpty { ProgressView() .frame(maxWidth: .infinity) @@ -38,7 +74,7 @@ struct DocumentReaderView: View { NativeTextViewWrapper( text: $viewModel.text, configuration: .init(heightBehavior: .fitsContent), - isEditable: false + isEditable: viewModel.isEditing ) if !viewModel.children.isEmpty { @@ -47,12 +83,9 @@ struct DocumentReaderView: View { } } .padding() + .frame(maxWidth: viewModel.isFullWidth ? .infinity : 900) + .frame(maxWidth: .infinity) } - // No `.navigationTitle` here either — same reason as CollectionOverviewView. - // Corner spinner, not gated on `text.isEmpty`: `viewModel.text` starts - // pre-filled from the list/summary copy of the doc, so the full-page - // spinner above rarely fires on open — without this, the re-fetch in - // `loadFullContent()` looked like nothing was happening. .overlay(alignment: .topTrailing) { if viewModel.isLoading && !viewModel.text.isEmpty { ProgressView() @@ -60,7 +93,130 @@ struct DocumentReaderView: View { .padding(12) } } + // No `.navigationTitle` here either — same reason as CollectionOverviewView. + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + viewerAvatars + Button { + isShowingShareSheet = true + } label: { + Image(systemName: "square.and.arrow.up") + } + .help("Share") + + Button { + Task { await viewModel.toggleEditing() } + } label: { + if viewModel.isSaving { + ProgressView().controlSize(.small) + } else { + Text(viewModel.isEditing ? "Done" : "Edit") + } + } + .disabled(viewModel.isSaving) + + Button { + Task { await createChildDocument() } + } label: { + Image(systemName: "doc.badge.plus") + } + .help("New Document") + + Menu { + menuContent + } label: { + Image(systemName: "ellipsis.circle") + } + } + } .task { await viewModel.loadFullContent() } + .task { + await viewModel.loadPinAndSubscriptionState() + } + .task { + while !Task.isCancelled { + await viewModel.loadViewers() + try? await Task.sleep(for: .seconds(20)) + } + } + .confirmationDialog( + "Unpublish \"\(viewModel.title.isEmpty ? "Untitled" : viewModel.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 \"\(viewModel.title.isEmpty ? "Untitled" : viewModel.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 \"\(viewModel.title.isEmpty ? "Untitled" : viewModel.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: document) { + onDeleted() + } + } + .sheet(isPresented: $isShowingHistorySheet) { + DocumentHistorySheet(apiClient: apiClient, document: document) + } + .sheet(isPresented: $isShowingInsightsSheet) { + DocumentInsightsSheet(apiClient: apiClient, document: document) + } + .sheet(isPresented: $isShowingPresentSheet) { + DocumentPresentSheet(apiClient: apiClient, document: document) + } + .sheet(isPresented: $isShowingSearchSheet) { + DocumentSearchSheet(apiClient: apiClient, document: document) + } + .sheet(isPresented: $isShowingShareSheet) { + DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) + } + } + + @ViewBuilder + private var viewerAvatars: some View { + if !viewModel.viewers.isEmpty { + HStack(spacing: -6) { + ForEach(viewModel.viewers.prefix(5)) { viewer in + AvatarBadge( + avatarURL: viewer.user.avatarUrl.flatMap { URL(string: $0, relativeTo: session.serverURL)?.absoluteURL }, + size: 20 + ) + .overlay(Circle().stroke(.background, lineWidth: 1.5)) + .help(Text(viewer.user.name)) + } + } + .padding(.trailing, 4) + } } private var childrenSection: some View { @@ -87,5 +243,241 @@ struct DocumentReaderView: View { } } } + + @ViewBuilder + private var menuContent: some View { + Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") { + Task { await star() } + } + Button(viewModel.isSubscribed ? "Unsubscribe" : "Subscribe") { + Task { await toggleSubscription() } + } + + Divider() + + Button(viewModel.isEditing ? "Done Editing" : "Edit") { + Task { await viewModel.toggleEditing() } + } + // 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 — deferred rather than + // half-built here. + Button("Import Document…") {} + .disabled(true) + Button("New Document") { + Task { await createChildDocument() } + } + Button(viewModel.isPinned ? "Unpin" : "Pin") { + Task { await togglePin() } + } + + Divider() + + Button("History") { + isShowingHistorySheet = true + } + Button("Insights") { + isShowingInsightsSheet = true + } + Button("Present") { + isShowingPresentSheet = true + } + + Divider() + + Button("Download") { + Task { await download() } + } + Button("Copy") { + Task { await copyMarkdown() } + } + Button("Print") { + Task { await printDocument() } + } + Button("Search in Document") { + isShowingSearchSheet = true + } + + Divider() + + Button("Enable Viewer Insights") { + Task { await enableInsights() } + } + Button("Enable Embeds") { + Task { await enableEmbeds() } + } + Button(viewModel.isFullWidth ? "Default Width" : "Full Width") { + Task { await toggleFullWidth() } + } + + Divider() + + Button("Delete…", role: .destructive) { + isShowingDeleteConfirmation = true + } + } + + private func star() async { + do { + try await starStore.toggleDocument(viewModel.documentId, apiClient: apiClient) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this document.") + } + } + + private func togglePin() async { + do { + try await viewModel.togglePin() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.") + } + } + + private func toggleSubscription() async { + do { + try await viewModel.toggleSubscription() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update subscription state.") + } + } + + private func toggleFullWidth() async { + do { + try await viewModel.toggleFullWidth() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't change document width.") + } + } + + private func enableInsights() async { + do { + try await viewModel.enableViewerInsights() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable viewer insights.") + } + } + + private func enableEmbeds() async { + do { + try await viewModel.enableEmbeds() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable embeds.") + } + } + + private func templatize() async { + do { + _ = try await apiClient.templatizeDocument(TemplatizeDocumentRequest(id: viewModel.documentId)) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't templatize this document.") + } + } + + private func duplicate() async { + do { + _ = try await apiClient.duplicateDocument(DuplicateDocumentRequest(id: viewModel.documentId)) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't duplicate this document.") + } + } + + private func unpublish() async { + do { + _ = try await apiClient.unpublishDocument(UnpublishDocumentRequest(id: viewModel.documentId)) + onDeleted() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't unpublish this document.") + } + } + + private func archive() async { + do { + _ = try await apiClient.archiveDocument(id: viewModel.documentId) + onDeleted() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't archive this document.") + } + } + + private func delete() async { + do { + try await apiClient.deleteDocument(DeleteDocumentRequest(id: viewModel.documentId)) + onDeleted() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete this document.") + } + } + + private func createChildDocument() async { + guard let collectionId = viewModel.collectionId else { + actionErrorMessage = "This document isn't in a collection." + return + } + do { + let child = try await apiClient.createDocument( + CreateDocumentRequest(title: "Untitled", text: "", collectionId: collectionId, parentDocumentId: viewModel.documentId) + ) + onOpenChild(child) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.") + } + } + + private func download() async { + do { + let markdown = try await apiClient.exportDocument(id: viewModel.documentId) + let panel = NSSavePanel() + panel.nameFieldStringValue = "\(viewModel.title.isEmpty ? "Untitled" : viewModel.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 { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(viewModel.text, forType: .string) + } + + /// 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 { + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 612, height: 792)) + textView.string = viewModel.text + textView.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + + let operation = NSPrintOperation(view: textView) + operation.printInfo.horizontalPagination = .fit + operation.printInfo.verticalPagination = .automatic + operation.run() + } } #endif diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 37a0fe5..47bc3c2 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -8,10 +8,28 @@ final class DocumentReaderViewModel { var title: String var emoji: String? var text: String + var collectionId: String? + var isFullWidth = false var children: [OutlineDocument] = [] var isLoading = false var errorMessage: String? + var isEditing = false + var isSaving = false + var saveErrorMessage: String? + + /// Recent viewers, `views.list` filtered to entries that actually have a + /// `lastViewedAt` — this is historical/aggregated view data, not live + /// "viewing right now" presence (that needs the Hocuspocus collaboration + /// socket's awareness protocol, which isn't wired up yet). + private(set) var viewers: [OutlineView] = [] + + private(set) var isPinned = false + private var pinId: String? + private(set) var isSubscribed = false + private var subscriptionId: String? + private(set) var share: OutlineShare? + let documentId: String private let apiClient: OutlineAPIClient @@ -21,6 +39,8 @@ final class DocumentReaderViewModel { self.title = document.title self.emoji = document.emoji self.text = document.text + self.collectionId = document.collectionId + self.isFullWidth = document.fullWidth ?? false } /// The list endpoint's copy of a document isn't guaranteed to be the full, @@ -35,6 +55,8 @@ final class DocumentReaderViewModel { title = full.title emoji = full.emoji text = full.text + collectionId = full.collectionId + isFullWidth = full.fullWidth ?? false } catch { errorMessage = "Couldn't load this document. Check your connection and try again." } @@ -46,4 +68,114 @@ final class DocumentReaderViewModel { limit: 100 )) ?? [] } + + func loadViewers() async { + guard let views = try? await apiClient.listViews(ListViewsRequest(documentId: documentId)) else { return } + viewers = views.filter { $0.lastViewedAt != nil } + } + + func loadPinAndSubscriptionState() async { + if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)), + let match = pins.first(where: { $0.documentId == documentId }) { + isPinned = true + pinId = match.id + } else { + isPinned = false + pinId = nil + } + + if let subscriptions = try? await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)), + let match = subscriptions.first { + isSubscribed = true + subscriptionId = match.id + } else { + isSubscribed = false + subscriptionId = nil + } + } + + func loadShare() async { + share = try? await apiClient.shareInfo(documentId: documentId) + } + + func togglePin() async throws { + if let pinId { + self.pinId = nil + isPinned = false + do { + try await apiClient.deletePin(id: pinId) + } catch { + self.pinId = pinId + isPinned = true + throw error + } + } else { + let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId)) + pinId = pin.id + isPinned = true + } + } + + func toggleSubscription() async throws { + if let subscriptionId { + self.subscriptionId = nil + isSubscribed = false + do { + try await apiClient.deleteSubscription(id: subscriptionId) + } catch { + self.subscriptionId = subscriptionId + isSubscribed = true + throw error + } + } else { + let subscription = try await apiClient.createSubscription(CreateSubscriptionRequest(documentId: documentId)) + subscriptionId = subscription.id + isSubscribed = true + } + } + + func createOrLoadShare() async throws { + share = try await apiClient.createShare(CreateShareRequest(documentId: documentId)) + } + + /// Turning editing off saves; turning it on is just a mode switch. + func toggleEditing() async { + guard isEditing else { + isEditing = true + return + } + isSaving = true + saveErrorMessage = nil + defer { isSaving = false } + do { + let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text)) + title = updated.title + text = updated.text + isEditing = false + } catch { + saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.") + } + } + + func toggleFullWidth() async throws { + let newValue = !isFullWidth + isFullWidth = newValue + do { + _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, fullWidth: newValue)) + } catch { + isFullWidth = !newValue + throw error + } + } + + /// Fire-and-forget: `insightsEnabled` isn't readable back off `Document` + /// in the vendored spec, so there's no state to reflect as a checkmark. + func enableViewerInsights() async throws { + _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: true)) + } + + /// Fire-and-forget, speculative field — see `UpdateDocumentRequest.documentEmbeds`. + func enableEmbeds() async throws { + _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, documentEmbeds: true)) + } } diff --git a/Outpost/Features/Collections/DocumentShareSheet.swift b/Outpost/Features/Collections/DocumentShareSheet.swift new file mode 100644 index 0000000..1cae350 --- /dev/null +++ b/Outpost/Features/Collections/DocumentShareSheet.swift @@ -0,0 +1,112 @@ +#if os(macOS) +import AppKit +import SwiftUI +import OutlineKit + +@MainActor +struct DocumentShareSheet: View { + @Environment(\.dismiss) private var dismiss + + let apiClient: OutlineAPIClient + let documentId: String + + @State private var share: OutlineShare? + @State private var isLoading = false + @State private var isUpdating = false + @State private var errorMessage: String? + @State private var didCopy = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Share") + .font(.headline) + Spacer() + Button("Done") { dismiss() } + } + + if isLoading { + ProgressView().frame(maxWidth: .infinity) + } else if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.red) + } else if let share { + HStack { + Text(share.url) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button { + copyLink(share.url) + } label: { + Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + } + .buttonStyle(.plain) + } + .padding(8) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) + + Toggle( + "Published — accessible without sign-in", + isOn: Binding( + get: { share.published }, + set: { newValue in Task { await setPublished(newValue) } } + ) + ) + .disabled(isUpdating) + } else { + Button("Create Share Link") { + Task { await create() } + } + } + } + .padding(20) + .frame(width: 380) + .task { await load() } + } + + private func copyLink(_ url: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(url, forType: .string) + didCopy = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + didCopy = false + } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + share = try await apiClient.shareInfo(documentId: documentId) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.") + } + } + + private func create() async { + isLoading = true + defer { isLoading = false } + do { + share = try await apiClient.createShare(CreateShareRequest(documentId: documentId)) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.") + } + } + + private func setPublished(_ published: Bool) async { + guard let share else { return } + isUpdating = true + defer { isUpdating = false } + do { + self.share = try await apiClient.updateShare(UpdateShareRequest(id: share.id, published: published)) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") + } + } +} +#endif