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
+63
View File
@@ -271,6 +271,43 @@ public final class BannerCenter {
signposts.insert(InfoSignpost(message: message), at: 0)
}
/// One item a degraded paste could not bring its attachments with what
/// `degradedPasteMessage(for:)` names.
///
/// `title` is the item's as written, `nil` for an untitled one: "Untitled" is a rendering, never
/// a value (03-board-ui.md § Card face), and the phrasing below says "the item" instead, exactly
/// as `actionPhrase(for:)` does for a failure whose title never got read.
public struct AttachmentLoss: Sendable, Equatable {
public let title: String?
public let attachments: Int
public init(title: String?, attachments: Int) {
self.title = title
self.attachments = attachments
}
}
/// **The degraded paste** (04-interactions.md Clipboard, settled): the staged snapshot was
/// missing or unreadable, so the paste fell back to the manifest's embedded `index.md` content
/// intact, attachments absent and this is the row that says so. "A degraded paste is loud,
/// never silent the user never discovers an empty `attachments/` later."
///
/// **A signpost, not a `oneShot`**, and the choice is the vocabulary's rather than a compromise:
/// 02-architecture.md's `oneShot` is *a write that did not happen*, carrying a `BoardWriteError`,
/// and nothing here failed the items landed, whole but for files that were never on the
/// pasteboard's side of the transfer. A signpost is the other member of the same lifecycle class
/// ("one-shots dismiss"): it reports something that already happened, it has no timeout, and only
/// the user clears it, which is the whole of "never evaporates unread". What it costs is
/// precedence a signpost ranks last and may collapse behind "+N more" which is the one place
/// this row reads quieter than 04's "loud" deserves. See the report's design-gap note.
///
/// An empty list posts nothing: a fallback that lost no attachments lost nothing at all, and a
/// banner announcing that would be noise.
public func postDegradedPaste(_ losses: [AttachmentLoss]) {
guard let message = Self.degradedPasteMessage(for: losses) else { return }
postSignpost(message)
}
/// Removes a dismissable row: a one-shot failure or a signpost. **An id that names an
/// in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel" are
/// different promises and a row that offers one must never quietly do the other.
@@ -512,6 +549,32 @@ public final class BannerCenter {
return "\(subject): \(reason) — showing the last good view"
}
/// The degraded paste's line 04-interactions.md's own example sentence, "Pasted 'Fix login'
/// without its 3 attachments", generalized over the two axes it can vary on.
///
/// **It names exactly what was lost**, which is what the design asks for and what decides every
/// choice below: the count is real (never "some"), the singular and the plural are both spelled,
/// and a multi-item paste totals the attachments rather than listing every title a banner is one
/// line, and "2 items" plus the true total is the honest summary where a truncated list would not
/// be. `nil` for an empty list: nothing was lost, so there is nothing to say.
///
/// The count is the item's `attachments/` as the snapshot listed it at copy time the design's
/// own vocabulary for what a card carries (01-storage-format.md § Attachments). A stray file
/// sitting loose in the card folder is not in it and is not named here; see the report's
/// design-gap note.
public nonisolated static func degradedPasteMessage(for losses: [AttachmentLoss]) -> String? {
guard !losses.isEmpty else { return nil }
let total = losses.reduce(0) { $0 + $1.attachments }
guard total > 0 else { return nil }
guard losses.count == 1, let only = losses.first else {
return "Pasted \(losses.count) items without their \(total) attachments"
}
let subject = only.title.map { "'\($0)'" } ?? "the item"
let tail = total == 1 ? "its attachment" : "its \(total) attachments"
return "Pasted \(subject) without \(tail)"
}
/// The suspended-history line. It names the *consequence* the user cares about undo and the
/// flush-before-overwrite guarantee are degraded rather than the git mechanics, and carries
/// the diagnosis as its tail.