diff --git a/Kanban/App/ClipboardStore.swift b/Kanban/App/ClipboardStore.swift index fc49ef2..43917ec 100644 --- a/Kanban/App/ClipboardStore.swift +++ b/Kanban/App/ClipboardStore.swift @@ -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`. diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift index c86aa03..7f08721 100644 --- a/Kanban/UI/Board/BoardCommands.swift +++ b/Kanban/UI/Board/BoardCommands.swift @@ -270,17 +270,57 @@ struct MoveCardCommands: View { // 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)" -/// (11-command-nexus.md), closing 10-accessibility.md's lane-move defect (04 ▸ Accessibility). -/// -/// **Sole lane, deliberately.** The width pair one row below explicitly batches over a multi-lane -/// 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. +/// (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 +/// menu-bar row around it. /// /// **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 @@ -311,23 +351,9 @@ struct MoveLaneCommands: View { 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)? { guard let store, store.acceptsBoardMutations else { return nil } - let selection = store.selection - 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) + return LaneMoveTarget.destination(selection: store.selection, snapshot: store.snapshot, delta: delta) } private func move(by delta: Int) { diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 7ae5ac2..69edb2e 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -680,55 +680,193 @@ struct CardFaceView: View, Equatable { // MARK: - Context menus - /// Open, Copy Link, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ - /// Context menus' Card row, in its order. + /// **The card context menu, redesigned** (2026-08-09 ▸ "redesign context menu for cards", card + /// 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 + /// `/.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 private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View { - // Open: Board ▸ Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card - // alone — "a card window is tied to one card" (11-command-nexus.md), so unlike Style… and - // Delete below it, this row never widens to the selection; Open never opens multiple, even - // 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. + // Group 1 — Open, Copy Link, Rename, Style ▸ (Symbol, Color). One run, no divider inside it: + // the owner's "open/rename+style" group is one group, not two (unlike the prior menu, which + // split Open/Copy Link from Rename/Style with a divider). Button("Open") { 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() } .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() } .disabled(!store.acceptsBoardMutations) - - // The target is **deferred**, and that is not a style choice: `.contextMenu`'s builder is - // non-escaping, so it runs while this body does, and computing a `StyleTarget` eagerly meant - // reading `store.selection` at body time — the O(board) subscription this view was rebuilt to - // shed (`isSelected`). Passed as a closure, `styleTarget` evaluates when a row *acts*, which - // is where every other menu target here is already read from (`deleteTargets`). - StyleMenuItems(store: store, recents: appModel.styleRecents, target: { styleTarget }) + Menu("Style") { + // Both rows open the identical popover — see the type comment's "one popover, not two". + Button("Symbol") { store.transient.beginStyleEditor(for: styleTarget) } + .disabled(!store.acceptsBoardMutations) + Button("Color") { store.transient.beginStyleEditor(for: styleTarget) } + .disabled(!store.acceptsBoardMutations) + } + // 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() - // Delete: File ▸ Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the - // widened target set below (`targetIDs`) — the successor-selection rule is `delete(_:)`'s own, - // so this row gets it for free. - Button("Delete") { deleteTargets() } + // Group 2 — Copy, Cut, Paste, Paste Special ▸ Paste Image into Card. + 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) + 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) } - /// `boardMenu`'s plain rows as VoiceOver custom actions — every one calling the *same* private - /// method its menu row does, so the two surfaces cannot come to mean different things. + /// `boardMenu`'s plain (non-submenu) rows as VoiceOver custom actions — every one calling the + /// *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 private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View { Button("Open") { openCard(card.id) } @@ -736,7 +874,13 @@ struct CardFaceView: View, Equatable { .disabled(!copyLinkEnabled) Button("Rename") { beginRename() } .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) } @@ -859,6 +1003,57 @@ struct CardFaceView: View, Equatable { .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 private var titleRow: some View { diff --git a/KanbanTests/ClipboardTests.swift b/KanbanTests/ClipboardTests.swift index a64768c..6aef1a1 100644 --- a/KanbanTests/ClipboardTests.swift +++ b/KanbanTests/ClipboardTests.swift @@ -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 diff --git a/KanbanTests/KeyboardGrammarTests.swift b/KanbanTests/KeyboardGrammarTests.swift index e20e6a4..2dfd695 100644 --- a/KanbanTests/KeyboardGrammarTests.swift +++ b/KanbanTests/KeyboardGrammarTests.swift @@ -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) + } +}