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