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:
2026-08-09 09:44:30 -04:00
parent a429a7ec4d
commit 72ca22251b
5 changed files with 478 additions and 68 deletions
+38 -8
View File
@@ -221,16 +221,33 @@ public final class ClipboardStore {
/// C stages `store`'s selection and writes the pasteboard. Any pending cut is voided: its /// C stages `store`'s selection and writes the pasteboard. Any pending cut is voided: its
/// pasteboard entry has just been overwritten, so the items it dimmed are staying put. /// pasteboard entry has just been overwritten, so the items it dimmed are staying put.
public func copy(from store: BoardStore) { public func copy(from store: BoardStore) {
write(from: store, cut: false) write(from: store, targeting: store.selection, cut: false)
} }
/// X the same write, plus the deferred move: the items stay where they are, dimmed, until a /// X the same write, plus the deferred move: the items stay where they are, dimmed, until a
/// paste relocates them (04: "Cut is Finder-style deferred"). /// paste relocates them (04: "Cut is Finder-style deferred").
public func cut(from store: BoardStore) { public func cut(from store: BoardStore) {
write(from: store, cut: true) write(from: store, targeting: store.selection, cut: true)
} }
/// The one write both gestures share. /// **The card context menu's own Copy/Cut** (`CardFaceView`, 2026-08-09 "redesign context menu
/// for cards") Delete's widening rule (`targetIDs`, `styleTarget`) extended to the clipboard for
/// the first time: "right-clicking something outside the selection acts on what was clicked"
/// (standard macOS context-menu targeting). Same write, same staging, same pasteboard, same armed
/// cut `copy(from:)`/`cut(from:)` above are simply this with `store.selection` as the target;
/// this overload exists so a caller whose target is *not* the live selection (a card clicked
/// outside it) never has to fight `store.selection` to get there.
public func copy(from store: BoardStore, targeting target: ItemReferenceSet) {
write(from: store, targeting: target, cut: false)
}
/// `copy(from:targeting:)`'s cut twin `cut(from:)`'s deferred-move behavior, on an explicit
/// target.
public func cut(from store: BoardStore, targeting target: ItemReferenceSet) {
write(from: store, targeting: target, cut: true)
}
/// The one write every gesture and every menu row shares.
/// ///
/// The order is the contract: capture from the snapshot (main actor, no I/O every item's /// The order is the contract: capture from the snapshot (main actor, no I/O every item's
/// `index.md` is already parsed into the snapshot and `FrontmatterDocument.serialized()` returns /// `index.md` is already parsed into the snapshot and `FrontmatterDocument.serialized()` returns
@@ -239,8 +256,8 @@ public final class ClipboardStore {
/// chain** (`paste(into:)`): it can never read a half-written snapshot, so it never sees a tree the /// chain** (`paste(into:)`): it can never read a half-written snapshot, so it never sees a tree the
/// staging has not finished. This used to lean on the manifest's fallback text instead; with /// staging has not finished. This used to lean on the manifest's fallback text instead; with
/// refuse-don't-degrade the chain is the whole guarantee, and it is the stronger one. /// refuse-don't-degrade the chain is the whole guarantee, and it is the stronger one.
private func write(from store: BoardStore, cut: Bool) { private func write(from store: BoardStore, targeting target: ItemReferenceSet, cut: Bool) {
guard let capture = Self.capture(selection: store.selection, snapshot: store.snapshot) else { return } guard let capture = Self.capture(selection: target, snapshot: store.snapshot) else { return }
let copyID = UUID().uuidString.lowercased() let copyID = UUID().uuidString.lowercased()
let stagingDir = stagingRoot.appendingPathComponent(copyID, isDirectory: true) let stagingDir = stagingRoot.appendingPathComponent(copyID, isDirectory: true)
@@ -299,9 +316,16 @@ public final class ClipboardStore {
/// `capture`'s single `kind` honest: the manifest names one payload type, and a set spanning both /// `capture`'s single `kind` honest: the manifest names one payload type, and a set spanning both
/// never reaches it. /// never reaches it.
public func canCopy(from store: BoardStore) -> Bool { public func canCopy(from store: BoardStore) -> Bool {
canCopy(from: store, targeting: store.selection)
}
/// `canCopy(from:)` on an explicit target the card context menu's own reading
/// (`copy(from:targeting:)`'s doc comment), same three clauses, aimed at whatever the caller
/// widened to rather than always `store.selection`.
public func canCopy(from store: BoardStore, targeting target: ItemReferenceSet) -> Bool {
guard !store.isEditingInline else { return false } guard !store.isEditingInline else { return false }
guard !SelectionGrammar.mixesKinds(store.selection, in: store.snapshot) else { return false } guard !SelectionGrammar.mixesKinds(target, in: store.snapshot) else { return false }
return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil return SelectionGrammar.kind(of: target, in: store.snapshot) != nil
} }
/// Whether Edit Cut applies. Copy's conditions plus the one a *move* adds: the board must /// Whether Edit Cut applies. Copy's conditions plus the one a *move* adds: the board must
@@ -312,7 +336,13 @@ public final class ClipboardStore {
/// into a lane is the keyboard-native restore, an ordinary folder move". So there is no /// into a lane is the keyboard-native restore, an ordinary folder move". So there is no
/// container clause here at all, which is the pivot showing up as a deleted line. /// container clause here at all, which is the pivot showing up as a deleted line.
public func canCut(from store: BoardStore) -> Bool { public func canCut(from store: BoardStore) -> Bool {
canCopy(from: store) && !store.isReadOnly canCut(from: store, targeting: store.selection)
}
/// `canCut(from:)` on an explicit target `canCopy(from:targeting:)`'s own reasoning, plus the
/// read-only clause.
public func canCut(from store: BoardStore, targeting target: ItemReferenceSet) -> Bool {
canCopy(from: store, targeting: target) && !store.isReadOnly
} }
/// Whether Edit Paste applies to `store`. /// Whether Edit Paste applies to `store`.
+51 -25
View File
@@ -270,17 +270,57 @@ struct MoveCardCommands: View {
// MARK: - Lane moves // MARK: - Lane moves
/// **The pure predicate behind Board Move Left / Move Right** extracted so the menu-bar row
/// (`MoveLaneCommands`) and the card context menu's Navigation submenu (`CardFaceView`, 2026-08-09
/// "redesign context menu for cards") validate off one definition rather than two that could drift.
///
/// "Lane selection only (one slot; never into the trash)" (11-command-nexus.md), closing
/// 10-accessibility.md's lane-move defect (04 Accessibility).
///
/// **Sole lane, deliberately.** The width pair batches over a multi-lane selection; this rule's
/// inventory line says "Lane selection only" with no batching clause, and a multi-lane move has no
/// single unambiguous meaning ("one slot" for a discontiguous pair is not one answer). So this
/// validates on exactly one selected live lane.
///
/// **A card id answers `nil` here, on purpose and unconditionally** "a card id is in no lane
/// order, so this is also the 'not a lane' test". That is the whole of why the card context menu's
/// own Navigation rows are a structural mismatch rather than a card-scoped move: this predicate reads
/// the *board's live selection*, never the clicked card, so a card menu's Move Left/Right can only
/// ever be live when the selection elsewhere on the board happens to be a sole lane see
/// `CardFaceView`'s own note on why it does not even attempt to read this per card.
///
/// **Never into the trash** costs nothing: the column is not in the lane order, so a step past the
/// last real lane is simply off the end which is also the disable rule at the walls, following the
/// width stepper's floor style rather than letting the store no-op silently.
enum LaneMoveTarget {
/// The sole selected live lane and the display slot one step would put it in `nil` when there
/// is no such lane or it is already at that wall.
///
/// `from + delta` **is** the index `moveLane` wants: that method counts display positions among
/// the live lanes *with the moved lane already removed*, so inserting at `from - 1` puts the lane
/// before its old predecessor and at `from + 1` after its old successor one slot each way. The
/// convention is easy to get backwards, which is why it is pinned by a test.
static func destination(
selection: ItemReferenceSet,
snapshot: BoardModel,
delta: Int
) -> (lane: ItemID, index: Int)? {
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first
else { return nil }
let lanes = SelectionGrammar.lanes(in: snapshot)
// A card id is in no lane order, so this is also the "not a lane" test.
guard let from = lanes.firstIndex(of: id) else { return nil }
let to = from + delta
guard lanes.indices.contains(to) else { return nil }
return (id, to)
}
}
/// Board Move Left / Move Right (/) "Lane selection only (one slot; never into the trash)" /// Board Move Left / Move Right (/) "Lane selection only (one slot; never into the trash)"
/// (11-command-nexus.md), closing 10-accessibility.md's lane-move defect (04 Accessibility). /// (11-command-nexus.md), closing 10-accessibility.md's lane-move defect (04 Accessibility). The
/// /// validating predicate is `LaneMoveTarget.destination(selection:snapshot:delta:)`; this struct is the
/// **Sole lane, deliberately.** The width pair one row below explicitly batches over a multi-lane /// menu-bar row around it.
/// selection; this row's inventory line says "Lane selection only" with no batching clause, and a
/// multi-lane move has no single unambiguous meaning ("one slot" for a discontiguous pair is not one
/// answer). So the items validate on exactly one selected live lane.
///
/// **Never into the trash** costs nothing: the column is not in the lane order, so a step
/// past the last real lane is simply off the end which is also the disable rule at the walls,
/// following the width stepper's floor style rather than letting the store no-op silently.
/// ///
/// **Caret chords yield to any focused text control** (04-interactions.md Grammar, settled): /// **Caret chords yield to any focused text control** (04-interactions.md Grammar, settled):
/// / are the standard line-start/end chords, and an enabled key equivalent fires before a /// / are the standard line-start/end chords, and an enabled key equivalent fires before a
@@ -311,23 +351,9 @@ struct MoveLaneCommands: View {
caretChordsYield(boardInfo: boardInfo, search: search) caretChordsYield(boardInfo: boardInfo, search: search)
} }
/// The sole selected live lane and the display slot one step would put it in `nil` when there
/// is no such lane or it is already at that wall.
///
/// `from + delta` **is** the index `moveLane` wants: that method counts display positions among
/// the live lanes *with the moved lane already removed*, so inserting at `from - 1` puts the lane
/// before its old predecessor and at `from + 1` after its old successor one slot each way. The
/// convention is easy to get backwards, which is why it is pinned by a test.
private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? { private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? {
guard let store, store.acceptsBoardMutations else { return nil } guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection return LaneMoveTarget.destination(selection: store.selection, snapshot: store.snapshot, delta: delta)
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first else { return nil }
let lanes = SelectionGrammar.lanes(in: store.snapshot)
// A card id is in no lane order, so this is also the "not a lane" test.
guard let from = lanes.firstIndex(of: id) else { return nil }
let to = from + delta
guard lanes.indices.contains(to) else { return nil }
return (id, to)
} }
private func move(by delta: Int) { private func move(by delta: Int) {
+230 -35
View File
@@ -680,55 +680,193 @@ struct CardFaceView: View, Equatable {
// MARK: - Context menus // MARK: - Context menus
/// Open, Copy Link, Rename, Style, the quick-style recents row, Delete 11-command-nexus.md /// **The card context menu, redesigned** (2026-08-09 "redesign context menu for cards", card
/// Context menus' Card row, in its order. /// fe66c461): four groups, a divider between each, the owner's shape verbatim
///
/// 1. Open, Copy Link, Rename, Style (Symbol, Color)
/// 2. Copy, Cut, Paste, Paste Special (Paste Image into Card, more tbd)
/// 3. Navigation (Move Left, Move Right)
/// 4. Send to Trash
///
/// Every row below routes through the *existing* command it twins nothing here is a new
/// capability, only a new arrangement of ones the app already has (`OpenCardCommand`,
/// `BoardRenameCommand`, `StyleCommand`/`StyleEditorSession`, `ClipboardStore`,
/// `LaneMoveTarget`/`MoveLaneCommands`, `store.delete`/`TrashCommands`).
///
/// ### Two deviations from the card's literal list, both kept and both journaled
///
/// **Copy Link stays.** The owner's list does not mention it, but it shipped the same day this
/// card was filed (design ruling 2026-08-09, card 737a949f) and nothing here was asked to retire
/// it dropping a same-day feature would be a behavior change, not a structure one. It rides in
/// the first group, immediately after Open (its original neighbor), ahead of Rename.
///
/// **The row reads "Send to Trash", not "Delete".** The owner's own word for group 4, and the more
/// accurate one for what this button does on the board side it is the staged move into
/// `<root>/.trash/`, not a permanent delete (that word is reserved for the trash lane's own menu,
/// `trashMenu` below, which this rename does not touch). The action underneath is unchanged:
/// `store.delete(targetIDs)`, `TrashCommands`'s own twin.
///
/// ### Style Symbol / Color one popover, not two (v1, flagged for owner review)
///
/// `StyleEditorSession` carries only a `StyleTarget`, no notion of "arrived here for the symbol
/// section" versus "the color section" (`StyleModel.swift`), and adding one would mean teaching
/// `StyleEditorView` to pre-focus a section internals this card is explicitly told not to touch
/// (another agent is concurrently redesigning the pickers themselves). So both rows open the same
/// existing popover, showing both sections, exactly as the single "Style" row always has. This is
/// the SCOPE's own named fallback ("if it doesn't [support pre-focus], opening the existing
/// popover for both rows is acceptable v1") kept, and flagged: a future pass that adds
/// section-focus to the editor should aim these two rows at it.
///
/// **The quick-style recents row is gone from this menu.** `StyleMenuItems` used to bundle
/// "Style" with a horizontal recents `Picker` beside it; the owner's list names only Symbol and
/// Color under Style, so the recents row never mentioned is dropped here to match the shape
/// exactly. It is untouched on the **lane** menu (`LaneView.laneMenu`, out of this card's scope,
/// which only asked for the *card* menu). Flagged for owner review: easy to bring back as a third
/// row under Style if the drop was not intended.
///
/// ### Navigation Move Left / Move Right wired, but unconditionally disabled (flagged)
///
/// `LaneMoveTarget.destination` Move Left/Right's one predicate, shared with the menu-bar row
/// reads the **board's live selection**, not the clicked card, and answers `nil` for any card id
/// unconditionally ("a card id is in no lane order" see its own doc comment). A card's own
/// context menu can therefore never itself be a case where this predicate answers `true` for
/// *this* card; the only way the row could ever be live is if some unrelated lane happened to be
/// the board's live selection at the same time a coincidence with nothing to do with the card
/// that was right-clicked, and confusing to expose ("Move Left" on a card silently moving some
/// other lane).
///
/// Reading the live selection to chase that coincidence would also cost real render performance:
/// unlike every other row here, `LaneMoveTarget.destination` needs `store.selection` and
/// `store.snapshot`, and `.contextMenu`'s builder is **not lazy** SwiftUI evaluates `boardMenu`
/// (and every `.disabled` inside it) on every ordinary body pass, the exact regression
/// `isSelected`/`selectedCount` exist to prevent (this struct's own top-of-file note;
/// `BoardRenderPerformanceTests.selectionStillRepaints`). So the two rows are wired to the real
/// store call (`moveLane`, via `LaneMoveTarget.destination`) for when this is revisited, but
/// `.disabled(true)` unconditionally rather than paying an O(board) selection read for a row that
/// is a structural mismatch for a card menu in the first place. Flagged for owner review: a
/// genuine per-card "move this card's lane" affordance would be new targeting behavior out of this
/// card's "menu structure, not new behavior" scope and the owner's card body may simply not have
/// anticipated the sole-lane restriction Move Left/Right already carries (11-command-nexus.md).
///
/// ### Copy / Cut / Paste / Paste Special targeting
///
/// **Copy and Cut widen exactly as Delete and Style do** `clipboardTarget`, `targetIDs`'s own
/// `ItemReferenceSet` wrapper "right-clicking something outside the selection acts on what was
/// clicked" (`targetIDs`'s doc comment), extended to the clipboard for the first time
/// (`ClipboardStore.copy(from:targeting:)`/`cut(from:targeting:)`). Multi-selection semantics are
/// `ClipboardStore`'s own, unchanged: a multi-card selection copies/cuts every member, in flatten
/// order, exactly as C/X already do.
///
/// **Paste does not retarget to the clicked card.** Unlike Copy/Cut/Delete/Style, Paste has no
/// per-item widening precedent anywhere in this codebase it is a *destination* operation
/// (`PasteTarget`), not an item operation, and its target has always been "wherever the live
/// selection anchors" (`PasteTarget.cards`), the same rule V and Edit Paste already use. This
/// row calls that exact rule (`ClipboardStore.paste(into:)`) rather than inventing a
/// click-anchored variant. Flagged for owner review if a click-anchored paste ("paste after the
/// card I right-clicked, regardless of the live selection") turns out to be what was wanted.
///
/// **Paste Image into Card is genuinely per-card** "into card" is the row's own wording, and it
/// is `ClipboardStore.pasteImage(intoCard:in:)`, the *same* method the card window's own V
/// already calls for its attachment branch. It always targets **this** card, never the widened
/// selection: like Open, Rename and Style's anchor, "single-card by nature" the existing
/// precedent this SCOPE asks new rows to follow for such rows. " more tbd" is left as the SCOPE
/// asks: one row today, the submenu built to grow.
///
/// ### Render-safety: what `.disabled` is allowed to read here
///
/// Every `.disabled` in this menu reads only plain, rarely-changing `BoardStore` flags
/// (`acceptsBoardMutations`, `isReadOnly`, `isEditingInline`) or the clipboard's own observable
/// state (`appModel.clipboard.payload`/`imagePayload`, which this file's own top note already
/// accepts reading directly, `appModel.styleRecents`' own precedent) **never** `store.selection`
/// or `store.snapshot` directly, because `.contextMenu`'s builder is not lazy and either read would
/// re-subscribe every card face's body to board-wide state, the O(board) regression this file was
/// rebuilt to shed. Two rows lean on a proof rather than a literal call to make that hold:
///
/// - **Paste**: `PasteTarget.cards`'s only `nil` case is a board with no *renderable* lane
/// (`NewCardTarget.resolve`'s "a board whose every lane is folded has no target at all"). A card
/// face exists only because its own lane rendered it, so that case is already false by the time
/// this menu can even open `canPaste(into:)` reduces to `acceptsBoardMutations && payload !=
/// nil` here, with no selection read needed (`pasteEnabled`).
/// - **Paste Image into Card**: `canPasteImage(intoCard:in:)`'s snapshot lookup
/// (`BoardStore.boardItem`) only ever answers `false` for a card that is not a live board item
/// which, again, this very face rendering already rules out. Reduces to `!isReadOnly &&
/// imagePayload != nil` (`pasteImageEnabled`).
///
/// Copy and Cut lean on the same style of proof: `targetIDs` is always non-empty (at minimum this
/// card alone) and always homogeneous cards on the board side (`SelectionGrammar.mixesKinds`
/// answers `true` only for the trash see its own doc comment), so `canCopy`/`canCut`'s
/// kind-checks are always satisfied and the rows reduce to `isEditingInline`/`acceptsBoardMutations`
/// (`copyEnabled`/`store.acceptsBoardMutations`) without reading the target at all for `.disabled`
/// the target is still read, correctly, inside each action (`clipboardTarget`).
@ViewBuilder @ViewBuilder
private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View { private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
// Open: Board Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card // Group 1 Open, Copy Link, Rename, Style (Symbol, Color). One run, no divider inside it:
// alone "a card window is tied to one card" (11-command-nexus.md), so unlike Style and // the owner's "open/rename+style" group is one group, not two (unlike the prior menu, which
// Delete below it, this row never widens to the selection; Open never opens multiple, even // split Open/Copy Link from Rename/Style with a divider).
// when the clicked card is part of one. It calls the very `openCard` closure the double-click
// gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
// a focused inline editor to *this* card's context menu, so there is nothing here to commit
// first only the plain open.
Button("Open") { Button("Open") {
openCard(card.id) 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() } Button("Copy Link") { copyLink() }
.disabled(!copyLinkEnabled) .disabled(!copyLinkEnabled)
Divider()
// Rename: Board Rename's exact store path (`BoardRenameCommand`) `beginRename(of:
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
// this card to be the *sole* selection; a context menu already names its target by where it
// was invoked, so standard macOS practice it acts on the clicked card outright.
Button("Rename") { beginRename() } Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations) .disabled(!store.acceptsBoardMutations)
Menu("Style") {
// The target is **deferred**, and that is not a style choice: `.contextMenu`'s builder is // Both rows open the identical popover see the type comment's "one popover, not two".
// non-escaping, so it runs while this body does, and computing a `StyleTarget` eagerly meant Button("Symbol") { store.transient.beginStyleEditor(for: styleTarget) }
// reading `store.selection` at body time the O(board) subscription this view was rebuilt to .disabled(!store.acceptsBoardMutations)
// shed (`isSelected`). Passed as a closure, `styleTarget` evaluates when a row *acts*, which Button("Color") { store.transient.beginStyleEditor(for: styleTarget) }
// is where every other menu target here is already read from (`deleteTargets`). .disabled(!store.acceptsBoardMutations)
StyleMenuItems(store: store, recents: appModel.styleRecents, target: { styleTarget }) }
// Both rows share this exact condition, so the submenu itself greys out with them rather than
// opening to reveal two disabled rows.
.disabled(!store.acceptsBoardMutations)
Divider() Divider()
// Delete: File Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the // Group 2 Copy, Cut, Paste, Paste Special Paste Image into Card.
// widened target set below (`targetIDs`) the successor-selection rule is `delete(_:)`'s own, Button("Copy") { appModel.clipboard.copy(from: store, targeting: clipboardTarget) }
// so this row gets it for free. .disabled(!copyEnabled)
Button("Delete") { deleteTargets() } Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
.disabled(!store.acceptsBoardMutations)
Button("Paste") { appModel.clipboard.paste(into: store) }
.disabled(!pasteEnabled)
Menu("Paste Special") {
Button("Paste Image into Card") {
appModel.clipboard.pasteImage(intoCard: card.id, in: store)
}
.disabled(!pasteImageEnabled)
}
Divider()
// Group 3 Navigation Move Left / Move Right. Wired, unconditionally disabled see the
// type comment's own section on why.
Menu("Navigation") {
Button("Move Left") { moveLane(by: -1) }
.disabled(true)
Button("Move Right") { moveLane(by: 1) }
.disabled(true)
}
// Both rows are unconditionally disabled (see above), so the submenu itself greys out too
// rather than opening onto two dead rows.
.disabled(true)
Divider()
// Group 4 Send to Trash: File Delete's exact store path (`store.delete`, `TrashCommands`'s
// twin), on the widened target set (`targetIDs`) the successor-selection rule is
// `delete(_:)`'s own, so this row gets it for free. Relabeled from "Delete" see the type
// comment.
Button("Send to Trash") { deleteTargets() }
.disabled(!store.acceptsBoardMutations) .disabled(!store.acceptsBoardMutations)
} }
/// `boardMenu`'s plain rows as VoiceOver custom actions every one calling the *same* private /// `boardMenu`'s plain (non-submenu) rows as VoiceOver custom actions every one calling the
/// method its menu row does, so the two surfaces cannot come to mean different things. /// *same* private method its menu row does, so the two surfaces cannot come to mean different
/// things. Style, Paste Special and Navigation are absent for `LaneView`'s own reason (its Style
/// note): each opens its own accessible surface a popover, or the submenu itself, which the
/// context menu already exposes reachably (VO--M) and is "not an action" in the flat sense this
/// list carries.
@ViewBuilder @ViewBuilder
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View { private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
Button("Open") { openCard(card.id) } Button("Open") { openCard(card.id) }
@@ -736,7 +874,13 @@ struct CardFaceView: View, Equatable {
.disabled(!copyLinkEnabled) .disabled(!copyLinkEnabled)
Button("Rename") { beginRename() } Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations) .disabled(!store.acceptsBoardMutations)
Button("Delete") { deleteTargets() } Button("Copy") { appModel.clipboard.copy(from: store, targeting: clipboardTarget) }
.disabled(!copyEnabled)
Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
.disabled(!store.acceptsBoardMutations)
Button("Paste") { appModel.clipboard.paste(into: store) }
.disabled(!pasteEnabled)
Button("Send to Trash") { deleteTargets() }
.disabled(!store.acceptsBoardMutations) .disabled(!store.acceptsBoardMutations)
} }
@@ -859,6 +1003,57 @@ struct CardFaceView: View, Equatable {
.map { $0.folder(under: store.rootURL) } .map { $0.folder(under: store.rootURL) }
} }
// MARK: - Copy / Cut / Paste
/// Copy's and Cut's own widened target, as the `ItemReferenceSet` `ClipboardStore`'s targeted
/// overloads take `targetIDs` plus this face's container, `styleTarget`'s exact pairing one type
/// over. Read only inside an **action** closure (never inside a `.disabled`), `styleTarget`'s own
/// reason: it reads `store.selection`, and `.contextMenu`'s builder is not lazy.
private var clipboardTarget: ItemReferenceSet {
ItemReferenceSet(ids: targetIDs, container: role.container)
}
/// Copy's context-menu enablement render-safe by construction, never by re-deriving
/// `targetIDs`/`store.selection` inline (`copyLinkEnabled`'s own reason).
///
/// `ClipboardStore.canCopy(from:targeting:)` is three clauses: not mid-edit, the target does not
/// mix kinds, and the target names a kind at all. On this face's own container (`.board`) the
/// latter two are always satisfied for `clipboardTarget`: it is never empty (at minimum this card
/// alone) and never mixes kinds (`SelectionGrammar.mixesKinds` answers `true` only on the trash
/// its own doc comment: "only the trash can answer true"). So the whole predicate reduces to
/// `!store.isEditingInline`, which costs no selection read at all.
private var copyEnabled: Bool {
!store.isEditingInline
}
/// Paste's context-menu enablement, reduced the same render-safe way see the type comment's
/// "Render-safety" section for the proof that `PasteTarget.cards` cannot answer `nil` from a card
/// face that exists at all, which is what lets this skip `store.selection` entirely.
private var pasteEnabled: Bool {
store.acceptsBoardMutations && appModel.clipboard.payload != nil
}
/// Paste Image into Card's enablement, `pasteEnabled`'s own reduction: `canPasteImage(intoCard:in:)`
/// only ever answers `false` on its snapshot lookup for a card that is not a live board item, which
/// this face rendering at all already rules out see the type comment's "Render-safety" section.
private var pasteImageEnabled: Bool {
!store.isReadOnly && appModel.clipboard.imagePayload != nil
}
// MARK: - Navigation (Move Left / Move Right)
/// Navigation Move Left/Right's action `MoveLaneCommands.move(by:)`'s own shape, over
/// `LaneMoveTarget.destination`, the exact predicate the menu-bar row validates against. The rows
/// calling this are `.disabled(true)` unconditionally (see the type comment's own section on why),
/// so in practice this never fires from the UI; it stays real rather than a stub so a future pass
/// that relaxes the disable has the correct call already in place.
private func moveLane(by delta: Int) {
guard store.acceptsBoardMutations,
let target = LaneMoveTarget.destination(selection: store.selection, snapshot: store.snapshot, delta: delta)
else { return }
store.moveLane(target.lane, toIndex: target.index)
}
// MARK: - Title row // MARK: - Title row
private var titleRow: some View { private var titleRow: some View {
+77
View File
@@ -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 // MARK: - Availability
@MainActor @MainActor
+82
View File
@@ -1027,3 +1027,85 @@ struct MoveLaneConventionTests {
#expect(try load(fixture).lanes.map(\.id) == [lane2, lane1, lane3]) #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)
}
}