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
+78 -44
View File
@@ -267,28 +267,73 @@ struct DeleteCardTests {
}
}
// MARK: - Delete: a lane is physical
// MARK: - Delete: a lane takes the same move
@MainActor
@Suite("BoardStore ▸ delete a lane")
struct DeleteLaneTests {
@Test("Deleting a lane removes the folder and its contents — nothing is trashed")
func laneDeleteIsPhysical() throws {
/// 03-board-ui.md § Trash, re-ruled 2026-07-29: "deleting a lane moves its folder subtree
/// intact into `.trash/`, exactly as a card moves". The freight is inside the folder that
/// moved, so it is not filed separately and not surfaced.
@Test("Deleting a lane moves the folder into .trash/, subtree intact and opaque")
func laneDeleteIsAMoveIntoTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.delete([lane3])
// 03-board-ui.md § Trash: "Cards only. Lanes are never trashed deleting a lane deletes it,
// folder and contents, physically."
#expect(!fixture.exists(Ident.lane3))
#expect(!fixture.exists(".trash/\(Ident.card4)"), "its cards go with it, not into the trash")
#expect(try loaded(fixture).trash.map(\.id) == [newer, trashed], "the trash is untouched")
#expect(fixture.exists(".trash/\(Ident.lane3)"))
#expect(fixture.exists(".trash/\(Ident.lane3)/\(Ident.card4)"), "its cards ride along inside it")
#expect(!fixture.exists(".trash/\(Ident.card4)"), "and are never filed as trash cards")
let model = try loaded(fixture)
#expect(model.trash.map(\.id) == [newer, trashed], "the trash's cards are unchanged")
#expect(model.trashedLanes.map(\.id.rawValue) == [Ident.lane3])
#expect(model.trashedLanes.first?.heldCards == 1)
#expect(store.banners.oneShots.isEmpty)
}
/// "Every arrival lands at the trash's topmost position regardless of kind" (03 § Trash): the
/// ladder the rank is minted against is the whole container, cards and lane rows alike.
@Test("The lane lands at the top of the trash, above every existing entry")
func laneLandsOnTop() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let topBefore = try #require(loaded(fixture).trash.map(\.order).min())
store.delete([lane3])
let landed = try #require(loaded(fixture).trashedLanes.first?.order)
#expect(landed < topBefore)
// And the next card delete mints above *that* the lane row is in the ladder the store
// mints against, which is the whole container rather than one array of it. The reload is
// what puts the new row in the snapshot; without it the store is still holding the
// pre-delete picture, as it is for two of any deletes in a row.
await reload(store)
store.delete([card1])
let card = try #require(loaded(fixture).trash.first { $0.id == card1 }?.order)
#expect(card < landed)
}
/// No dialog: "the move is recoverable, so nothing needs confirming" (03 § Trash).
@Test("A lane delete raises no confirmation and needs none")
func laneDeleteIsNotConfirmed() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([lane3], in: .board)
confirmations.requestDelete(in: store)
#expect(confirmations.pending == nil)
#expect(fixture.exists(".trash/\(Ident.lane3)"), "it went straight through")
}
@Test("The selection moves to the successor lane")
func laneSuccessor() throws {
let fixture = try makeBoard()
@@ -344,7 +389,7 @@ struct StagedDeleteTests {
// The column's order is [newer, trashed].
store.select([newer], in: .trash)
store.deleteTrashCards([newer])
store.deleteTrashEntries([newer])
#expect(store.selection.ids == [trashed])
#expect(store.selection.container == .trash)
@@ -357,7 +402,7 @@ struct StagedDeleteTests {
let store = try BoardStore(rootURL: fixture.root)
store.select([newer, trashed], in: .trash)
store.deleteTrashCards([newer, trashed])
store.deleteTrashEntries([newer, trashed])
#expect(try loaded(fixture).trash.isEmpty)
#expect(store.selection.isEmpty)
@@ -369,7 +414,7 @@ struct StagedDeleteTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.deleteTrashCards([card1])
store.deleteTrashEntries([card1])
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(store.banners.oneShots.isEmpty)
@@ -432,8 +477,8 @@ struct PurgeTests {
#expect(store.purgeIsUnrecoverable)
store.select([trashed], in: .trash)
store.deleteTrashCards([trashed])
store.deleteTrashCards([newer])
store.deleteTrashEntries([trashed])
store.deleteTrashEntries([newer])
store.emptyTrash()
// 13-native-undo.md Rules: "Permanently delete (the trash's Delete, Empty Trash)
@@ -472,10 +517,11 @@ struct StoreTombstoneMigrationTests {
return fixture
}
@Test("Cards relocate into .trash/ with the key removed; lanes return live in place")
func migrationMovesCardsAndResurrectsLanes() throws {
@Test("Cards relocate into .trash/ with the key removed; a lane's key is never written to")
func migrationMovesCardsAndLeavesLanesAlone() throws {
let fixture = try makeLegacyBoard()
defer { fixture.tearDown() }
let laneBefore = try fixture.indexText(Ident.lane2)
let store = try BoardStore(rootURL: fixture.root)
store.migrateLegacyTombstones()
@@ -488,12 +534,12 @@ struct StoreTombstoneMigrationTests {
#expect(!(try fixture.indexText(".trash/\(Ident.card2)").contains("deleted:")))
#expect(!(try fixture.indexText(".trash/\(Ident.card3)").contains("deleted:")))
// The lane stayed exactly where it was, key stripped "resurrection is the safe direction,
// nothing is destroyed by migration".
#expect(fixture.exists(Ident.lane2))
#expect(!(try fixture.indexText(Ident.lane2).contains("deleted:")))
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"), "its cards come back with it")
#expect(try document(fixture, Ident.lane2).order.value == 2048, "at its own position")
// "A lane carrying `deleted:` simply loads live with the key ignored no migration
// machinery, no key-strip write" (01 § Deletion, re-ruled 2026-07-29): its bytes are
// untouched, `modified` included, and it renders as an ordinary lane.
#expect(try fixture.indexText(Ident.lane2) == laneBefore, "not one byte written")
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(try loaded(fixture).lanes.map(\.id.rawValue).contains(Ident.lane2))
}
@Test("Cards migrate oldest-first, so the newest deletion ends up on top")
@@ -554,7 +600,7 @@ struct StoreTombstoneMigrationTests {
#expect(store.banners.losses.isEmpty, "and nothing to announce")
}
@Test("The notice is one folded warning-tone row naming both halves")
@Test("The notice is one warning-tone row, and names only what was moved")
func theNotice() throws {
let fixture = try makeLegacyBoard()
defer { fixture.tearDown() }
@@ -563,8 +609,10 @@ struct StoreTombstoneMigrationTests {
store.migrateLegacyTombstones()
#expect(store.banners.losses.count == 1, "one migration, one row")
// No lane clause: nothing happened to the lane, so a row claiming otherwise would announce
// an act the app did not perform.
#expect(store.banners.losses.first?.message
== "Moved 2 cards to the trash and restored 'Retired' — they carried old deleted markers")
== "Moved 2 cards to the trash — they carried old deleted markers")
}
@Test("A board with nothing legacy migrates nothing and says nothing")
@@ -628,7 +676,7 @@ struct StoreTombstoneMigrationTests {
// read, so the channel empties and stays empty.
#expect(store.legacyTombstones.isEmpty)
#expect(store.snapshot.trash.count == 2)
#expect(store.snapshot.lanes.count == 2, "the resurrected lane is an ordinary lane again")
#expect(store.snapshot.lanes.count == 2, "the key-carrying lane was always an ordinary lane")
}
@Test("It is armed by the reload seam, exactly like the loose-file relocation")
@@ -659,31 +707,17 @@ struct MigrationNoticeTests {
@Test("One card names it; several fold to a count")
func cardsFold() {
#expect(BannerCenter.migratedTombstonesMessage(cards: ["Fix login"], lanes: [])
#expect(BannerCenter.migratedTombstonesMessage(cards: ["Fix login"])
== "Moved 'Fix login' to the trash — it carried an old deleted marker")
#expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"], lanes: [])
#expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"])
== "Moved 3 cards to the trash — they carried old deleted markers")
}
@Test("A lane reads as a restoration, which is what it is")
func lanesRead() {
#expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: ["Doing"])
== "Restored 'Doing' — it carried an old deleted marker")
#expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: ["A", "B"])
== "Restored 2 lanes — they carried old deleted markers")
}
@Test("Both halves fold into one sentence — one migration, one row")
func bothFold() {
#expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"], lanes: ["D", "E"])
== "Moved 3 cards to the trash and restored 2 lanes — they carried old deleted markers")
}
@Test("An untitled item reads as a rendering, and nothing migrated is not news")
func edges() {
#expect(BannerCenter.migratedTombstonesMessage(cards: [nil], lanes: [])
#expect(BannerCenter.migratedTombstonesMessage(cards: [nil])
== "Moved an untitled item to the trash — it carried an old deleted marker")
#expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: []) == nil)
#expect(BannerCenter.migratedTombstonesMessage(cards: []) == nil)
}
}
@@ -712,7 +746,7 @@ struct TrashConfirmationsTests {
store.select([trashed], in: .trash)
confirmations.requestDelete(in: store)
let pending = try #require(confirmations.pending)
#expect(pending.action == .deleteTrashCards([trashed]))
#expect(pending.action == .deleteTrashEntries([trashed]))
#expect(fixture.exists(".trash/\(Ident.indexless)"), "nothing has happened yet")
confirmations.confirm(in: store)
@@ -844,7 +878,7 @@ struct TrashMenuValidationTests {
confirmations.requestDelete(in: store)
// The trash staging: permanent, and behind the alert.
let pending = try #require(confirmations.pending)
#expect(pending.action == .deleteTrashCards([trashed]))
#expect(pending.action == .deleteTrashEntries([trashed]))
}
/// A context menu names its target by where it was invoked, so the trash row's Delete must purge
@@ -862,7 +896,7 @@ struct TrashMenuValidationTests {
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
#expect(pending.action == .deleteTrashCards([trashed]))
#expect(pending.action == .deleteTrashEntries([trashed]))
confirmations.confirm(in: store)
#expect(!fixture.exists(".trash/\(Ident.indexless)"))