Materialize the trash — store, undo, and the container universe

Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 17:47:56 -04:00
parent 16c10d61c3
commit 53bc71f7fb
53 changed files with 3459 additions and 3655 deletions
+67 -73
View File
@@ -76,9 +76,9 @@ struct TransientBoardStateTests {
// Overlapping but different: card1 is selected and cut, card2 is selected and dragged,
// card3 is dragged and cut. Whatever happens to one member, two of the three sets are
// always the control.
store.transient.select([card1, card2], liveness: .live)
store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], liveness: .live)
store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], liveness: .live)
store.transient.select([card1, card2], in: .board)
store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], container: .board)
store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], container: .board)
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
await reload(store)
@@ -98,7 +98,7 @@ struct TransientBoardStateTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let seeded = ItemReferenceSet(ids: [card1, card2], liveness: .live)
let seeded = ItemReferenceSet(ids: [card1, card2], container: .board)
// Direction one the live search filter (04-interactions.md § Search): card1's title and
// body both miss the query, so it is not in the visible set the predicate produced, and
@@ -116,75 +116,71 @@ struct TransientBoardStateTests {
#expect(filtered == resolved, "one primitive, two universes — the two rules are one rule")
}
@Test("Tombstoning a lane ejects its cards from every referencing set — liveness is effective")
func effectiveLivenessEjectsFromEverySet() async throws {
@Test("A container crossing is a vanish for every item-referencing set")
func aContainerCrossingEjectsFromEverySet() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1, card2], liveness: .live)
store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], liveness: .live)
store.transient.select([card1, card2], in: .board)
store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], container: .board)
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
// A foreign writer moves card1 and card2 into the board's trash the move a delete is.
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
await reload(store)
// The cards' own flags never changed, but their lane's did and liveness is ancestor-walked
// (02, settled): they render nowhere once 03 collapses the lane to a single trash entry, and
// nothing invisible may stay selected or pending-cut.
let survivor = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }
#expect(survivor?.isDeleted == false, "the card's own flag is untouched")
// 02-architecture.md § Live-reload resilience, resettled 2026-07-28: "re-resolution matches
// UUID *and* container side a foreign move that trashes a selected board card ejects it
// from the selection (and from the pending cut)".
let trashedIDs = Set(store.snapshot.trash.map(\.id))
#expect(trashedIDs == Set([card1, card2]))
#expect(store.transient.selection.ids.isEmpty)
#expect(store.transient.pendingCut.ids == [card3], "card3's lane is untouched, so card3 stays cut")
#expect(store.transient.pendingCut.ids == [card3], "card3 never moved, so card3 stays cut")
}
@Test("A card with its own deleted: under a tombstoned lane is in neither universe")
func ownFlaggedCardUnderATombstonedLaneIsInNeitherUniverse() async throws {
@Test("A restore ejects a trash-side set the same way, in the other direction")
func restoringEjectsATrashSideSet() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3)
let store = try BoardStore(rootURL: fixture.root)
// Everything that can point at an item, all aimed at card3 and on the *trashed* side, as
// if the user had clicked its trash row a moment before its lane went too.
store.transient.select([card3], liveness: .trashed, anchor: card3, head: card3)
store.transient.dragMembers = ItemReferenceSet(ids: [card3], liveness: .trashed)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .trashed)
// Everything that can point at an item, all aimed at the trashed card.
store.transient.select([card3], in: .trash, anchor: card3, head: card3)
store.transient.dragMembers = ItemReferenceSet(ids: [card3], container: .trash)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .trash)
store.transient.beginRename(of: card3, currentTitle: "Third")
#expect(store.snapshot.trash.map(\.id) == [card3])
// Both flags at once: the card carries its own `deleted:` *and* an agent tombstones its lane.
try fixture.item("\(Ident.lane2)/\(Ident.card3)", tombstoned(order: "1024", title: "Third"))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
// An agent moves it back out a container crossing the other way.
try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3)
await reload(store)
let lane = try #require(store.snapshot.lanes.first { $0.id == lane2 })
#expect(lane.isDeleted)
#expect(lane.cards.first { $0.id == card3 }?.isDeleted == true, "the card's own flag is on disk")
#expect(store.snapshot.trash.isEmpty)
#expect(!ItemContainer.trash.ids(in: store.snapshot).contains(card3))
#expect(ItemContainer.board.ids(in: store.snapshot).contains(card3),
"presence is the whole test — the card is simply on the other side now")
// The lane's single entry subsumes it (03-board-ui.md's absolute ancestor walk), so it has
// no row and "the trashed side has exactly one definition: the set with trash rows"
// (02-architecture.md, settled). No row, no membership, on either side.
#expect(!TrashModel.entries(of: store.snapshot).map(\.id).contains(card3))
#expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .trashed).contains(card3))
#expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .live).contains(card3))
// So every set holding it is ejected from the trashed side here, and from the live side
// for the same reason, which the value function says directly since a set has one side.
#expect(store.transient.selection.ids.isEmpty)
#expect(store.transient.dragMembers.ids.isEmpty)
#expect(store.transient.pendingCut.ids.isEmpty)
#expect(ItemReferenceSet(ids: [card3], liveness: .live).resolved(against: store.snapshot).isEmpty)
// And no cursor or editor survives on it: an anchor that ranges from somewhere the board
// draws nowhere would be a range the user cannot see the origin of.
// And no cursor survives on it: an anchor that ranges from a container the selection has left
// would be a range the user cannot see the origin of.
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == nil)
#expect(store.transient.renameEditor == nil)
// Menu validation agrees, which is the point of the sets and the commands reading one rule:
// neither twin offers to act on it.
let stale = ItemReferenceSet(ids: [card3], liveness: .trashed)
#expect(!TrashModel.canActOnTrash(selection: stale, in: store.snapshot))
#expect(!TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], liveness: .live),
in: store.snapshot))
// The rename editor tracks the *board* container, so a card arriving back on the board keeps
// its editor "a foreign move mid-rename is invisible" (04 Grammar). It is the departure
// into the trash that discards it, which the delete tests cover.
#expect(store.transient.renameEditor?.targetID == card3)
// Menu validation agrees with the sets, which is the point of both reading one rule.
let stale = ItemReferenceSet(ids: [card3], container: .trash)
#expect(!TrashModel.canDelete(selection: stale, in: store.snapshot))
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], container: .board),
in: store.snapshot))
}
@Test("Every set resolves to empty against a board whose lanes all vanished")
@@ -193,9 +189,9 @@ struct TransientBoardStateTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1, lane1], liveness: .live)
store.transient.dragMembers = ItemReferenceSet(ids: [card2], liveness: .live)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .live)
store.transient.select([card1, lane1], in: .board)
store.transient.dragMembers = ItemReferenceSet(ids: [card2], container: .board)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .board)
store.transient.beginPlaceholder(inLane: lane2)
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
@@ -238,13 +234,12 @@ struct TransientBoardStateTests {
store.transient.beginPlaceholder(inLane: lane2)
store.transient.updateDraft("Half a title")
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store)
// Still in the snapshot the trash renders its single entry but the lane the editor was
// sitting in is not on the board any more, which is the same vanish as far as an overlay
// anchored to it is concerned.
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.isDeleted == true)
// A lane delete is physical (03-board-ui.md § Trash), so "the placeholder's lane vanished in
// the reload" is literally the whole test.
#expect(store.snapshot.lanes.first { $0.id == lane2 } == nil)
#expect(store.transient.newCardPlaceholder == nil)
}
@@ -376,37 +371,36 @@ struct TransientBoardStateTests {
#expect(store.transient.renameEditor == nil)
}
@Test("A rename whose target is tombstoned is discarded — a liveness flip is a vanish")
func renameDiscardedWhenItsTargetIsTombstoned() async throws {
@Test("A rename whose target enters the trash is discarded — a container crossing is a vanish")
func renameDiscardedWhenItsTargetIsTrashed() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginRename(of: card1, currentTitle: "First")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
await reload(store)
#expect(store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }?.isDeleted == true)
// 04 Grammar: "a target that is trashed, deleted, or gone at commit time discards the
// editor and its keystrokes silently entering the trash is a vanish from the board".
#expect(store.snapshot.trash.map(\.id) == [card1])
#expect(store.transient.renameEditor == nil)
}
@Test("A rename under a tombstoned lane is discarded too — liveness is effective")
func renameDiscardedWhenItsLaneIsTombstoned() async throws {
@Test("A rename whose lane is deleted is discarded too — the card went with it")
func renameDiscardedWhenItsLaneIsDeleted() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginRename(of: card1, currentTitle: "First")
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
await reload(store)
// The card's own flag never changed; its lane's did. The ancestor walk is absolute the
// card renders nowhere, so the editor sitting on it has no target
// (`CardWindowHost.cardWindowFate`'s rule, applied to the third inline editor).
let card = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }
#expect(card?.isDeleted == false)
// A lane delete is physical, so the card is simply not in the snapshot no ancestor walk.
#expect(store.snapshot.lanes.first { $0.id == lane1 } == nil)
#expect(store.transient.renameEditor == nil)
}
@@ -457,17 +451,17 @@ struct TransientBoardStateTests {
#expect(store.transient.lastActiveLaneID == nil, "a fresh board has no history to remember")
store.select([lane2], liveness: .live)
store.select([lane2], in: .board)
#expect(store.transient.lastActiveLaneID == lane2)
// A *card* selection is its lane holding selection too 04's "the lane that most recently
// held selection or a creation".
store.select([card1], liveness: .live)
store.select([card1], in: .board)
#expect(store.transient.lastActiveLaneID == lane1)
// A cross-lane selection names no single lane, so it leaves the memory alone rather than
// guessing at one of the two.
store.select([card1, card3], liveness: .live)
store.select([card1, card3], in: .board)
#expect(store.transient.lastActiveLaneID == lane1)
// Deselecting does not un-happen where the user was working: N with nothing selected is
@@ -485,11 +479,11 @@ struct TransientBoardStateTests {
store.transient.beginPlaceholder(inLane: lane2)
#expect(store.transient.lastActiveLaneID == lane2)
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store)
// A lane that renders nowhere is no target at all; `NewCardTarget` then falls through to
// the first lane rather than proposing the trash.
// A lane that is gone is no target at all; `NewCardTarget` then falls through to the first
// lane rather than proposing the trash.
#expect(store.transient.lastActiveLaneID == nil)
}