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:
2026-07-30 16:16:57 -04:00
parent 785ef5fe14
commit 8014bde7c6
21 changed files with 1357 additions and 910 deletions
+11 -10
View File
@@ -35,7 +35,7 @@ final class TrashConfirmations {
/// reachable inside the trash now: the trash's own staged Delete, and Empty Trash (which names
/// the whole container and re-derives its targets at the moment it runs).
enum Action: Equatable {
case deleteTrashCards(Set<ItemID>)
case deleteTrashEntries(Set<ItemID>)
case emptyTrash
}
}
@@ -43,8 +43,8 @@ final class TrashConfirmations {
/// **File Delete, staged by place** (04-interactions.md The map) with the confirmation the
/// trash side owes and the board side does not.
///
/// A board selection goes straight through: moving a card into the trash and deleting a lane are
/// both recoverable (the trash itself, and native undo 03-board-ui.md § Trash), so neither
/// A board selection goes straight through: a card and a lane alike move into the trash, and the
/// move is recoverable (the trash itself, and native undo 03-board-ui.md § Trash), so neither
/// stands an alert. A **trash** selection is the permanent one, and it "confirms exactly where
/// the loss is real": `purgeIsUnrecoverable` decides.
///
@@ -71,7 +71,7 @@ final class TrashConfirmations {
/// is what a board that keeps history does for every permanent delete (delete-never-forgets).
func requestTrashDelete(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteTrashCards(ids)
store.deleteTrashEntries(ids)
return
}
guard let prompt = TrashModel.purgePrompt(
@@ -79,7 +79,7 @@ final class TrashConfirmations {
snapshot: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .deleteTrashCards(ids))
pending = Pending(prompt: prompt, action: .deleteTrashEntries(ids))
}
/// Raises Empty Trash's alert. **Always** it guards bulk scope rather than per-item
@@ -98,7 +98,7 @@ final class TrashConfirmations {
guard let pending else { return }
self.pending = nil
switch pending.action {
case let .deleteTrashCards(ids): store.deleteTrashCards(ids)
case let .deleteTrashEntries(ids): store.deleteTrashEntries(ids)
case .emptyTrash: store.emptyTrash()
}
}
@@ -168,9 +168,10 @@ struct TrashCommands: View {
extension BoardStore {
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for the Empty Trash row)
/// where "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash:
/// "menu validation's non-empty reads `.trash/`, not the filtered view", so a search that hides
/// every trash card leaves the command enabled and its confirmation still names the true count).
/// where "non-empty" reads `.trash/` itself **both kinds of entry**, and never the filtered
/// view (03-board-ui.md § Trash: "menu validation's non-empty reads `.trash/`, not the filtered
/// view", so a search that hides every trash row leaves the command enabled and its confirmation
/// still names the true count, lane freight included).
///
/// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is
/// invisible to every gesture".
@@ -179,7 +180,7 @@ extension BoardStore {
/// menu (`TrashModel`'s own reason for being a pure function of a snapshot).
var canEmptyTrash: Bool {
guard acceptsBoardMutations, transient.isTrashVisible else { return false }
return !snapshot.trash.isEmpty
return !snapshot.trash.isEmpty || !snapshot.trashedLanes.isEmpty
}
}