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