A card's context menu finds its four groups — style, clipboard, navigation, and the trash
CardFaceView.boardMenu/boardActions restructured to the owner's shape (card
fe66c461): Open/Copy Link/Rename/Style▸(Symbol,Color), a divider, then
Copy/Cut/Paste/Paste Special▸(Paste Image into Card), a divider, then
Navigation▸(Move Left,Move Right), a divider, then Send to Trash. Every row
routes through existing machinery — no new commands.
ClipboardStore gains copy(from:targeting:)/cut(from:targeting:) and their
canCopy/canCut twins, so Copy and Cut can widen to the clicked card exactly
as Delete and Style already do ("right-clicking something outside the
selection acts on what was clicked"), without disturbing the Edit-menu path.
LaneMoveTarget.destination is extracted out of MoveLaneCommands so the
card menu's Navigation rows validate against the identical sole-live-lane
predicate as Board ▸ Move Left/Right. Since a card id can never itself
satisfy that predicate, the two rows are wired to the real store call but
unconditionally disabled — reading the live selection per card face would
reproduce the O(board) render regression isSelected/selectedCount exist to
prevent (contextMenu's builder is not lazy).
Style ▸ Symbol and ▸ Color both open the one existing style popover — no
per-section pre-focus (StyleEditorSession has no such concept, and
StyleEditorView internals are out of scope while another pass redesigns
the pickers). Paste and Paste Image into Card reduce their .disabled
checks to selection/snapshot-free forms, proven safe by construction (a
rendered card face already guarantees a live lane / a live board card).
Journaled on the card: Copy Link kept (shipped same day, not in the
owner's list), "Delete" relabeled "Send to Trash" (board-side move, not
the permanent trash delete), quick-style recents row dropped from this
menu, Navigation's always-disabled rows, and Paste not retargeting to the
clicked card — all flagged needs owner review. DESIGN/11-command-nexus.md's
Card row is owed a rewrite, left for the main session.
Tests: LaneMoveTarget.destination (new), targeted copy/cut (new), plus
existing ClipboardStore/PasteTarget/MoveLane/CopyLink/Trash-menu/PasteImage/
PasteFile/Style/CaretChord/render-performance/equatable-gate suites —
156 tests, all passing.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -671,6 +671,83 @@ struct ClipboardCutTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Targeted copy/cut (the card context menu's own reading)
|
||||
|
||||
/// `copy(from:targeting:)`/`cut(from:targeting:)` — the card context menu's Copy/Cut
|
||||
/// (`CardFaceView`, 2026-08-09 ▸ "redesign context menu for cards"), Delete's widening rule
|
||||
/// (`targetIDs`) extended to the clipboard for the first time. The interesting claim these pin: the
|
||||
/// explicit *target* wins over whatever the store's live selection happens to be — the
|
||||
/// `TrashWriteTests.contextMenuDeleteIgnoresTheSelection` precedent, one type over.
|
||||
@MainActor
|
||||
@Suite("ClipboardStore ▸ targeted copy/cut")
|
||||
struct ClipboardTargetedCopyCutTests {
|
||||
|
||||
@Test("A targeted copy stages and writes the target, ignoring an unrelated live selection")
|
||||
func targetedCopyIgnoresTheSelection() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
// The live selection names a card the target never mentions.
|
||||
harness.store.select([clipboardCard4], in: .board)
|
||||
let target = ItemReferenceSet(ids: [clipboardCard1], container: .board)
|
||||
|
||||
harness.clipboard.copy(from: harness.store, targeting: target)
|
||||
await harness.clipboard.stagingSettled()
|
||||
|
||||
let manifest = try #require(harness.clipboard.payload)
|
||||
#expect(manifest.entries.map(\.id) == [Ident.card1])
|
||||
}
|
||||
|
||||
@Test("A targeted cut arms the target, not the live selection")
|
||||
func targetedCutArmsTheTarget() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.select([clipboardCard4], in: .board)
|
||||
let target = ItemReferenceSet(ids: [clipboardCard1], container: .board)
|
||||
|
||||
harness.clipboard.cut(from: harness.store, targeting: target)
|
||||
|
||||
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1])
|
||||
#expect(harness.store.transient.pendingCut.container == .board)
|
||||
}
|
||||
|
||||
@Test("A targeted copy still supports multiple members, in flatten order")
|
||||
func targetedCopySupportsMultipleMembers() async throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
harness.store.select([clipboardCard4], in: .board)
|
||||
let target = ItemReferenceSet(ids: [clipboardCard1, clipboardCard2], container: .board)
|
||||
|
||||
harness.clipboard.copy(from: harness.store, targeting: target)
|
||||
|
||||
let manifest = try #require(harness.clipboard.payload)
|
||||
#expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2])
|
||||
}
|
||||
|
||||
@Test("canCopy/canCut targeting answer for the target, not the live selection")
|
||||
func canCopyCanCutReadTheTarget() throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
// Nothing is live-selected at all, yet a target still enables both.
|
||||
let target = ItemReferenceSet(ids: [clipboardCard1], container: .board)
|
||||
#expect(harness.clipboard.canCopy(from: harness.store, targeting: target))
|
||||
#expect(harness.clipboard.canCut(from: harness.store, targeting: target))
|
||||
}
|
||||
|
||||
@Test("An open inline editor closes the targeted forms too — the focused-editor rule")
|
||||
func focusedEditorClosesTargetedForms() throws {
|
||||
let harness = try makeClipboardHarness()
|
||||
defer { harness.tearDown() }
|
||||
let target = ItemReferenceSet(ids: [clipboardCard1], container: .board)
|
||||
|
||||
harness.store.transient.beginPlaceholder(inLane: clipboardLane1)
|
||||
#expect(!harness.clipboard.canCopy(from: harness.store, targeting: target))
|
||||
#expect(!harness.clipboard.canCut(from: harness.store, targeting: target))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Availability
|
||||
|
||||
@MainActor
|
||||
|
||||
Reference in New Issue
Block a user