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.
+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)
}
}
}
+7 -1
View File
@@ -10,7 +10,13 @@ import Foundation
/// (`ItemReferenceSet`), so the kind of a selection is always re-derived from the snapshot. Storing
/// it would be a second answer to a question the snapshot can always answer, and one that a reload
/// could falsify.
public enum SelectionKind: Sendable, Equatable {
///
/// The **one** place a kind is written down is the clipboard manifest, which has no snapshot to
/// re-derive it from "the cards-XOR-lanes selection rule means the clipboard holds cards or lanes,
/// never both" (04-interactions.md Clipboard). Hence `String`-backed and `Codable`: those raw
/// spellings are pasteboard API, decoded after a relaunch, and they are the case names so there is
/// no second vocabulary to keep in step.
public enum SelectionKind: String, Codable, Sendable, Equatable {
case card
case lane
}
+6 -1
View File
@@ -9,7 +9,12 @@ import Observation
/// mixes live and tombstoned items, so the side is a property of the set as a whole rather than of
/// each member which is exactly what makes re-resolution across a reload a matching rule rather
/// than a partition.
public enum Liveness: Sendable, Equatable {
///
/// **`String`-backed and `Codable` because the clipboard manifest carries one** (04-interactions.md
/// Clipboard: a manifest records its entries' "source side (live/trashed)"). The raw spellings are
/// therefore pasteboard API a manifest written before a quit is decoded after the relaunch and
/// they are the case names so nothing has to remember a second vocabulary.
public enum Liveness: String, Codable, Sendable, Equatable {
case live
case trashed