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:
@@ -830,6 +830,73 @@ public enum BoardWriter: Sendable {
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Materializes an item from **supplied `index.md` text** rather than from a folder on disk —
|
||||
/// the clipboard's staging-less fallback (04-interactions.md ▸ Clipboard: "if the staged
|
||||
/// snapshot is missing or unreadable at paste time, paste falls back to the embedded
|
||||
/// `index.md` — content intact, attachments absent").
|
||||
///
|
||||
/// **The text is written byte-faithfully, because it *is* the source bytes.** It was captured
|
||||
/// verbatim at copy time and travels through the manifest untouched, so this call writes it as
|
||||
/// given — unknown keys, comments, blank lines, line endings, body and all — rather than
|
||||
/// re-serializing anything. That is the round-trip guarantee applied to a file the app is
|
||||
/// minting from bytes it was handed (01-storage-format.md § Fractal layout ▸ Rules).
|
||||
///
|
||||
/// **Fresh identity, fork stamps** — the same semantics `copyItem` gives an ordinary copy, and
|
||||
/// necessarily so: this is a copy that happened to arrive as text. Every folder is a fresh mint,
|
||||
/// `created` survives in the supplied bytes (a duplicate is a fork), and the root's `order` and
|
||||
/// `modified` are rewritten by the closing `updateIndex`, which also clears `modified-by`.
|
||||
///
|
||||
/// `children` are a **lane's** cards, each its own supplied `index.md`, materialized under the
|
||||
/// new root in the order given and deliberately *not* rewritten: "a nested item keeps its rank
|
||||
/// among its own siblings, which travelled with it" (`copyItem`'s rule). A card passes none.
|
||||
///
|
||||
/// **The root gets `copyItem`'s strictness and the children get its leniency.** The root must be
|
||||
/// rewritten — it needs its new `order` — so unparseable or uneditable text fails the call;
|
||||
/// a child is never rewritten, so whatever it is arrives exactly as it was.
|
||||
///
|
||||
/// **All-or-nothing at the destination**, `copyItem`'s rule for its reason: any failure once the
|
||||
/// folder exists removes the partial tree best-effort and rethrows, because a half-materialized
|
||||
/// item is pure residue — nothing was there before.
|
||||
public static func materializeItem(
|
||||
inParent destinationParent: URL,
|
||||
indexText: String,
|
||||
children: [String] = [],
|
||||
order: Double?
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
let operation = WriteOperation.copy(title: nil)
|
||||
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
|
||||
|
||||
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
|
||||
|
||||
// The same mint the create path uses, so every identity this app materializes is materialized
|
||||
// one way — fresh lowercase UUIDv4, folder and all.
|
||||
let root = try mintUUIDFolder(in: destinationParent, operation: operation)
|
||||
|
||||
do throws(BoardWriteError) {
|
||||
try atomicReplace(
|
||||
text: indexText,
|
||||
at: root.appendingPathComponent(BoardLoader.indexFileName),
|
||||
operation: operation
|
||||
)
|
||||
for child in children {
|
||||
let childFolder = try mintUUIDFolder(in: root, operation: operation)
|
||||
try atomicReplace(
|
||||
text: child,
|
||||
at: childFolder.appendingPathComponent(BoardLoader.indexFileName),
|
||||
operation: operation
|
||||
)
|
||||
}
|
||||
try updateIndex(inItemFolder: root, operation: operation) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
throw error
|
||||
}
|
||||
|
||||
return ItemID(rawValue: root.lastPathComponent)
|
||||
}
|
||||
|
||||
// MARK: - Tombstone
|
||||
|
||||
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md` —
|
||||
|
||||
Reference in New Issue
Block a user