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
+8 -2
View File
@@ -27,8 +27,8 @@ import SwiftUI
/// ### What is still a later card's
///
/// The search-aware filtering behind the count belongs to a later milestone. The card face is real
/// (`CardFaceView`); what it still owes is the cut treatment and the sole-selected card's attachment
/// carousel.
/// (`CardFaceView`) and wears the deferred cut's dim (`cutTreatment`); what it still owes is the
/// sole-selected card's attachment carousel.
struct LaneView: View {
let store: BoardStore
@@ -97,6 +97,9 @@ struct LaneView: View {
}
.background(selectionBackground)
.overlay(selectionStroke)
// The deferred cut's dim (04-interactions.md Clipboard) on the whole lane, because a cut
// lane is cut cards and all.
.cutTreatment(of: lane.id, in: store)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
// **This lane's drop target**, on the whole body. It accepts *every* session type and routes
// internally card sessions against this lane's masonry zones, lane sessions forwarded to
@@ -746,6 +749,9 @@ private struct CardFaceView: View {
lineWidth: isFileHovered ? 2.5 : 1.5
)
)
// The deferred cut's dim (04-interactions.md Clipboard: "cut items dim in place until paste
// moves them"). Above `contentShape` so the face stays fully clickable while it waits.
.cutTreatment(of: card.id, in: store)
.contentShape(Rectangle())
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
// two-stage Finder rename): one click selects and that is all it does no timer, no