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
+55 -12
View File
@@ -664,29 +664,63 @@ struct TrashUndoTests {
#expect(history.canUndo == false)
}
@Test("A lane delete is physical, and its undo recreates the folder byte for byte")
func laneDeleteRecreatesTheSubtree() throws {
/// 13 Interaction with the trash: "a lane [returns] to its strip position (subtree intact it
/// never left the folder)". The inverse is the ordinary move back, so the capture/recreate
/// machinery is retired: the lane's bytes never left the disk to need replaying.
@Test("A lane delete is a move into the trash, and its undo is the move back to its strip rank")
func laneDeleteRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let laneText = try fixture.indexText(Ident.lane2)
let laneBefore = try fixture.indexText(Ident.lane2)
let cardText = try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)")
let laneRank = try #require(try document(fixture, Ident.lane2).order.value)
store.delete([lane2])
#expect(!fixture.exists(Ident.lane2), "03 ▸ Trash: deleting a lane deletes it, physically")
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "lanes are never trashed")
#expect(!fixture.exists(Ident.lane2))
#expect(fixture.exists(".trash/\(Ident.lane2)"))
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "its cards are not trash cards")
#expect(try loadedTrashedLanes(fixture).map(\.id) == [lane2])
#expect(history.undoActionName == "Delete Lane")
let trashRank = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value)
let resident = try #require(try document(fixture, trashedPath).order.value)
#expect(trashRank < resident, "entry is at the top — a rank above the current topmost")
history.undo()
#expect(fixture.exists(Ident.lane2))
#expect(try fixture.indexText(Ident.lane2) == laneText, "the capture replays bytes, it does not edit")
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
#expect(try document(fixture, Ident.lane2).order.value == laneRank, "at its own strip position")
#expect(untouchedLines(try fixture.indexText(Ident.lane2)) == untouchedLines(laneBefore),
"byte-identical but for the stamps every container-changing move owns")
#expect(try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)") == cardText,
"and the whole subtree comes back with it — nested cards included")
"the subtree never moved relative to its lane, so not one nested byte changed")
history.redo()
#expect(!fixture.exists(Ident.lane2))
#expect(fixture.exists(".trash/\(Ident.lane2)"))
#expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == trashRank,
"the redo replays the write's own captured rank")
}
@Test("A multi-lane delete is one step with a plural title")
func batchLaneDeleteIsOneStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([lane1, lane2])
#expect(history.undoActionName == "Delete 2 Lanes")
let first = try #require(try document(fixture, ".trash/\(Ident.lane1)").order.value)
let second = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value)
#expect(second < first, "each arrival in the run mints a rank above the one before it")
history.undo()
#expect(fixture.exists(Ident.lane1))
#expect(fixture.exists(Ident.lane2))
#expect(try loadedTrashedLanes(fixture).isEmpty)
#expect(history.canUndo == false)
}
@Test("A restore-by-move-out registers as an ordinary Move, with the ordinary move inverse")
@@ -721,6 +755,11 @@ private func loadedTrash(_ fixture: WriterFixture) throws -> [Card] {
try BoardLoader.load(boardRoot: fixture.root).model.trash
}
@MainActor
private func loadedTrashedLanes(_ fixture: WriterFixture) throws -> [TrashedLane] {
try BoardLoader.load(boardRoot: fixture.root).model.trashedLanes
}
// MARK: - The Edit session
@MainActor
@@ -820,7 +859,7 @@ struct NotUndoableTests {
let armed = try #require(history.undoActionName)
store.select([trashed], in: .trash)
store.deleteTrashCards([trashed])
store.deleteTrashEntries([trashed])
#expect(fixture.exists(trashedPath) == false)
#expect(store.purgeIsUnrecoverable)
@@ -963,7 +1002,9 @@ private enum Foreign {
try fixture.move(path, toTrash: id)
}
/// A foreign delete of a **lane** physical, since lanes are never trashed.
/// A **Finder deletion** of a lane: the folder disappears entirely, which "is also a delete"
/// (01 § Deletion) such items never enter the trash, so this is the shape a foreign writer's
/// destructive removal has, distinct from the app's own delete (a move into `.trash/`).
static func removeLane(_ fixture: WriterFixture, _ path: String) throws {
try FileManager.default.removeItem(at: fixture.url(path))
}
@@ -1093,8 +1134,10 @@ struct StaleStepTests {
#expect(store.banners.signposts.count == 1)
}
/// The card's own path is the check: a lane delete is physical, so a card under it is simply not
/// there any more no ancestor walk, which is what materializing the trash bought.
/// The card's own path is the check: its lane's folder is gone, so the card is simply not there
/// any more no ancestor walk, which is what materializing the trash bought. (The same holds
/// when the app trashes the lane: the card's path moves under `.trash/` and nothing is left at
/// the one the step named.)
@Test("A card whose lane a foreign writer deleted is stale too")
func aCardUnderARemovedLaneIsStale() throws {
let fixture = try makeBoard()