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
+28 -28
View File
@@ -38,7 +38,6 @@ private func makeBoard() throws -> WriterFixture {
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Archive"))
return fixture
}
@@ -71,14 +70,14 @@ struct NewCardTargetTests {
// "With a card selected, the new card is created in that card's lane, immediately after it
// (paste-anchor consistency)."
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], liveness: .live))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board))
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card1))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], liveness: .live))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], container: .board))
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
// The last card in a lane is still an anchor here "after the last card" and "at the
// bottom" coincide, and it is the commit that notices (`BoardStore.insertionIndex`).
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card2], liveness: .live))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card2], container: .board))
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
}
@@ -89,7 +88,7 @@ struct NewCardTargetTests {
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// "With a lane selected, appended at its bottom (Return consistency)."
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane2], liveness: .live))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane2], container: .board))
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
}
@@ -107,25 +106,25 @@ struct NewCardTargetTests {
#expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
#expect(resolve(snapshot, lastActive: ItemID(rawValue: Ident.indexless))
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
// A *tombstoned* lane is not a target either it renders nowhere, and the trash is never a
// creation destination.
// A lane that has been deleted is not a target either a lane delete is physical, so the
// memory simply names nothing.
#expect(resolve(snapshot, lastActive: lane3)
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
}
@Test("A tombstoned selection never anchors creation — it behaves as nothing selected")
func aTombstonedSelectionNeverAnchors() throws {
@Test("A trash selection never anchors creation — it behaves as nothing selected")
func aTrashSelectionNeverAnchors() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// Settled in 04 The map, on the N rule's own wording: "a **tombstoned** selection, which
// never anchors creation". The trash-side lane is a real lane on disk with a live sibling
// list the rule must not let its identity leak in as a target.
let trashed = ItemReferenceSet(ids: [lane3], liveness: .trashed)
#expect(resolve(snapshot, selection: trashed, lastActive: lane2)
// 04 The map, on the N rule's own wording: "with nothing selected or a **trash**
// selection, which never anchors creation". A trashed card's board-side lane must not leak
// in as a target.
let inTrash = ItemReferenceSet(ids: [card1], container: .trash)
#expect(resolve(snapshot, selection: inTrash, lastActive: lane2)
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
#expect(resolve(snapshot, selection: trashed)
#expect(resolve(snapshot, selection: inTrash)
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
}
@@ -140,21 +139,22 @@ struct NewCardTargetTests {
// target New Card, Return-creation, and Paste with a card payload disable via menu
// validation until a lane exists."
#expect(resolve(empty) == nil)
#expect(resolve(empty, selection: ItemReferenceSet(ids: [card1], liveness: .live)) == nil)
#expect(resolve(empty, selection: ItemReferenceSet(ids: [card1], container: .board)) == nil)
#expect(resolve(empty, lastActive: lane1) == nil)
}
@Test("A board whose every lane is tombstoned is a zero-lane board")
func everyLaneTombstonedIsAlsoZeroLane() throws {
let fixture = try WriterFixture()
@Test("A board whose every lane has been deleted is a zero-lane board")
func everyLaneDeletedIsAlsoZeroLane() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
for lane in [Ident.lane1, Ident.lane2, Ident.lane3] where fixture.exists(lane) {
try FileManager.default.removeItem(at: fixture.url(lane))
}
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// The lanes are still in the snapshot the trash renders them but none is on the board,
// and "every lane deleted" is the design's own second reading of the zero-lane case.
#expect(snapshot.lanes.count == 1)
// "Every lane deleted" is the design's own second reading of the zero-lane case and a lane
// delete is physical now, so it is literally the same board as the hand-made one above.
#expect(snapshot.lanes.isEmpty)
#expect(resolve(snapshot) == nil)
}
@@ -167,15 +167,15 @@ struct NewCardTargetTests {
// "A multi-selection anchors at its last member in flatten order (lane `order`, then card
// `order`, the multi-drag order)": card3 lives in lane2, which sorts after lane1's pair, so
// creation follows card3 the last-active lane never enters into it.
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card3], liveness: .live), lastActive: lane1)
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card3], container: .board), lastActive: lane1)
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
// Within one lane the flatten order is card order: card2 sorts after card1.
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], liveness: .live), lastActive: lane2)
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], container: .board), lastActive: lane2)
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
// A multi-LANE selection appends to the last selected lane's bottom.
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1, lane2], liveness: .live))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1, lane2], container: .board))
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
}
@@ -187,7 +187,7 @@ struct NewCardTargetTests {
// A selection naming something the board does not render the reload that drops it has not
// landed yet must not refuse the creation the user just asked for.
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], liveness: .live))
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], container: .board))
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
}
}