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
|
||||
|
||||
@@ -1027,3 +1027,85 @@ struct MoveLaneConventionTests {
|
||||
#expect(try load(fixture).lanes.map(\.id) == [lane2, lane1, lane3])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LaneMoveTarget's destination predicate
|
||||
|
||||
/// `LaneMoveTarget.destination` — the pure predicate behind Board ▸ Move Left/Move Right
|
||||
/// (`MoveLaneCommands`) and, since 2026-08-09 ▸ "redesign context menu for cards", the card context
|
||||
/// menu's Navigation submenu (`CardFaceView`). Pinned directly rather than only through
|
||||
/// `MoveLaneCommands`'s own view, which the suite above already exercises via `store.moveLane`'s
|
||||
/// convention but never through this gate — the predicate itself was previously untested in
|
||||
/// isolation.
|
||||
@MainActor
|
||||
@Suite("LaneMoveTarget ▸ destination")
|
||||
struct LaneMoveTargetTests {
|
||||
|
||||
@Test("A sole selected mid-board lane answers both directions")
|
||||
func midBoardLaneAnswersBothWays() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let selection = ItemReferenceSet(ids: [lane2], container: .board)
|
||||
|
||||
let left = LaneMoveTarget.destination(selection: selection, snapshot: store.snapshot, delta: -1)
|
||||
#expect(left?.lane == lane2)
|
||||
#expect(left?.index == 0)
|
||||
|
||||
let right = LaneMoveTarget.destination(selection: selection, snapshot: store.snapshot, delta: 1)
|
||||
#expect(right?.lane == lane2)
|
||||
#expect(right?.index == 2)
|
||||
}
|
||||
|
||||
@Test("The left wall refuses left and the right wall refuses right")
|
||||
func wallsRefuseTheirOwnDirection() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
let atLeftWall = ItemReferenceSet(ids: [lane1], container: .board)
|
||||
#expect(LaneMoveTarget.destination(selection: atLeftWall, snapshot: store.snapshot, delta: -1) == nil)
|
||||
#expect(LaneMoveTarget.destination(selection: atLeftWall, snapshot: store.snapshot, delta: 1) != nil)
|
||||
|
||||
let atRightWall = ItemReferenceSet(ids: [lane3], container: .board)
|
||||
#expect(LaneMoveTarget.destination(selection: atRightWall, snapshot: store.snapshot, delta: 1) == nil)
|
||||
#expect(LaneMoveTarget.destination(selection: atRightWall, snapshot: store.snapshot, delta: -1) != nil)
|
||||
}
|
||||
|
||||
/// "A card id is in no lane order, so this is also the 'not a lane' test" — the exact clause the
|
||||
/// card context menu's Navigation rows lean on to justify their unconditional `.disabled(true)`
|
||||
/// (`CardFaceView.boardMenu`'s own doc comment): a card selection can never itself make this
|
||||
/// predicate answer `true`, whatever card it names.
|
||||
@Test("A card selection answers nil in both directions — a card id is in no lane order")
|
||||
func cardSelectionAnswersNil() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let selection = ItemReferenceSet(ids: [card1], container: .board)
|
||||
|
||||
#expect(LaneMoveTarget.destination(selection: selection, snapshot: store.snapshot, delta: -1) == nil)
|
||||
#expect(LaneMoveTarget.destination(selection: selection, snapshot: store.snapshot, delta: 1) == nil)
|
||||
}
|
||||
|
||||
@Test("A multi-lane selection answers nil — one slot has no meaning for a discontiguous pair")
|
||||
func multiLaneSelectionAnswersNil() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let selection = ItemReferenceSet(ids: [lane1, lane2], container: .board)
|
||||
|
||||
#expect(LaneMoveTarget.destination(selection: selection, snapshot: store.snapshot, delta: -1) == nil)
|
||||
#expect(LaneMoveTarget.destination(selection: selection, snapshot: store.snapshot, delta: 1) == nil)
|
||||
}
|
||||
|
||||
@Test("An empty selection and a trash-container selection both answer nil")
|
||||
func emptyAndTrashSelectionsAnswerNil() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
#expect(LaneMoveTarget.destination(selection: .empty, snapshot: store.snapshot, delta: 1) == nil)
|
||||
|
||||
let trashSelection = ItemReferenceSet(ids: [lane1], container: .trash)
|
||||
#expect(LaneMoveTarget.destination(selection: trashSelection, snapshot: store.snapshot, delta: 1) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user