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