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
+54
View File
@@ -425,6 +425,60 @@ struct BoardInfoCommand: View {
}
}
// MARK: - Copy Link
extension BoardStore {
/// Board Copy Link's target: the sole selected **live board card**'s folder URL, or `nil` "a
/// link is singular" (design ruling 2026-08-09, card 737a949f "Add an option to card context menu
/// to copy a link to the card folder"), the same board-card-only shape `openCardTarget` answers
/// for its own reason: a lane, a multi-selection and a trash selection all disable it.
///
/// **Additionally gated on `acceptsBoardMutations`**, unlike `openCardTarget` the ruling asks
/// for this in as many words ("disabled wherever edit-shaped actions already disable"), even
/// though writing a link to the pasteboard changes nothing on disk. Worth flagging rather than
/// silently matching: Copy Link could have stayed live under the lock the way Reveal in Finder
/// does ("not edit-shaped inspecting a folder before a purge is exactly the errand it exists
/// for" `CardFaceView.trashMenu`'s doc), but the ruling states the gate explicitly, so it is
/// implemented as written rather than re-litigated here (the card's DECISIONS comment flags it).
var copyLinkTarget: URL? {
guard acceptsBoardMutations else { return nil }
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
Self.boardItem(id, in: snapshot)?.cardID != nil
else { return nil }
return ItemPath.resolve([id], in: .board, snapshot: snapshot).first?.folder(under: rootURL)
}
/// Board Copy Link's action, and the menu-bar row's one write. The context menu's own Copy Link
/// row (`CardFaceView.copyLink`) does not call this: a context menu names its target by where it
/// was invoked (`targetIDs`'s standing rule, shared with Style and Delete), not by the live
/// selection this property reads.
func copyCardLink(to pasteboard: FolderLinkPasteboard = SystemFolderLinkPasteboard()) {
guard let folder = copyLinkTarget else { return }
pasteboard.write(link: folder)
}
}
/// Board Copy Link no default chord (11-command-nexus.md; design ruling 2026-08-09, card
/// 737a949f). The menu-bar twin the ruling asks for ("follow the codebase's CURRENT command
/// conventions if the every-function-a-menu-item contract still governs, add the item in the
/// matching menu"): 11-command-nexus.md's contract is still in force, so this exists beside the card
/// context menu's own row (`CardFaceView.boardMenu`) rather than instead of it.
///
/// One answer (`copyLinkTarget`) for both the row's `disabled` state and its action, `BoardRenameCommand`'s
/// shape.
struct CopyLinkCommand: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Copy Link") {
store?.copyCardLink()
}
.disabled(store?.copyLinkTarget == nil)
}
}
// MARK: - Rename
/// Board Rename no default chord, deliberately (11-command-nexus.md: " (cards: Return in