Copy Link — a card context-menu row that puts the card folder on the pasteboard

Card 737a949f: "Add an option to card context menu to copy a link to
the card folder." Implements the design ruling verbatim.

- New context-menu row "Copy Link" (CardFaceView.boardMenu, board
  side only — trash cards are excluded, matching "sole selected live
  card"). Writes the clicked card's folder in one pasteboard item
  carrying two representations: the file:// URL under .fileURL and
  the plain absolute path under .string (FolderLinkPasteboard.swift).
  Enabled on a sole selected live card; disabled on a multi-selection
  and wherever edit-shaped actions already disable, per the ruling.
  Also exposed as a VoiceOver custom action alongside its siblings.
- Menu-bar twin: Board ▸ Copy Link (BoardCommands.swift,
  CopyLinkCommand), no default chord — the every-function-a-menu-item
  contract in DESIGN/11-command-nexus.md is still current, so this is
  the twin that contract calls for, homed the way Open Card/Rename/
  Style… already are.
- DESIGN/11-command-nexus.md: new Board-menu row and an updated Card
  context-menu row.
- Tests (KanbanTests/CopyLinkTests.swift): the target predicate's
  enablement (sole card / multi-selection / lane / trash / empty /
  inline-editing), the pasteboard write's exact bytes via a fake
  pasteboard (both representations, exact folder URL), and a
  disabled-target no-op.

Caught and fixed during self-review: an early version read the
context menu's widened-selection helper (targetIDs) inside the
Copy Link row's .disabled(...), which reads store.selection. Since
.contextMenu's content closure is evaluated on every ordinary body
pass (not only when the menu opens), that resubscribed every card
face on the board to every selection change — the exact O(board)
regression RENDER-INSTRUMENTATION.md's isSelected/selectedCount
split exists to prevent, caught by BoardRenderPerformanceTests and
MarqueeRenderCostTests. Fixed by reading the already-hoisted,
non-Observable `selectedCount` parameter instead, which answers the
same "how many ride along" question at zero extra subscription cost.

xcodegen generate, the Kanban scheme build, and the full KanbanTests
suite (2816 tests) are clean.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 23:28:05 -04:00
parent bab456c08d
commit b09c4bd5c0
6 changed files with 343 additions and 6 deletions
+51 -2
View File
@@ -27,6 +27,11 @@ import SwiftUI
/// so the file-hover highlight is board-only; and the trash's context-menu Delete is *permanent*, so
/// it needs the window's confirmation host (11-command-nexus.md Context menus' Trash cards row).
///
/// **A fourth board-only row joined 2026-08-09**: Copy Link (design ruling, card 737a949f) not one
/// of the three edit-shaped absences above (it changes nothing on disk), but "sole selected **live**
/// card" is its own words for the same board-only scope, so it lives in `boardMenu` only and has no
/// trash-side counterpart, absence or otherwise.
///
/// **Only the trash side carries the confirmation host** (settled): the board side's Delete is the
/// ordinary staged move into `.trash/` and never stands an alert, so `board` needs nothing beyond the
/// card opener.
@@ -596,8 +601,8 @@ struct CardFaceView: View, Equatable {
// MARK: - Context menus
/// Open, Rename, Style, the quick-style recents row, Delete 11-command-nexus.md Context
/// menus' Card row, in its order.
/// Open, Copy Link, Rename, Style, the quick-style recents row, Delete 11-command-nexus.md
/// Context menus' Card row, in its order.
@ViewBuilder
private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
// Open: Board Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
@@ -611,6 +616,13 @@ struct CardFaceView: View, Equatable {
openCard(card.id)
}
// Copy Link design ruling 2026-08-09, card 737a949f: "writes the card FOLDER's file:// URL
// to the general pasteboard enabled on a sole selected live card only; disabled on
// multi-selections (a link is singular)". Grouped beside Open, both read-only rows, ahead of
// the edit-shaped block below (Board Copy Link's own doc comment, `CopyLinkCommand`).
Button("Copy Link") { copyLink() }
.disabled(!copyLinkEnabled)
Divider()
// Rename: Board Rename's exact store path (`BoardRenameCommand`) `beginRename(of:
@@ -641,6 +653,8 @@ struct CardFaceView: View, Equatable {
@ViewBuilder
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
Button("Open") { openCard(card.id) }
Button("Copy Link") { copyLink() }
.disabled(!copyLinkEnabled)
Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations)
Button("Delete") { deleteTargets() }
@@ -698,6 +712,41 @@ struct CardFaceView: View, Equatable {
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
}
/// Copy Link's context-menu enablement **`selectedCount`, never `targetIDs`**. `targetIDs`
/// reads `store.selection` directly, and `.contextMenu`'s content closure is not lazy: SwiftUI
/// evaluates `boardMenu` (and therefore any `.disabled(...)` inside it) on every ordinary body
/// pass, not only when the menu opens, exactly as building this row against `targetIDs` first
/// proved the hard way every face's body re-ran on every selection change, the precise O(board)
/// regression `isSelected`/`selectedCount` exist to prevent (this struct's own top-of-file note;
/// `BoardRenderPerformanceTests.selectionStillRepaints`, which caught it).
///
/// `selectedCount` is the render-safe answer to the same question: the parent (`LaneView`) already
/// computes "the size of the selection this face belongs to, else 1" as a **plain, non-Observable
/// parameter** `targetIDs.count`'s exact widening, paid for once per lane instead of once per
/// card-menu-construction. "A link is singular" (design ruling 2026-08-09, card 737a949f), so a
/// count above 1 disables rather than guessing which member was meant the same reading Style
/// and Delete's `targetIDs` give their own action, just checked here instead of only inside it,
/// because unlike them this row's *enabled state itself* has to say so.
private var copyLinkEnabled: Bool {
store.acceptsBoardMutations && selectedCount == 1
}
/// Copy Link's write `copyLinkEnabled`'s one caller. Resolves **this card's own folder**
/// directly rather than through `targetIDs`: `copyLinkEnabled == true` already guarantees the
/// widened target is `card.id` alone (either this card sits outside the live selection, or it is
/// the selection's sole member), so there is no widened set left to read `store.selection` for.
/// `store.copyLinkTarget` is deliberately not reused either that property reads the *live*
/// selection for the menu-bar row (`CopyLinkCommand`), which would answer wrongly for a card
/// clicked outside the current selection, exactly the case `TrashWriteTests`'
/// `contextMenuDeleteIgnoresTheSelection` pins for Delete.
private func copyLink() {
guard copyLinkEnabled,
let folder = ItemPath.resolve([card.id], in: role.container, snapshot: store.snapshot)
.first?.folder(under: store.rootURL)
else { return }
SystemFolderLinkPasteboard().write(link: folder)
}
/// VO-Space's landing: the -click funnel, on this card, **in this face's container** so a
/// trash card's toggle can no more mix with a board selection than a -click could.
private func toggleSelection() {