Lanes delete into the trash — storage, loader, writer, and undo
Phase 1 of the lanes-in-trash card (2026-07-29 ruling, docs led the
code): lane delete is a move into .trash/ with the subtree intact,
arriving at top trash rank — no destructive delete remains outside
the trash.
TrashedLane opaque unit (id/schema/title/order/heldCards) beside
trash cards — deliberately not a Lane, so no card-shaped surface can
believe an empty subtree. Loader's trash walk trusts the kind VALUE
(lane → opaque unit w/ held-card count counted at the loader's own
unit; card → ordinary card; absent/unrecognized → UUID-children
shape, empty-kindless falls to card per 01's honest limit). Writer:
moveIntoTrash generalized with kind passed never derived (an empty
lane would re-derive as card), deleteLaneToTrash mints against the
whole-container rank ladder. Retired: migrateTombstonedLane (lane
deleted: now ignored — loads live, bytes inert, tolerate-tier
warning), removeLane, captureSubtree/recreateSubtree and the
subtree-snapshot machinery. Undo inverse = move back to captured
strip position, redo replays at captured trash rank. Purge walks
lane subtrees; TrashModel.Freight phrases confirms with lane freight
("…and its 5 cards"). ItemPath gains .trashLane; resolve interleaves
the trash by rank; SearchFilter matches lane rows by title only.
Trashed-lane card windows dismiss and pending cuts void via the
ordinary vanish rule — no new plumbing.
Phase 2 (rendering, selection grammar, drag, a11y, agent guide)
follows. Both schemes 1858 tests / 318 suites green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
+107
-352
@@ -1046,9 +1046,9 @@ public enum BoardWriter: Sendable {
|
||||
/// The sequence, which is the contract:
|
||||
///
|
||||
/// 1. **`cardFolder` must be a card** (`checkIsCardFolder`, the stricter guard: UUID-shaped
|
||||
/// *under* a UUID-shaped parent). This is what makes "lanes are never trashed" structural
|
||||
/// rather than a policy the caller has to remember — a lane, a board root and a stray are
|
||||
/// all refused here, and lane deletion has its own call (`removeLane`).
|
||||
/// *under* a UUID-shaped parent). A board root and a stray are refused here, and a lane takes
|
||||
/// the sibling door (`deleteLaneToTrash`) — same move, different guard and a different `kind`
|
||||
/// to stamp, which is exactly the pair `.trash/`'s flat container needs told apart.
|
||||
/// 2. **Pre-flight the card's `index.md`** (`checkIndexIsRewritable`) — the move rewrites it
|
||||
/// at the destination, so a file that cannot be round-tripped refuses *before* the folder
|
||||
/// travels. `moveItem`'s discover-before-you-write rule, for its reason.
|
||||
@@ -1088,9 +1088,48 @@ public enum BoardWriter: Sendable {
|
||||
inBoard boardRoot: URL,
|
||||
order: Double
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try moveCardIntoTrash(
|
||||
try moveIntoTrash(
|
||||
at: cardFolder,
|
||||
inBoard: boardRoot,
|
||||
kind: .card,
|
||||
order: order,
|
||||
operation: .delete(title: nil),
|
||||
removingLegacyKey: false
|
||||
)
|
||||
}
|
||||
|
||||
/// **Deleting a lane: the same physical move into `<board-root>/.trash/`** (01-storage-format.md
|
||||
/// § Deletion, lanes joined 2026-07-29 — "retiring the design's sole destructive delete";
|
||||
/// 03-board-ui.md § Trash: "deleting a lane moves its folder — subtree intact — into `.trash/`,
|
||||
/// exactly as a card moves").
|
||||
///
|
||||
/// `deleteCardToTrash`'s body with two differences, and they are the whole of what a lane is:
|
||||
///
|
||||
/// - **The guard is `checkIsLaneFolder`** — UUID-shaped directly under a board root, which
|
||||
/// refuses a card, a board root, a stray, and notably a folder already in `.trash/`.
|
||||
/// - **`kind: lane` is stamped**, not derived. The rank rewrite is a `updateIndex` on a folder
|
||||
/// that is by then *inside* `.trash/`, where position cannot answer and shape would answer
|
||||
/// *wrongly* for the one lane that most needs the key: an **empty** lane is shape-identical to
|
||||
/// a card (01's own "honest limit"). The caller knows what it moved, so it says so — which is
|
||||
/// also the backfill the ruling asks of this write ("`kind: lane` … backfilled on touch when
|
||||
/// absent … the trash move's rank mint included").
|
||||
///
|
||||
/// **The subtree rides along untouched**: nothing beneath the lane is read or rewritten, so its
|
||||
/// cards, their `attachments/` and every stray arrive byte-identical and come back with it on
|
||||
/// restore — which is what makes the inverse an ordinary move rather than a replay of captured
|
||||
/// bytes (13-native-undo.md ▸ Interaction with the trash).
|
||||
///
|
||||
/// - Returns: the lane's identity, unchanged.
|
||||
@discardableResult
|
||||
public static func deleteLaneToTrash(
|
||||
at laneFolder: URL,
|
||||
inBoard boardRoot: URL,
|
||||
order: Double
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try moveIntoTrash(
|
||||
at: laneFolder,
|
||||
inBoard: boardRoot,
|
||||
kind: .lane,
|
||||
order: order,
|
||||
operation: .delete(title: nil),
|
||||
removingLegacyKey: false
|
||||
@@ -1117,29 +1156,39 @@ public enum BoardWriter: Sendable {
|
||||
inBoard boardRoot: URL,
|
||||
order: Double
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try moveCardIntoTrash(
|
||||
try moveIntoTrash(
|
||||
at: cardFolder,
|
||||
inBoard: boardRoot,
|
||||
kind: .card,
|
||||
order: order,
|
||||
operation: .migrateTombstone(title: nil),
|
||||
removingLegacyKey: true
|
||||
)
|
||||
}
|
||||
|
||||
/// The shared body of `deleteCardToTrash` and `migrateTombstonedCard` — see the former for the
|
||||
/// sequence and the latter for what `removingLegacyKey` adds.
|
||||
private static func moveCardIntoTrash(
|
||||
at cardFolder: URL,
|
||||
/// The shared body of the three moves into `.trash/` — `deleteCardToTrash`, `deleteLaneToTrash`
|
||||
/// and `migrateTombstonedCard`. See the first for the sequence, the second for what a lane's
|
||||
/// `kind` is doing here, and the third for what `removingLegacyKey` adds.
|
||||
///
|
||||
/// **`kind` is both the guard and the stamp**: it picks which shape check the item must pass on
|
||||
/// the way out, and it is handed to `updateIndex` so the arrived entry's `kind` is written from
|
||||
/// what the caller *moved* rather than guessed from what the flat container makes it look like.
|
||||
private static func moveIntoTrash(
|
||||
at itemFolder: URL,
|
||||
inBoard boardRoot: URL,
|
||||
kind: IntegrityRules.ObjectKind,
|
||||
order: Double,
|
||||
operation initialOperation: WriteOperation,
|
||||
removingLegacyKey: Bool
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
var operation = initialOperation
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||
try checkIsDirectory(itemFolder, describedAs: kind == .lane ? "lane folder" : "card folder", operation: operation)
|
||||
try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation)
|
||||
try checkIsCardFolder(cardFolder, operation: operation)
|
||||
operation = try checkIndexIsRewritable(inItemFolder: cardFolder, operation: operation)
|
||||
switch kind {
|
||||
case .lane: try checkIsLaneFolder(itemFolder, operation: operation)
|
||||
case .card, .board: try checkIsCardFolder(itemFolder, operation: operation)
|
||||
}
|
||||
operation = try checkIndexIsRewritable(inItemFolder: itemFolder, operation: operation)
|
||||
|
||||
let trash = trashFolder(inBoard: boardRoot)
|
||||
do {
|
||||
@@ -1152,23 +1201,23 @@ public enum BoardWriter: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
let name = cardFolder.lastPathComponent
|
||||
let name = itemFolder.lastPathComponent
|
||||
let arrived = trash.appendingPathComponent(name, isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.moveItem(at: cardFolder, to: arrived)
|
||||
try FileManager.default.moveItem(at: itemFolder, to: arrived)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: cardFolder.path,
|
||||
path: itemFolder.path,
|
||||
reason: .io(message: "could not move folder into the trash: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
// A delete is a move into `.trash/` on disk (01-storage-format.md § Deletion), so the
|
||||
// receipt is the move pair — and it reads correctly from either end: the board side sees an
|
||||
// absence where the card was, the shown-trash side sees an arrival where it went.
|
||||
EchoLedger.current?.recordMove(from: cardFolder, to: arrived)
|
||||
// absence where the item was, the shown-trash side sees an arrival where it went.
|
||||
EchoLedger.current?.recordMove(from: itemFolder, to: arrived)
|
||||
|
||||
try updateIndex(inItemFolder: arrived, operation: operation) { document in
|
||||
try updateIndex(inItemFolder: arrived, kind: kind, operation: operation) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(order))
|
||||
if removingLegacyKey {
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
@@ -1185,134 +1234,80 @@ public enum BoardWriter: Sendable {
|
||||
return ItemID(rawValue: name)
|
||||
}
|
||||
|
||||
/// **Migrating a legacy tombstoned lane**: the `deleted:` key is removed and the lane returns
|
||||
/// **live**, exactly where it always was (01-storage-format.md § Deletion: "a lane carrying
|
||||
/// `deleted:` returns live with the key removed and a notice — resurrection is the safe
|
||||
/// direction, nothing is destroyed by migration").
|
||||
///
|
||||
/// **Nothing moves and nothing is removed.** There is no lane trash to move it into, and
|
||||
/// destroying a lane the user may never have meant to lose is the one direction migration is
|
||||
/// forbidden to take. Its cards come back with it; any of *them* carrying their own
|
||||
/// `deleted:` key migrate on their own account, as ordinary tombstoned cards.
|
||||
///
|
||||
/// The lane's rank is untouched, so it returns to its own position among its siblings —
|
||||
/// position-perfect for the same reason the retired Put Back was: the folder never moved.
|
||||
///
|
||||
/// Refuses anything that is not a lane (`checkIsLaneFolder`): a card's migration is a move and
|
||||
/// has its own call, and pointing this at one would strip the key while leaving the card
|
||||
/// exactly where the tombstone had hidden it.
|
||||
public static func migrateTombstonedLane(at laneFolder: URL) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.migrateTombstone(title: nil)
|
||||
try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation)
|
||||
try checkIsLaneFolder(laneFolder, operation: operation)
|
||||
|
||||
try updateIndex(inItemFolder: laneFolder, operation: operation) { document in
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
}
|
||||
EchoLedger.current?.markHeal(at: laneFolder.appendingPathComponent(BoardLoader.indexFileName))
|
||||
}
|
||||
|
||||
/// **Deleting a lane is physical** — the folder and everything under it are removed
|
||||
/// (01-storage-format.md § Deletion; 03-board-ui.md § Trash: "Cards only. Lanes are never
|
||||
/// trashed"). There is no lane trash and no tombstone; the recovery net is native undo
|
||||
/// in-session and git history on git boards.
|
||||
///
|
||||
/// **Capture before you remove.** Undo restores a lane by replaying its bytes, which only
|
||||
/// works if someone is holding them — `captureSubtree(at:operation:)` is that primitive, and
|
||||
/// the pairing is the caller's (the undo step captures, then calls this). Deliberately not
|
||||
/// folded in here: a purge that always paid for a full tree read would make Empty Trash on a
|
||||
/// large board slow for a recovery nothing was going to use.
|
||||
///
|
||||
/// **A folder that is already gone is success**, `purgeItem`'s rule and for its reason: a
|
||||
/// Finder deletion converges on exactly the end state this produces, so there is nothing left
|
||||
/// to distinguish.
|
||||
///
|
||||
/// Refuses anything that is not a lane (`checkIsLaneFolder`) — a board root, a card, a stray,
|
||||
/// and notably a *trash card*, whose parent is `.trash/` rather than the board root.
|
||||
public static func removeLane(at laneFolder: URL) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.delete(title: nil)
|
||||
guard FileManager.default.fileExists(atPath: laneFolder.path) else { return }
|
||||
try checkIsLaneFolder(laneFolder, operation: operation)
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: laneFolder)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: laneFolder.path,
|
||||
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
// The absence marker — and it takes the lane's cards' receipts with it, which is what makes
|
||||
// the digest's implied-events rule and the ledger agree that this was one event.
|
||||
EchoLedger.current?.recordDeletion(at: laneFolder)
|
||||
}
|
||||
|
||||
/// Permanently removes one card from the trash — the trash's own **Delete**
|
||||
/// (03-board-ui.md § Trash: "on a trash card, Delete (⌫/⌘⌫) is permanent").
|
||||
/// Permanently removes one **entry** from the trash — the trash's own **Delete**
|
||||
/// (03-board-ui.md § Trash: "on a trash selection, Delete (⌫/⌘⌫) is permanent … in the trash it
|
||||
/// removes the folder").
|
||||
///
|
||||
/// `purgeItem` with the container checked: the folder must actually sit in this board's
|
||||
/// `.trash/`, so a mis-aimed permanent delete cannot reach a live card. `purgeItem`
|
||||
/// `.trash/`, so a mis-aimed permanent delete cannot reach a live item. `purgeItem`
|
||||
/// (unconstrained) is for the create-undo's own removal, not this call.
|
||||
///
|
||||
/// **A trashed lane purges whole, freight and all** (lanes joined the trash 2026-07-29): the
|
||||
/// removal is recursive, so "permanent delete … walks lane subtrees" needs no walk of its own
|
||||
/// here — what the confirmation must *count* before this runs is `TrashModel`'s job, off the
|
||||
/// snapshot. Which kind the entry is therefore never comes up: the container and the shape are
|
||||
/// the whole check, exactly as they were when only cards lived here.
|
||||
///
|
||||
/// An already-gone folder is success, `purgeItem`'s rule.
|
||||
public static func purgeTrashCard(at cardFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) {
|
||||
public static func purgeTrashEntry(at entryFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.purge(title: nil)
|
||||
guard FileManager.default.fileExists(atPath: cardFolder.path) else { return }
|
||||
guard FileManager.default.fileExists(atPath: entryFolder.path) else { return }
|
||||
|
||||
try checkIsUUIDShaped(cardFolder, operation: operation)
|
||||
guard isSameLocation(cardFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else {
|
||||
try checkIsUUIDShaped(entryFolder, operation: operation)
|
||||
guard isSameLocation(entryFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: cardFolder.path,
|
||||
path: entryFolder.path,
|
||||
reason: .unreadable(message: "folder is not in this board's trash")
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: cardFolder)
|
||||
try FileManager.default.removeItem(at: entryFolder)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: cardFolder.path,
|
||||
path: entryFolder.path,
|
||||
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
EchoLedger.current?.recordDeletion(at: cardFolder)
|
||||
EchoLedger.current?.recordDeletion(at: entryFolder)
|
||||
}
|
||||
|
||||
/// **Empty Trash** (⇧⌘⌫, 03-board-ui.md § Trash): permanently removes every card in
|
||||
/// `<board-root>/.trash/`. Returns what it removed, in folder-name order.
|
||||
/// **Empty Trash** (⇧⌘⌫, 03-board-ui.md § Trash): permanently removes every entry in
|
||||
/// `<board-root>/.trash/` — cards and trashed lanes alike, **lane subtrees walked** by the
|
||||
/// recursive removal. Returns what it removed, in folder-name order.
|
||||
///
|
||||
/// **The card folders, not the container.** The design says it "purges the whole `.trash/`",
|
||||
/// and the cards are the whole of it in every board the app produces — but the container is a
|
||||
/// **The entry folders, not the container.** The design says it "purges the whole `.trash/`",
|
||||
/// and the entries are the whole of it in every board the app produces — but the container is a
|
||||
/// real folder a hand-editor can put things in, and stray tolerance ("preserved verbatim,
|
||||
/// never rendered") does not stop applying because the folder is the app's. Removing only what
|
||||
/// the loader recognizes as a card keeps the count honest (the confirmation names cards) and
|
||||
/// keeps this command from being the one place in the app that destroys a file nobody ever
|
||||
/// saw. The emptied container is left standing; the next delete would only recreate it.
|
||||
/// the loader recognizes as an entry keeps the count honest (the confirmation names cards and
|
||||
/// lane freight) and keeps this command from being the one place in the app that destroys a file
|
||||
/// nobody ever saw. The emptied container is left standing; the next delete would only recreate
|
||||
/// it.
|
||||
///
|
||||
/// **Search-independent**, by construction: this walks the folder, never a filtered view.
|
||||
///
|
||||
/// Removal is per card, in order, and a failure stops the batch and throws — everything
|
||||
/// Removal is per entry, in order, and a failure stops the batch and throws — everything
|
||||
/// already removed stays removed, `importAttachments`' rule. A board with no trash at all
|
||||
/// removes nothing and returns `[]`.
|
||||
@discardableResult
|
||||
public static func emptyTrash(inBoard boardRoot: URL) throws(BoardWriteError) -> [ItemID] {
|
||||
let operation = WriteOperation.purge(title: nil)
|
||||
var purged: [ItemID] = []
|
||||
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
||||
for entry in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: card)
|
||||
try FileManager.default.removeItem(at: entry)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: card.path,
|
||||
path: entry.path,
|
||||
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
EchoLedger.current?.recordDeletion(at: card)
|
||||
purged.append(ItemID(rawValue: card.lastPathComponent))
|
||||
EchoLedger.current?.recordDeletion(at: entry)
|
||||
purged.append(ItemID(rawValue: entry.lastPathComponent))
|
||||
}
|
||||
return purged
|
||||
}
|
||||
@@ -1336,206 +1331,6 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subtree capture and replay
|
||||
|
||||
/// Every byte of a folder tree, in memory — the capture half of a physical removal's undo
|
||||
/// (13-native-undo.md ▸ Rules; 03-board-ui.md § Trash, "the net is undo, not the trash").
|
||||
///
|
||||
/// **`readIndexText(ofItem:operation:)` widened from one file to a whole tree**, and for the
|
||||
/// same reason: the inverse of a physical removal is a recreation, and the only way ⌘Z can put
|
||||
/// a lane back *with its identity and its cards* is for the step to be holding what was there.
|
||||
/// One `index.md` is enough to replay a create; a lane delete takes its nested cards, their
|
||||
/// `attachments/`, and every stray with it.
|
||||
///
|
||||
/// **Everything, verbatim.** Unlike every other walk in this file, this one does *not* apply
|
||||
/// the loader's stray exclusions: hidden files (`.DS_Store`, a dot-file a hand-editor left),
|
||||
/// non-UUID folders, files with no meaning to the schema — all captured, because the promise is
|
||||
/// that undo restores what was removed rather than what the app would have rendered. Bytes are
|
||||
/// carried as `Data` and never decoded, so encoding, line endings and BOMs are non-questions;
|
||||
/// POSIX permissions ride along per entry.
|
||||
///
|
||||
/// **Symlinks are captured as links, never followed** (01-storage-format.md § Fractal layout ▸
|
||||
/// Rules) — the destination string is recorded and recreated as a link, so a cyclic or
|
||||
/// cross-volume link is neither traversed here nor materialized as a copy of its target.
|
||||
///
|
||||
/// Entries are sorted by name at every level, so a capture is a deterministic value: two
|
||||
/// captures of one unchanged tree are `==`, which is what makes a round-trip assertable.
|
||||
///
|
||||
/// It reads the whole tree into memory, so it is for the sizes a board actually has (a lane
|
||||
/// and its cards) — not a general-purpose archiver.
|
||||
public static func captureSubtree(
|
||||
at folder: URL,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) -> SubtreeSnapshot {
|
||||
try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
|
||||
|
||||
let entries: [URL]
|
||||
do {
|
||||
entries = try FileManager.default.contentsOfDirectory(
|
||||
at: folder,
|
||||
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
|
||||
options: []
|
||||
)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: folder.path,
|
||||
reason: .unreadable(message: "could not list folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
|
||||
var captured: [SubtreeSnapshot.Entry] = []
|
||||
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
||||
let name = entry.lastPathComponent
|
||||
let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
|
||||
|
||||
if values?.isSymbolicLink == true {
|
||||
guard let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: entry.path) else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: entry.path,
|
||||
reason: .unreadable(message: "could not read symbolic link")
|
||||
)
|
||||
}
|
||||
captured.append(.symlink(name: name, destination: destination))
|
||||
} else if values?.isDirectory == true {
|
||||
captured.append(.folder(try captureSubtree(at: entry, operation: operation)))
|
||||
} else {
|
||||
let contents: Data
|
||||
do {
|
||||
contents = try Data(contentsOf: entry)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: entry.path,
|
||||
reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
captured.append(.file(name: name, contents: contents, permissions: posixPermissions(of: entry)))
|
||||
}
|
||||
}
|
||||
|
||||
return SubtreeSnapshot(
|
||||
name: folder.lastPathComponent,
|
||||
permissions: posixPermissions(of: folder),
|
||||
entries: captured
|
||||
)
|
||||
}
|
||||
|
||||
/// Puts a captured tree back, at `folder`, byte for byte — the replay half of
|
||||
/// `captureSubtree(at:operation:)`, and `recreateItem`'s rules one level of nesting wider.
|
||||
///
|
||||
/// - **The parent must already exist** (`withIntermediateDirectories: false`): a redo whose
|
||||
/// board root has since gone must fail rather than conjure a tree in mid-air.
|
||||
/// - **It refuses to clobber**: anything at `folder` fails loudly rather than being written
|
||||
/// over. Restoring on top of a folder someone recreated meanwhile would silently merge two
|
||||
/// trees.
|
||||
/// - **The bytes are written verbatim** — nothing is stamped, nothing is re-serialized, no
|
||||
/// `index.md` is parsed. This replays; it does not edit.
|
||||
/// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows,
|
||||
/// `copyItem`'s rule — a half-restored lane is pure residue, since nothing was there.
|
||||
///
|
||||
/// `folder`'s own name governs, not `snapshot.name`: a caller restoring to the path it removed
|
||||
/// passes the same URL, and the snapshot's name is carried for identification, not as an
|
||||
/// instruction.
|
||||
///
|
||||
/// Permissions are applied **after** a folder's children are written, so a captured read-only
|
||||
/// directory does not lock out its own contents on the way back in.
|
||||
public static func recreateSubtree(
|
||||
at folder: URL,
|
||||
from snapshot: SubtreeSnapshot,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) {
|
||||
guard !FileManager.default.fileExists(atPath: folder.path) else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: folder.path,
|
||||
reason: .io(message: "something already exists here")
|
||||
)
|
||||
}
|
||||
do throws(BoardWriteError) {
|
||||
try materialize(snapshot, at: folder, operation: operation, intermediates: false)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: folder)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// `recreateSubtree`'s recursion, minus its clobber refusal and its cleanup — both belong to
|
||||
/// the top-level call, which is the only one with a partial tree to remove.
|
||||
private static func materialize(
|
||||
_ snapshot: SubtreeSnapshot,
|
||||
at folder: URL,
|
||||
operation: WriteOperation,
|
||||
intermediates: Bool
|
||||
) throws(BoardWriteError) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: intermediates)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: folder.path,
|
||||
reason: .io(message: "could not create folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
|
||||
for entry in snapshot.entries {
|
||||
switch entry {
|
||||
case let .file(name, contents, permissions):
|
||||
let fileURL = folder.appendingPathComponent(name)
|
||||
do {
|
||||
try contents.write(to: fileURL)
|
||||
// An undo restore recreates whole subtrees byte for byte; the bytes are already
|
||||
// in hand, so every restored file gets its own receipt rather than only the
|
||||
// `index.md` at the top.
|
||||
EchoLedger.current?.recordWrite(at: fileURL, data: contents)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: fileURL.path,
|
||||
reason: .io(message: "could not write file: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
setPosixPermissions(permissions, of: fileURL)
|
||||
case let .folder(child):
|
||||
try materialize(
|
||||
child,
|
||||
at: folder.appendingPathComponent(child.name, isDirectory: true),
|
||||
operation: operation,
|
||||
intermediates: false
|
||||
)
|
||||
case let .symlink(name, destination):
|
||||
let linkURL = folder.appendingPathComponent(name)
|
||||
do {
|
||||
try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: destination)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: linkURL.path,
|
||||
reason: .io(message: "could not create symbolic link: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After the children, so a captured read-only folder cannot lock out its own contents.
|
||||
setPosixPermissions(snapshot.permissions, of: folder)
|
||||
}
|
||||
|
||||
/// An item's POSIX permission bits, or `nil` when they cannot be read — `attributesOfItem`
|
||||
/// rather than a `URLResourceValues` key because it is `lstat`-based, so a symlink's own
|
||||
/// attributes are never its target's. Best-effort by design: permissions decorate a capture,
|
||||
/// and a tree that restores with default modes is a far better outcome than one that refuses
|
||||
/// to restore.
|
||||
private static func posixPermissions(of url: URL) -> Int? {
|
||||
(try? FileManager.default.attributesOfItem(atPath: url.path))?[.posixPermissions] as? Int
|
||||
}
|
||||
|
||||
private static func setPosixPermissions(_ permissions: Int?, of url: URL) {
|
||||
guard let permissions else { return }
|
||||
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
/// Physical removal — the create-undo's own primitive (13-native-undo.md: "create → remove the
|
||||
/// created folder"): deletes the folder tree from disk. Irreversible, and distinct from the
|
||||
/// ordinary delete, which is a *move* into `.trash/` — this call does **not** require the item to
|
||||
@@ -2569,46 +2364,6 @@ public struct MoveResult: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subtree vocabulary
|
||||
|
||||
/// A folder tree captured whole, in memory — what `BoardWriter.captureSubtree(at:operation:)`
|
||||
/// produces and `recreateSubtree(at:from:operation:)` replays.
|
||||
///
|
||||
/// **A value, deliberately**: `Sendable` so an undo step can carry it across isolation domains,
|
||||
/// and `Equatable` so a capture → recreate → capture round trip is one assertion. Equality is
|
||||
/// exact — names, bytes, link destinations, permissions, and order — which holds because a capture
|
||||
/// sorts every level by name.
|
||||
///
|
||||
/// It describes bytes, never meaning. There is no `index.md` here, no frontmatter, no identity:
|
||||
/// a lane, a card, an `attachments/` folder and a hand-made `notes/` are all just folders with
|
||||
/// entries, which is exactly what a byte-faithful restore needs and all it may assume.
|
||||
public struct SubtreeSnapshot: Sendable, Equatable {
|
||||
/// The captured folder's own name. Carried for identification and for nested folders' paths;
|
||||
/// the top-level replay takes its path from the caller instead (see `recreateSubtree`).
|
||||
public let name: String
|
||||
|
||||
/// POSIX permission bits as captured, `nil` when unreadable — applied best-effort on replay.
|
||||
public let permissions: Int?
|
||||
|
||||
/// The folder's direct children, sorted by name.
|
||||
public let entries: [Entry]
|
||||
|
||||
public init(name: String, permissions: Int?, entries: [Entry]) {
|
||||
self.name = name
|
||||
self.permissions = permissions
|
||||
self.entries = entries
|
||||
}
|
||||
|
||||
/// One captured child. Symlinks are their own case rather than a file holding their target's
|
||||
/// bytes — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules), so
|
||||
/// a capture records the link and a replay recreates the link.
|
||||
public enum Entry: Sendable, Equatable {
|
||||
case file(name: String, contents: Data, permissions: Int?)
|
||||
case folder(SubtreeSnapshot)
|
||||
case symlink(name: String, destination: String)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a copy stamps the files it materializes — the one axis on which the two kinds of copy
|
||||
/// differ (01-storage-format.md § Fractal layout ▸ Rules; § Frontmatter).
|
||||
public enum CopyStamps: Sendable {
|
||||
@@ -2665,8 +2420,9 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// the embedded `index.md` is kept for now that it is never a materialization source), `nil` for
|
||||
/// an untitled item.
|
||||
case paste(title: String?)
|
||||
/// ⌫ / ⌘⌫ — a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed
|
||||
/// outright (`removeLane`). The word the user pressed, whichever staging it took.
|
||||
/// ⌫ / ⌘⌫ — a card (`deleteCardToTrash`) or a lane (`deleteLaneToTrash`) moving into `.trash/`.
|
||||
/// One word for one gesture: since lanes rejoined the trash (2026-07-29) both are the same
|
||||
/// physical move, and neither is destructive.
|
||||
///
|
||||
/// **There is no `restore` case**: restoring is an ordinary move out (`moveItem`), so a failed
|
||||
/// restore says the app couldn't *move* the card — which is exactly what it couldn't do
|
||||
@@ -2674,15 +2430,14 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case delete(title: String?)
|
||||
case purge(title: String?)
|
||||
|
||||
/// A legacy `deleted:` key being migrated away — a card relocating into `.trash/` with the key
|
||||
/// removed, or a lane getting the key stripped and returning live (01-storage-format.md
|
||||
/// § Deletion, "Legacy `deleted:` keys migrate on load-and-write, never destroy").
|
||||
/// A legacy `deleted:` key being migrated away — a **card** relocating into `.trash/` with the
|
||||
/// key removed (01-storage-format.md § Deletion: "cards migrate, lanes ignore"; the lane half
|
||||
/// retired 2026-07-29 with the lane trash, so this operation has one performer left).
|
||||
///
|
||||
/// Its own case rather than a fold into `.delete` or `.move`, on the vocabulary's standing
|
||||
/// reasoning and `.relocateLooseFile`'s in particular: this is work the *app* started on its
|
||||
/// own, on a board an older version wrote, and a banner telling the user the app "couldn't
|
||||
/// delete 'Fix login'" would name a gesture they never made — on a lane, one whose outcome is
|
||||
/// the opposite of deletion.
|
||||
/// delete 'Fix login'" would name a gesture they never made.
|
||||
case migrateTombstone(title: String?)
|
||||
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
|
||||
case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane)
|
||||
|
||||
Reference in New Issue
Block a user