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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user