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:
@@ -221,16 +221,33 @@ public final class ClipboardStore {
|
||||
/// ⌘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.
|
||||
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
|
||||
/// paste relocates them (04: "Cut is Finder-style deferred").
|
||||
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
|
||||
/// `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
|
||||
/// 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.
|
||||
private func write(from store: BoardStore, cut: Bool) {
|
||||
guard let capture = Self.capture(selection: store.selection, snapshot: store.snapshot) else { return }
|
||||
private func write(from store: BoardStore, targeting target: ItemReferenceSet, cut: Bool) {
|
||||
guard let capture = Self.capture(selection: target, snapshot: store.snapshot) else { return }
|
||||
|
||||
let copyID = UUID().uuidString.lowercased()
|
||||
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
|
||||
/// never reaches it.
|
||||
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 !SelectionGrammar.mixesKinds(store.selection, in: store.snapshot) else { return false }
|
||||
return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil
|
||||
guard !SelectionGrammar.mixesKinds(target, in: store.snapshot) else { return false }
|
||||
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
|
||||
@@ -312,7 +336,13 @@ public final class ClipboardStore {
|
||||
/// 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.
|
||||
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`.
|
||||
|
||||
Reference in New Issue
Block a user