From fa31cd707ee05352ca624ddcbb2f2b9a9c7e200c Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 15:43:14 +0100 Subject: [PATCH 1/5] fix(collections): wire up sidebar Pin, drop dead Embeds field, real toggles Smoke-tested against a live server: Pin didn't work from the sidebar context menu, Subscribe/Unsubscribe worked, Enable Embeds didn't. - Sidebar document context menu still had the pre-pins.*-support disabled Pin/Unsubscribe stubs from before that endpoint existed - only the reader's menu got updated at the time. Wired up real Pin (collection-scoped pins.list loaded once per CollectionDocumentsOutline, not per row - avoids an N+1 call storm); left Unsubscribe disabled there since subscriptions.list is per-document, with an accurate comment pointing at the reader's menu instead. - documentEmbeds confirmed not a real field - removed from UpdateDocumentRequest entirely rather than leave a menu item that silently no-ops. - Subscribe, Viewer Insights, and Full Width are now real Toggle menu items (checkmark reflects actual state) instead of static action buttons. Viewer Insights state is inferred from whether documents.insights succeeds/fails, since insightsEnabled isn't readable back off Document - heuristic, flagged in code. --- .../Requests/UpdateDocumentRequest.swift | 10 +--- .../CollectionDocumentsOutline.swift | 52 ++++++++++++++++--- .../Collections/DocumentReaderView.swift | 47 +++++++++-------- .../Collections/DocumentReaderViewModel.swift | 33 ++++++++---- 4 files changed, 94 insertions(+), 48 deletions(-) diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift index b7a5611..5e38781 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift @@ -7,12 +7,6 @@ public struct UpdateDocumentRequest: Encodable, Sendable { 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, @@ -20,8 +14,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable { text: String? = nil, append: Bool? = nil, fullWidth: Bool? = nil, - insightsEnabled: Bool? = nil, - documentEmbeds: Bool? = nil + insightsEnabled: Bool? = nil ) { self.id = id self.title = title @@ -29,6 +22,5 @@ public struct UpdateDocumentRequest: Encodable, Sendable { self.append = append self.fullWidth = fullWidth self.insightsEnabled = insightsEnabled - self.documentEmbeds = documentEmbeds } } diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 6db03f2..12a2856 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -9,6 +9,7 @@ import OutlineKit /// client-side rather than `collections.documents`. struct CollectionDocumentsOutline: View { let apiClient: OutlineAPIClient + let collection: OutlineCollection @State private var viewModel: DocumentsViewModel let sortOption: SidebarSortOption let refreshToken: Int @@ -17,6 +18,11 @@ struct CollectionDocumentsOutline: View { /// toolbar render the real hierarchy instead of just the leaf title. let onSelectDocument: ([OutlineDocument]) -> Void + /// Loaded once per collection (`pins.list` is collection-scoped) rather + /// than per-row — a per-row `pins.list`/lookup would be an N+1 call for + /// every document in the tree. + @State private var pinsByDocumentID: [String: OutlinePin] = [:] + private var tree: [DocumentNode] { buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) } @@ -30,6 +36,7 @@ struct CollectionDocumentsOutline: View { onSelectDocument: @escaping ([OutlineDocument]) -> Void ) { self.apiClient = apiClient + self.collection = collection _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) self.sortOption = sortOption self.refreshToken = refreshToken @@ -56,13 +63,23 @@ struct CollectionDocumentsOutline: View { depth: 0, ancestors: [], selectedDocumentID: selectedDocumentID, + pinsByDocumentID: pinsByDocumentID, onSelectDocument: onSelectDocument, - onDocumentsChanged: { await viewModel.load() } + onDocumentsChanged: { await viewModel.load() }, + onPinsChanged: { await loadPins() } ) } } } - .task(id: refreshToken) { await viewModel.load() } + .task(id: refreshToken) { + await viewModel.load() + await loadPins() + } + } + + private func loadPins() async { + guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return } + pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) }) } } @@ -81,8 +98,10 @@ private struct DocumentNodeRow: View { /// Chain from root down to (not including) this node. let ancestors: [OutlineDocument] let selectedDocumentID: String? + let pinsByDocumentID: [String: OutlinePin] let onSelectDocument: ([OutlineDocument]) -> Void let onDocumentsChanged: () async -> Void + let onPinsChanged: () async -> Void @State private var isExpanded = false @@ -172,8 +191,10 @@ private struct DocumentNodeRow: View { depth: depth + 1, ancestors: ancestors + [node.document], selectedDocumentID: selectedDocumentID, + pinsByDocumentID: pinsByDocumentID, onSelectDocument: onSelectDocument, - onDocumentsChanged: onDocumentsChanged + onDocumentsChanged: onDocumentsChanged, + onPinsChanged: onPinsChanged ) } } @@ -259,7 +280,11 @@ private struct DocumentNodeRow: View { Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") { Task { await star() } } - // No `subscriptions.*` endpoint in the API — nothing to back this with. + // subscriptions.* does exist and works (confirmed against a live + // server via the reader's menu) — not shown here because + // subscriptions.list is per-document, so reflecting accurate + // per-row state for every document in the tree would mean an N+1 + // call storm. Use the reader's ⋯ menu instead. Button("Unsubscribe") {} .disabled(true) @@ -305,9 +330,9 @@ private struct DocumentNodeRow: View { Button("New Document") { Task { await createChildDocument() } } - // No `pins.*` endpoint in the API — nothing to back this with. - Button("Pin") {} - .disabled(true) + Button(pinsByDocumentID[node.document.id] != nil ? "Unpin" : "Pin") { + Task { await togglePin() } + } Divider() @@ -351,6 +376,19 @@ private struct DocumentNodeRow: View { } } + private func togglePin() async { + do { + if let pin = pinsByDocumentID[node.document.id] { + try await apiClient.deletePin(id: pin.id) + } else { + _ = try await apiClient.createPin(CreatePinRequest(documentId: node.document.id, collectionId: node.document.collectionId)) + } + await onPinsChanged() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.") + } + } + private func rename() async { do { _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText)) diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index cbd8786..fedb00d 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -133,6 +133,9 @@ struct DocumentReaderView: View { .task { await viewModel.loadPinAndSubscriptionState() } + .task { + await viewModel.loadInsightsEnabledState() + } .task { while !Task.isCancelled { await viewModel.loadViewers() @@ -249,9 +252,10 @@ struct DocumentReaderView: View { Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") { Task { await star() } } - Button(viewModel.isSubscribed ? "Unsubscribe" : "Subscribe") { - Task { await toggleSubscription() } - } + Toggle("Subscribed", isOn: Binding( + get: { viewModel.isSubscribed }, + set: { _ in Task { await toggleSubscription() } } + )) Divider() @@ -323,15 +327,20 @@ struct DocumentReaderView: View { Divider() - Button("Enable Viewer Insights") { - Task { await enableInsights() } - } - Button("Enable Embeds") { - Task { await enableEmbeds() } - } - Button(viewModel.isFullWidth ? "Default Width" : "Full Width") { - Task { await toggleFullWidth() } - } + Toggle("Viewer Insights", isOn: Binding( + get: { viewModel.isInsightsEnabled ?? false }, + set: { _ in Task { await toggleInsights() } } + )) + // Confirmed against a live server: there's no per-document embeds + // field. Only a workspace-level setting exists, and that's not + // reachable via the API either (no `team.update` endpoint in the + // vendored spec) — disabled rather than kept as a broken action. + Button("Enable Embeds") {} + .disabled(true) + Toggle("Full Width", isOn: Binding( + get: { viewModel.isFullWidth }, + set: { _ in Task { await toggleFullWidth() } } + )) Divider() @@ -372,19 +381,11 @@ struct DocumentReaderView: View { } } - private func enableInsights() async { + private func toggleInsights() async { do { - try await viewModel.enableViewerInsights() + try await viewModel.toggleViewerInsights() } 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.") + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update viewer insights.") } } diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 47bc3c2..4a482fc 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -30,6 +30,12 @@ final class DocumentReaderViewModel { private var subscriptionId: String? private(set) var share: OutlineShare? + /// `nil` until checked. Inferred from whether `documents.insights` + /// succeeds or fails — `insightsEnabled` isn't readable back off + /// `Document` in the vendored spec, so there's no direct field to read. + /// This is a heuristic, not confirmed server behavior. + private(set) var isInsightsEnabled: Bool? + let documentId: String private let apiClient: OutlineAPIClient @@ -98,6 +104,15 @@ final class DocumentReaderViewModel { share = try? await apiClient.shareInfo(documentId: documentId) } + func loadInsightsEnabledState() async { + do { + _ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId)) + isInsightsEnabled = true + } catch { + isInsightsEnabled = false + } + } + func togglePin() async throws { if let pinId { self.pinId = nil @@ -168,14 +183,14 @@ final class DocumentReaderViewModel { } } - /// 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)) + func toggleViewerInsights() async throws { + let newValue = !(isInsightsEnabled ?? false) + isInsightsEnabled = newValue + do { + _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: newValue)) + } catch { + isInsightsEnabled = !newValue + throw error + } } } From 02023cf382990758f33c92f2bf67d3d156770c94 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 16:08:44 +0100 Subject: [PATCH 2/5] fix(reader): force menu rebuild so toggle checkmarks reflect state Viewer Insights (and likely Subscribed/Full Width, same mechanism) kept showing the pre-toggle checkmark in the overflow menu until the whole view was torn down and rebuilt (navigate away and back) - SwiftUI's macOS Menu doesn't reliably re-evaluate a Toggle's checkmark against updated @Observable state on its own. Keying the Menu's .id() to every toggle-backed state it displays forces a fresh rebuild whenever any of them change. --- .../Collections/DocumentReaderView.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index fedb00d..4b6c6d5 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -127,6 +127,13 @@ struct DocumentReaderView: View { } label: { Image(systemName: "ellipsis.circle") } + // SwiftUI's macOS `Menu` doesn't reliably re-evaluate a + // `Toggle`'s checkmark against updated @Observable state on + // its own — without a fresh `.id()` per state combination, + // toggling Subscribed/Viewer Insights/Full Width kept + // showing the pre-toggle checkmark until the whole view was + // torn down and rebuilt (e.g. navigating away and back). + .id(menuIdentity) } } .task { await viewModel.loadFullContent() } @@ -205,6 +212,19 @@ struct DocumentReaderView: View { } } + /// Every toggle-backed piece of state shown as a checkmark inside + /// `menuContent` — see the `.id()` comment on the `Menu` above. + private var menuIdentity: String { + [ + starStore.isStarred(documentId: viewModel.documentId), + viewModel.isSubscribed, + viewModel.isPinned, + viewModel.isInsightsEnabled ?? false, + viewModel.isFullWidth, + viewModel.isEditing + ].map(String.init).joined(separator: "-") + } + @ViewBuilder private var viewerAvatars: some View { if !viewModel.viewers.isEmpty { From 278e93ddeb7138f3d1f4392240bf06e3369756f4 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 16:14:33 +0100 Subject: [PATCH 3/5] fix(reader): tie viewer avatars to the Viewer Insights toggle Turning off Viewer Insights didn't hide the avatar stack until the view was torn down and rebuilt - that data belongs to the insights feature, so gate it on isInsightsEnabled directly instead of only on whether any viewers loaded. --- Outpost/Features/Collections/DocumentReaderView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 4b6c6d5..03d88ea 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -227,7 +227,10 @@ struct DocumentReaderView: View { @ViewBuilder private var viewerAvatars: some View { - if !viewModel.viewers.isEmpty { + // Tied to the Viewer Insights toggle — that's the feature this data + // belongs to, so turning it off should hide the avatars immediately + // rather than leaving them showing until the view reloads. + if viewModel.isInsightsEnabled == true, !viewModel.viewers.isEmpty { HStack(spacing: -6) { ForEach(viewModel.viewers.prefix(5)) { viewer in AvatarBadge( From ad3e35e28e34d33bf2f3bda19f8abd9952401540 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 16:36:07 +0100 Subject: [PATCH 4/5] fix(pins): decode pins.list correctly, distinguish Pin to Home vs Collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same root-cause fix as feature/home-page, applied to this branch's copy of the pin code (which additionally has the sidebar's real per-collection Pin wiring): - pins.list's real response is {data: {pins: [...], documents: [...]}}, not a bare array — confirmed against a live server. Decoding straight to [OutlinePin] threw every call; try? swallowed it, so pins never showed up (even a doc pinned for real via the web app). - "Pin to Home" (web's actual label) sends collectionId: null; "Pin to Collection" sends a real id — distinct actions. The reader toolbar's Pin was sending the doc's own collectionId under a plain "Pin" label, silently doing the wrong one. Fixed to nil, relabeled "Pin to Home". - Sidebar's per-document Pin was already correctly scoped to collection.id — relabeled "Pin to Collection" for clarity, no logic change. --- .../OutlineKit/LiveOutlineAPIClient.swift | 12 +++++++- .../OutlineKit/Models/OutlinePin.swift | 12 ++++---- .../Requests/CreatePinRequest.swift | 3 +- .../OutlineKit/Requests/ListPinsRequest.swift | 2 +- .../LiveOutlineAPIClientTests.swift | 28 +++++++++++++++++++ .../CollectionDocumentsOutline.swift | 4 ++- .../Collections/DocumentReaderView.swift | 2 +- .../Collections/DocumentReaderViewModel.swift | 6 ++-- 8 files changed, 56 insertions(+), 13 deletions(-) diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 19a3725..27a47bd 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -129,7 +129,12 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { } public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { - try await post("pins.list", body: request) + // `data` here is `{ pins: [...], documents: [...] }`, not a bare + // array — confirmed against a live server. Decoding straight to + // `[OutlinePin]` throws on every call, which `try?` at call sites + // swallows silently, so pins never showed up anywhere. + let payload: PinsListPayload = try await post("pins.list", body: request) + return payload.pins } public func deletePin(id: String) async throws { @@ -286,6 +291,11 @@ private struct DuplicateDocumentResponse: Decodable { let documents: [OutlineDocument] } +private struct PinsListPayload: Decodable { + let pins: [OutlinePin] + let documents: [OutlineDocument] +} + private struct ListStarsResponse: Decodable { let stars: [OutlineStar] } diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift b/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift index 1637c40..d6a66ec 100644 --- a/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift +++ b/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift @@ -1,11 +1,11 @@ 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. +/// A document pinned to the top of a collection, or to team Home when +/// `collectionId` is `nil`. Backed by `pins.*` — not in the vendored OpenAPI +/// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but +/// confirmed real against a live server's network traffic. `collectionId: nil` +/// is what "Pin to Home" actually sends; a non-nil value is "Pin to +/// Collection", a distinct action. public struct OutlinePin: Decodable, Identifiable, Sendable { public let id: String public let documentId: String diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift index e8bc0d7..29e2701 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift @@ -1,6 +1,7 @@ import Foundation -/// See `OutlinePin` — best-effort shape, not in the vendored spec. +/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to +/// Collection" — these are distinct actions on the real server. public struct CreatePinRequest: Encodable, Sendable { public let documentId: String public let collectionId: String? diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift index 29dc61f..b6a8d5d 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift @@ -1,6 +1,6 @@ import Foundation -/// See `OutlinePin` — best-effort shape, not in the vendored spec. +/// See `OutlinePin`. `collectionId: nil` lists Home pins only. public struct ListPinsRequest: Encodable, Sendable { public let collectionId: String? diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 82c518f..759c98d 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -676,6 +676,34 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create") } + func testListPinsDecodesNestedPinsArray() async throws { + // Real shape confirmed against a live server: `data` is + // `{ pins: [...], documents: [...] }`, not a bare array. + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "pagination": { "limit": 25, "offset": 0 }, + "data": { + "pins": [ + { "id": "pin-1", "documentId": "doc-1", "collectionId": null, "index": "h" } + ], + "documents": [] + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let pins = try await client.listPins(ListPinsRequest(collectionId: nil)) + + XCTAssertEqual(pins.first?.id, "pin-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.list") + } + func testCreateSubscriptionDecodesSubscription() async throws { let httpClient = MockHTTPClient() httpClient.responseData = """ diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 12a2856..1c40219 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -330,7 +330,9 @@ private struct DocumentNodeRow: View { Button("New Document") { Task { await createChildDocument() } } - Button(pinsByDocumentID[node.document.id] != nil ? "Unpin" : "Pin") { + // Scoped to this collection (`collection.id`) — "Pin to Collection", + // distinct from the reader toolbar's "Pin to Home" (collectionId: nil). + Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") { Task { await togglePin() } } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 03d88ea..94db606 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -317,7 +317,7 @@ struct DocumentReaderView: View { Button("New Document") { Task { await createChildDocument() } } - Button(viewModel.isPinned ? "Unpin" : "Pin") { + Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") { Task { await togglePin() } } diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 4a482fc..3005440 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -81,7 +81,9 @@ final class DocumentReaderViewModel { } func loadPinAndSubscriptionState() async { - if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)), + // `collectionId: nil` = Home pins. This menu's Pin action is "Pin to + // Home", not "Pin to Collection" — those are distinct on the server. + if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)), let match = pins.first(where: { $0.documentId == documentId }) { isPinned = true pinId = match.id @@ -125,7 +127,7 @@ final class DocumentReaderViewModel { throw error } } else { - let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId)) + let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: nil)) pinId = pin.id isPinned = true } From ed9fd26c854076d806caccb68886e960726d9bba Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 16:50:09 +0100 Subject: [PATCH 5/5] fix(sidebar): auto-refresh after creating a doc from the reader toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader's inline "New Document" button (createChildDocument()) only navigated to the new document — no handle on the sidebar row it landed under, so the tree stayed stale until the periodic remote-changes poll surfaced the reload banner. Right-click "New Document" on a collection already refreshed correctly (bumps its own documentsRefreshToken) and is untouched. Threads a documentsChangedToken from ContentView_macOS down through CollectionsTreeView -> CollectionTreeRow -> CollectionDocumentsOutline as externalRefreshToken; every expanded row reloads itself when it bumps, since the reader doesn't know which row (if any) corresponds to where the new document landed. --- .../Collections/CollectionDocumentsOutline.swift | 13 ++++++++++++- .../Features/Collections/CollectionTreeRow.swift | 3 +++ .../Features/Collections/CollectionsTreeView.swift | 7 +++++++ .../Features/Collections/ContentView_macOS.swift | 9 ++++++++- .../Features/Collections/DocumentReaderView.swift | 10 +++++++++- 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 1c40219..a93b840 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -13,6 +13,12 @@ struct CollectionDocumentsOutline: View { @State private var viewModel: DocumentsViewModel let sortOption: SidebarSortOption let refreshToken: Int + /// Bumped from `ContentView_macOS` whenever a document is created from + /// somewhere that has no direct handle on this row — the reader's + /// toolbar "New Document" button, specifically, which doesn't know which + /// (if any) sidebar row corresponds to the collection its new document + /// landed in, so every expanded row just reloads itself. + let externalRefreshToken: Int let selectedDocumentID: String? /// Full chain from root to the clicked document (inclusive) — lets the /// toolbar render the real hierarchy instead of just the leaf title. @@ -32,6 +38,7 @@ struct CollectionDocumentsOutline: View { collection: OutlineCollection, sortOption: SidebarSortOption, refreshToken: Int, + externalRefreshToken: Int, selectedDocumentID: String?, onSelectDocument: @escaping ([OutlineDocument]) -> Void ) { @@ -40,6 +47,7 @@ struct CollectionDocumentsOutline: View { _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) self.sortOption = sortOption self.refreshToken = refreshToken + self.externalRefreshToken = externalRefreshToken self.selectedDocumentID = selectedDocumentID self.onSelectDocument = onSelectDocument } @@ -71,7 +79,10 @@ struct CollectionDocumentsOutline: View { } } } - .task(id: refreshToken) { + // Combined into one identity rather than two separate `.task(id:)` + // modifiers — each of those fires once unconditionally on first + // appear, so two of them would double the initial load. + .task(id: "\(refreshToken)-\(externalRefreshToken)") { await viewModel.load() await loadPins() } diff --git a/Outpost/Features/Collections/CollectionTreeRow.swift b/Outpost/Features/Collections/CollectionTreeRow.swift index 1bd159e..203e88e 100644 --- a/Outpost/Features/Collections/CollectionTreeRow.swift +++ b/Outpost/Features/Collections/CollectionTreeRow.swift @@ -11,6 +11,8 @@ struct CollectionTreeRow: View { let isExpanded: Bool let isSelected: Bool let selectedDocumentID: String? + /// See the identical parameter on `CollectionDocumentsOutline`. + let externalRefreshToken: Int let onToggle: () -> Void let onSelectDocument: ([OutlineDocument]) -> Void let onSearchInCollection: (OutlineCollection) -> Void @@ -62,6 +64,7 @@ struct CollectionTreeRow: View { collection: collection, sortOption: sortOption, refreshToken: documentsRefreshToken, + externalRefreshToken: externalRefreshToken, selectedDocumentID: selectedDocumentID, onSelectDocument: onSelectDocument ) diff --git a/Outpost/Features/Collections/CollectionsTreeView.swift b/Outpost/Features/Collections/CollectionsTreeView.swift index fe6bf27..71a6dd9 100644 --- a/Outpost/Features/Collections/CollectionsTreeView.swift +++ b/Outpost/Features/Collections/CollectionsTreeView.swift @@ -7,6 +7,10 @@ struct CollectionsTreeView: View { @Binding private var selectedCollection: OutlineCollection? let selectedDocumentID: String? @State private var expandedCollectionIDs: Set = [] + /// Bumped from `ContentView_macOS` whenever a document is created + /// somewhere with no direct handle on the sidebar row it belongs + /// under — see the identical parameter on `CollectionDocumentsOutline`. + let externalRefreshToken: Int let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void let onSearchInCollection: (OutlineCollection) -> Void @@ -14,12 +18,14 @@ struct CollectionsTreeView: View { apiClient: OutlineAPIClient, selectedCollection: Binding, selectedDocumentID: String?, + externalRefreshToken: Int, onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void, onSearchInCollection: @escaping (OutlineCollection) -> Void ) { _viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient)) _selectedCollection = selectedCollection self.selectedDocumentID = selectedDocumentID + self.externalRefreshToken = externalRefreshToken self.onSelectDocument = onSelectDocument self.onSearchInCollection = onSearchInCollection } @@ -81,6 +87,7 @@ struct CollectionsTreeView: View { isExpanded: expandedCollectionIDs.contains(collection.id), isSelected: selectedCollection?.id == collection.id, selectedDocumentID: selectedDocumentID, + externalRefreshToken: externalRefreshToken, onToggle: { toggle(collection) }, onSelectDocument: { chain in onSelectDocument(collection, chain) }, onSearchInCollection: onSearchInCollection, diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 4d650f8..6410fee 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -12,6 +12,11 @@ struct ContentView_macOS: View { @State private var contextualSearchQuery = "" @State private var isContextualSearchExpanded = false @FocusState private var isContextualSearchFocused: Bool + /// Bumped whenever a document is created from somewhere with no direct + /// handle on the sidebar row it belongs under (the reader toolbar's + /// "New Document" button) — every expanded sidebar row reloads itself + /// in response. See `CollectionDocumentsOutline.externalRefreshToken`. + @State private var documentsChangedToken = 0 private var trimmedGlobalQuery: String { globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) @@ -153,6 +158,7 @@ struct ContentView_macOS: View { apiClient: apiClient, selectedCollection: $selectedCollection, selectedDocumentID: documentPath.last?.id, + externalRefreshToken: documentsChangedToken, onSelectDocument: selectDocumentChain, onSearchInCollection: searchInCollection ) @@ -229,7 +235,8 @@ struct ContentView_macOS: View { if !documentPath.isEmpty { documentPath.removeLast() } - } + }, + onDocumentCreated: { documentsChangedToken += 1 } ) } } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 94db606..f5f80ff 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -23,6 +23,11 @@ struct DocumentReaderView: View { /// longer visible in the collection it was opened from, so the reader /// pops itself off the navigation stack. let onDeleted: () -> Void + /// The reader's own "New Document" toolbar button has no direct handle + /// on the sidebar row it belongs under — this tells the sidebar a + /// document exists now so it can pick it up. See + /// `CollectionDocumentsOutline.externalRefreshToken`. + let onDocumentCreated: () -> Void @State private var isShowingUnpublishConfirmation = false @State private var isShowingArchiveConfirmation = false @@ -39,13 +44,15 @@ struct DocumentReaderView: View { apiClient: OutlineAPIClient, document: OutlineDocument, onOpenChild: @escaping (OutlineDocument) -> Void, - onDeleted: @escaping () -> Void + onDeleted: @escaping () -> Void, + onDocumentCreated: @escaping () -> Void ) { self.apiClient = apiClient self.document = document _viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document)) self.onOpenChild = onOpenChild self.onDeleted = onDeleted + self.onDocumentCreated = onDocumentCreated } var body: some View { @@ -464,6 +471,7 @@ struct DocumentReaderView: View { let child = try await apiClient.createDocument( CreateDocumentRequest(title: "Untitled", text: "", collectionId: collectionId, parentDocumentId: viewModel.documentId) ) + onDocumentCreated() onOpenChild(child) } catch { actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")