From f6852897b9439a4ec3d86b6426a229fb1d7c92ed Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 02:23:42 +0100 Subject: [PATCH] fix(collections): document context menu targeting + off-main AppKit calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar document rows lived inside the collection's single List row — on macOS, a List row's own context menu wins over any nested .contextMenu deeper in that row's content, so right-clicking a document always showed the collection's menu. Swapped List for a plain ScrollView/LazyVStack (every row already does its own selection highlighting, so List wasn't buying anything here). Also pins the new document-action views to @MainActor: Download/Print call NSSavePanel/NSPrintOperation/NSPasteboard after an await, and without a fixed actor those functions could resume on a background executor — off-main AppKit calls, which is what was producing the ViewBridge/nw_connection console spam. --- .../CollectionDocumentsOutline.swift | 6 +++ .../Collections/CollectionsTreeView.swift | 37 ++++++++++++------- .../Collections/DocumentHistorySheet.swift | 1 + .../Collections/DocumentInsightsSheet.swift | 1 + .../Collections/DocumentPresentSheet.swift | 1 + .../Collections/DocumentSearchSheet.swift | 1 + .../Collections/MoveDocumentSheet.swift | 1 + 7 files changed, 35 insertions(+), 13 deletions(-) diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 136a02a..4854976 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -66,6 +66,12 @@ struct CollectionDocumentsOutline: View { } } +/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions +/// below must run on the main thread — without pinning this to `@MainActor`, +/// a `Task` launched from a menu action can resume on a background executor +/// after its first `await`, and calling those APIs off-main is what produced +/// the ViewBridge/`nw_connection` console spam (and worse, silent failures). +@MainActor private struct DocumentNodeRow: View { let apiClient: OutlineAPIClient let node: DocumentNode diff --git a/Outpost/Features/Collections/CollectionsTreeView.swift b/Outpost/Features/Collections/CollectionsTreeView.swift index afc4455..fe6bf27 100644 --- a/Outpost/Features/Collections/CollectionsTreeView.swift +++ b/Outpost/Features/Collections/CollectionsTreeView.swift @@ -65,20 +65,31 @@ struct CollectionsTreeView: View { description: Text("Collections you have access to will appear here.") ) } else { - List { - ForEach(viewModel.collections) { collection in - CollectionTreeRow( - apiClient: viewModel.apiClient, - collection: collection, - isExpanded: expandedCollectionIDs.contains(collection.id), - isSelected: selectedCollection?.id == collection.id, - selectedDocumentID: selectedDocumentID, - onToggle: { toggle(collection) }, - onSelectDocument: { chain in onSelectDocument(collection, chain) }, - onSearchInCollection: onSearchInCollection, - onCollectionsChanged: { await viewModel.load() } - ) + // Plain `ScrollView`/`LazyVStack`, not `List`: on macOS, a + // `List` row's context menu wins over any `.contextMenu` + // nested deeper inside that row's content (right-clicking a + // document row inside an expanded `CollectionTreeRow` showed + // the collection's menu instead of the document's) — every + // row here already does its own selection highlighting, so + // `List` wasn't buying anything but that bug. + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(viewModel.collections) { collection in + CollectionTreeRow( + apiClient: viewModel.apiClient, + collection: collection, + isExpanded: expandedCollectionIDs.contains(collection.id), + isSelected: selectedCollection?.id == collection.id, + selectedDocumentID: selectedDocumentID, + onToggle: { toggle(collection) }, + onSelectDocument: { chain in onSelectDocument(collection, chain) }, + onSearchInCollection: onSearchInCollection, + onCollectionsChanged: { await viewModel.load() } + ) + } } + .padding(.horizontal, 8) + .padding(.vertical, 4) } } } diff --git a/Outpost/Features/Collections/DocumentHistorySheet.swift b/Outpost/Features/Collections/DocumentHistorySheet.swift index ef2d812..1e370eb 100644 --- a/Outpost/Features/Collections/DocumentHistorySheet.swift +++ b/Outpost/Features/Collections/DocumentHistorySheet.swift @@ -5,6 +5,7 @@ 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. +@MainActor struct DocumentHistorySheet: View { @Environment(\.dismiss) private var dismiss diff --git a/Outpost/Features/Collections/DocumentInsightsSheet.swift b/Outpost/Features/Collections/DocumentInsightsSheet.swift index a6ef91b..e401596 100644 --- a/Outpost/Features/Collections/DocumentInsightsSheet.swift +++ b/Outpost/Features/Collections/DocumentInsightsSheet.swift @@ -4,6 +4,7 @@ import OutlineKit /// Backed by `documents.insights` — server returns an error if insights /// aren't enabled on the document, surfaced like any other load failure. +@MainActor struct DocumentInsightsSheet: View { @Environment(\.dismiss) private var dismiss diff --git a/Outpost/Features/Collections/DocumentPresentSheet.swift b/Outpost/Features/Collections/DocumentPresentSheet.swift index de64d36..5c1fc5a 100644 --- a/Outpost/Features/Collections/DocumentPresentSheet.swift +++ b/Outpost/Features/Collections/DocumentPresentSheet.swift @@ -6,6 +6,7 @@ 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). +@MainActor struct DocumentPresentSheet: View { @Environment(\.dismiss) private var dismiss diff --git a/Outpost/Features/Collections/DocumentSearchSheet.swift b/Outpost/Features/Collections/DocumentSearchSheet.swift index 7dd3b52..849e8cc 100644 --- a/Outpost/Features/Collections/DocumentSearchSheet.swift +++ b/Outpost/Features/Collections/DocumentSearchSheet.swift @@ -8,6 +8,7 @@ import OutlineKit /// 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. +@MainActor struct DocumentSearchSheet: View { @Environment(\.dismiss) private var dismiss diff --git a/Outpost/Features/Collections/MoveDocumentSheet.swift b/Outpost/Features/Collections/MoveDocumentSheet.swift index e4bbbf8..996701f 100644 --- a/Outpost/Features/Collections/MoveDocumentSheet.swift +++ b/Outpost/Features/Collections/MoveDocumentSheet.swift @@ -7,6 +7,7 @@ import OutlineKit /// 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. +@MainActor struct MoveDocumentSheet: View { @Environment(\.dismiss) private var dismiss