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
+126 -30
View File
@@ -1425,6 +1425,25 @@ public final class BoardStore {
folder.deletingLastPathComponent().deletingLastPathComponent()
}
/// Where one arriving item's bytes come from.
///
/// **Two producers, one arrival path.** A drag names folders in the source board; a paste names
/// folders in the clipboard's staging directory and, when that snapshot is missing or
/// unreadable, the manifest's embedded `index.md` instead (04-interactions.md Clipboard's
/// staging-less fallback). Modelling the fallback as a second kind of *source* rather than as a
/// second arrival method is what keeps the rank insertion, the tombstone stripping and the
/// `deleted:` clearing stated once: everything downstream of "where do the bytes come from" is
/// identical, and a paste that half-falls-back mixes the two cases inside one bracket.
public enum ItemSource: Sendable, Equatable {
/// A folder on disk the source board's own, or a staged snapshot of it.
case folder(URL)
/// The manifest's embedded text: the item's `index.md`, and (for a lane) its cards'.
/// Materialized by `BoardWriter.materializeItem`, byte-faithfully.
case text(index: String, cards: [String])
}
/// A cross-board card drop, landing contiguously at `index` among `laneID`'s rendered cards.
///
/// - `.copy` (the default between boards) `copyItem` per folder: fresh GUIDs throughout,
@@ -1435,7 +1454,26 @@ public final class BoardStore {
/// finest grain (01-storage-format.md's per-folder degradation, which is `moveItem`'s own
/// behaviour rather than something this method arranges).
public func receiveCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: false)
receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: false)
}
/// **The clipboard's card arrival** `receiveCards`/`receiveRestoredCards` with the two axes a
/// paste varies independently (04-interactions.md Clipboard).
///
/// It is the same commit as a drop's, deliberately: `.copy` materializes from the staged snapshot
/// (or, per entry, from the embedded `index.md`), `.move` is the armed cut's "the -drag move
/// path identity travels" and `clearingTombstones` is the trash's copy-out rule, "`deleted:`
/// is stripped **at materialization**". A cut is live-only (X is disabled in the trash), so the
/// two flags never both fire; the parameter is not narrowed for that, because which of them is
/// reachable is the *clipboard's* rule and this method's job is only to obey both.
public func receiveCards(
_ sources: [ItemSource],
operation: TransferOperation,
toLane laneID: ItemID,
at index: Int,
clearingTombstones: Bool
) {
receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: clearingTombstones)
}
/// The cross-board half of drag-to-restore (04-interactions.md The trash): tombstoned rows
@@ -1456,11 +1494,11 @@ public final class BoardStore {
/// because `restoreItem` is already the one expression in the app for "remove the `deleted:`
/// key" the bytes are never rewritten any other way.
public func receiveRestoredCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: true)
receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: true)
}
private func receive(
_ sources: [URL],
_ sources: [ItemSource],
operation: TransferOperation,
toLane laneID: ItemID,
at index: Int,
@@ -1488,25 +1526,60 @@ public final class BoardStore {
guard let ranks else { return }
for (source, rank) in zip(sources, ranks) {
let arrived: ItemID
switch operation {
case .copy:
arrived = try BoardWriter.copyItem(at: source, toParent: laneFolder, order: rank, stamps: .fork)
case .move:
arrived = try BoardWriter.moveItem(
at: source,
toParent: laneFolder,
sourceBoardRoot: Self.boardRoot(ofCardFolder: source),
destinationBoardRoot: root,
order: rank
).id
}
guard let arrived = try Self.materialize(
source,
operation: operation,
intoParent: laneFolder,
destinationBoardRoot: root,
sourceBoardRoot: Self.boardRoot(ofCardFolder:),
order: rank
) else { continue }
guard clearingTombstones else { continue }
try BoardWriter.restoreItem(at: laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true))
}
}
}
/// One arrival's materialization the two `ItemSource` kinds crossed with the two operations,
/// in the one place both the card path and the lane path can share.
///
/// **`.move` of a `.text` source is unreachable and answers `nil`.** A move needs a folder whose
/// identity travels, and the only producer of text sources is the clipboard's fallback, which is
/// a *copy* by construction (04-interactions.md Clipboard: an armed cut moves the surviving
/// originals, and a cut that cannot find them is void). Skipping is the standing posture for an
/// arrival that names nothing the same silent no-op every other drop commit gives a source that
/// has gone.
private static func materialize(
_ source: ItemSource,
operation: TransferOperation,
intoParent parent: URL,
destinationBoardRoot: URL,
sourceBoardRoot: (URL) -> URL,
order: Double
) throws(BoardWriteError) -> ItemID? {
switch (source, operation) {
case let (.folder(folder), .copy):
return try BoardWriter.copyItem(at: folder, toParent: parent, order: order, stamps: .fork)
case let (.folder(folder), .move):
return try BoardWriter.moveItem(
at: folder,
toParent: parent,
sourceBoardRoot: sourceBoardRoot(folder),
destinationBoardRoot: destinationBoardRoot,
order: order
).id
case let (.text(index, cards), .copy):
return try BoardWriter.materializeItem(
inParent: parent,
indexText: index,
children: cards,
order: order
)
case (.text, .move):
return nil
}
}
/// A cross-board lane drop, landing contiguously at `stripIndex` among this board's live lanes.
///
/// The two operations differ in exactly one place beyond identity, and it is 04-interactions.md
@@ -1526,6 +1599,26 @@ public final class BoardStore {
/// not exist by drag at all ( is ignored on lane drags; the clipboard is that operation's one
/// home), so this method is cross-board by construction.
public func receiveLanes(_ sources: [URL], operation: TransferOperation, at stripIndex: Int) {
receiveLanes(sources.map(ItemSource.folder), operation: operation, at: stripIndex, clearingTombstones: false)
}
/// **The clipboard's lane arrival** `receiveLanes` with the staging-less fallback and the
/// trash's copy-out rule folded in (04-interactions.md Clipboard, The trash).
///
/// The two operations keep their drag semantics exactly, because 04 says they are the same
/// semantics: "a pasted *copy* takes fresh GUIDs throughout and **strips tombstoned cards**; a
/// cut-paste is the -drag move the folder moves whole, tombstoned cards landing in the
/// destination's trash". `clearingTombstones` adds the one thing a drag never asks for: a lane
/// *entry* copied out of the trash arrives live, its own `deleted:` removed once it is at the
/// destination the lane-level twin of `receiveRestoredCards`, and the reason the strip runs
/// first is that the two writes touch different files and the strip's target list is the one that
/// must be read before anything is rewritten.
public func receiveLanes(
_ sources: [ItemSource],
operation: TransferOperation,
at stripIndex: Int,
clearingTombstones: Bool
) {
guard !sources.isEmpty else { return }
let root = rootURL
@@ -1545,20 +1638,23 @@ public final class BoardStore {
guard let ranks else { return }
for (source, rank) in zip(sources, ranks) {
switch operation {
case .copy:
let arrived = try BoardWriter.copyItem(at: source, toParent: root, order: rank, stamps: .fork)
try BoardWriter.stripTombstonedChildren(
of: root.appendingPathComponent(arrived.rawValue, isDirectory: true)
)
case .move:
_ = try BoardWriter.moveItem(
at: source,
toParent: root,
sourceBoardRoot: Self.boardRoot(ofLaneFolder: source),
destinationBoardRoot: root,
order: rank
)
guard let arrived = try Self.materialize(
source,
operation: operation,
intoParent: root,
destinationBoardRoot: root,
sourceBoardRoot: Self.boardRoot(ofLaneFolder:),
order: rank
) else { continue }
let laneFolder = root.appendingPathComponent(arrived.rawValue, isDirectory: true)
// The strip belongs to the copy alone: "a move never reads below its root, so the
// tombstones travel and the destination's trash quasi-lane renders them".
if operation == .copy {
try BoardWriter.stripTombstonedChildren(of: laneFolder)
}
if clearingTombstones {
try BoardWriter.restoreItem(at: laneFolder)
}
}
}