Implement the hybrid clipboard with deferred cut

⌘X/⌘C/⌘V for cards and lanes per 04-interactions.md § Clipboard:

- ClipboardStore stages full folder snapshots eagerly at the gesture into
  Application Support (at most the current copy; sweep at launch and on
  each copy purges what the pasteboard no longer references; a copy made
  before quitting pastes whole after restart) and writes the pasteboard a
  JSON manifest — every entry embedding its index.md, lane entries their
  cards' too — plus plain-text titles.
- Cut is Finder-style deferred: items dim in place off pendingCut, void on
  pasteboard takeover (changeCount, no timers), source-board close, or
  per-item external tombstoning; the first armed paste moves the surviving
  originals whole (tombstoned interior cards land in the destination's
  trash), a second paste materializes copies from staging.
- Paste anchors by the shared flatten-order rule (NewCardTarget's anchor,
  extracted); a tombstoned selection never anchors; lane paste reaches the
  right end and stays enabled on a zero-lane board; paste into the source
  board is the within-board lane duplicate; copies keep created, take
  fresh GUIDs, and strip tombstoned cards; trash-sourced copies strip
  deleted: at materialization; ⌘X is disabled on the trash side.
- A degraded paste is loud, never silent: staging gone → the embedded
  index.md fallback lands content-intact, attachments absent, and a
  BannerCenter-phrased row names what was lost.
- The standard Edit items validate through conditionally-attached
  onCommand handlers, so AppKit's enablement mirrors the availability
  predicates; text fields keep their own clipboard while focused.

879 unit tests (68 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 22:18:58 -04:00
parent 8f116934b4
commit 7eee0934ee
18 changed files with 2749 additions and 59 deletions
+9
View File
@@ -30,6 +30,10 @@ import SwiftUI
/// outward, and Select All. The chorded commands are the menu's (`BoardCommands`); everything
/// here is a plain key or a grammar modifier, which is exactly the split 04-interactions.md
/// Configurable bindings draws between what remaps and what does not.
/// - **The standard Edit items the board answers as a responder** Select All, and Cut/Copy/Paste
/// beside it (`ClipboardCommands.swift`). They are not menu items of ours: the Edit menu already
/// carries those titles, and titles are the remapping mechanism's key, so a second row sharing one
/// is ruled out (04-interactions.md Configurable bindings).
///
/// - **The trash quasi-lane** trailing, one fixed unit, joining and leaving the width division as
/// View Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
@@ -179,6 +183,11 @@ struct BoardView: View {
// forbids outright (04 Configurable bindings). A focused text field consumes it first, so
// A inside an inline editor stays text selection with no guard needed here.
.onCommand(#selector(NSText.selectAll(_:))) { store.selectAll() }
// **Cut / Copy / Paste** (04-interactions.md Clipboard), through the same responder door
// Select All above uses and for the same titles-are-API reason. Each handler is attached only
// while its command applies, which is what makes AppKit's automatic enablement mirror the
// validation exactly see `boardClipboardCommands`.
.boardClipboardCommands(store: store, clipboard: appModel.clipboard)
// **Belt** for a session SwiftUI does report ending: immediate cleanup, gated on the physical
// mouse button being up. A finished session's phase events can arrive *after* the user has
// started the next drag, and an ungated handler would wipe the new session's state no