fix(collections): document context menu targeting + off-main AppKit calls

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.
This commit is contained in:
2026-08-14 02:23:42 +01:00
parent 52dacb26b3
commit f6852897b9
7 changed files with 35 additions and 13 deletions
@@ -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 { private struct DocumentNodeRow: View {
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
let node: DocumentNode let node: DocumentNode
@@ -65,7 +65,15 @@ struct CollectionsTreeView: View {
description: Text("Collections you have access to will appear here.") description: Text("Collections you have access to will appear here.")
) )
} else { } else {
List { // 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 ForEach(viewModel.collections) { collection in
CollectionTreeRow( CollectionTreeRow(
apiClient: viewModel.apiClient, apiClient: viewModel.apiClient,
@@ -80,6 +88,9 @@ struct CollectionsTreeView: View {
) )
} }
} }
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
} }
} }
.task { .task {
@@ -5,6 +5,7 @@ import OutlineKit
/// Read-only revision list, backed by `revisions.list`. No diff/restore view /// Read-only revision list, backed by `revisions.list`. No diff/restore view
/// `revisions.list` omits body content for performance, and restoring is a /// `revisions.list` omits body content for performance, and restoring is a
/// bigger follow-up feature, not a one-off action. /// bigger follow-up feature, not a one-off action.
@MainActor
struct DocumentHistorySheet: View { struct DocumentHistorySheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -4,6 +4,7 @@ import OutlineKit
/// Backed by `documents.insights` server returns an error if insights /// Backed by `documents.insights` server returns an error if insights
/// aren't enabled on the document, surfaced like any other load failure. /// aren't enabled on the document, surfaced like any other load failure.
@MainActor
struct DocumentInsightsSheet: View { struct DocumentInsightsSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -6,6 +6,7 @@ import OutlineKit
/// Distraction-free reading view no toolbar/sidebar chrome, larger type. /// Distraction-free reading view no toolbar/sidebar chrome, larger type.
/// Presentation is just a bigger render of the same markdown, not a real /// 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). /// slide-by-slide deck (Outline's own "Present" isn't slide-based either).
@MainActor
struct DocumentPresentSheet: View { struct DocumentPresentSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -8,6 +8,7 @@ import OutlineKit
/// text instead of the styled reader view the tradeoff is losing rich /// text instead of the styled reader view the tradeoff is losing rich
/// rendering in exchange for real match highlighting and scroll-to-match via /// rendering in exchange for real match highlighting and scroll-to-match via
/// `ScrollViewReader`, which wouldn't be possible against an opaque view. /// `ScrollViewReader`, which wouldn't be possible against an opaque view.
@MainActor
struct DocumentSearchSheet: View { struct DocumentSearchSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -7,6 +7,7 @@ import OutlineKit
/// picking a destination deeper than one level (i.e. can't move under a /// picking a destination deeper than one level (i.e. can't move under a
/// grandchild) kept intentionally shallow rather than mirroring the full /// grandchild) kept intentionally shallow rather than mirroring the full
/// sidebar tree in a picker. /// sidebar tree in a picker.
@MainActor
struct MoveDocumentSheet: View { struct MoveDocumentSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss