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
+39 -48
View File
@@ -75,9 +75,9 @@ private func target(
width: CGFloat = 100,
height: CGFloat = 100,
kind: SelectionKind = .card,
side: Liveness = .live
container: ItemContainer = .board
) -> MarqueeTarget {
MarqueeTarget(id: id, kind: kind, side: side, frame: CGRect(x: x, y: y, width: width, height: height))
MarqueeTarget(id: id, kind: kind, container: container, frame: CGRect(x: x, y: y, width: width, height: height))
}
/// A two-by-two grid: `card1` `card3` on the top row, `card2` `card4` beneath them the smallest
@@ -145,7 +145,7 @@ struct NavigationMathTests {
@Test("The predicate is the ⇧-arrow's restriction — the trash side is simply not a candidate")
func predicateRestrictsCandidates() {
let trashed = target(card2, x: 0, y: 100, side: .trashed)
let trashed = target(card2, x: 0, y: 100, container: .trash)
let live = target(card3, x: 0, y: 400)
let all = [trashed, live]
@@ -154,7 +154,7 @@ struct NavigationMathTests {
"a plain arrow walks across the boundary"
)
#expect(
NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.side == .live }) == card3
NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.container == .board }) == card3
)
}
}
@@ -216,10 +216,10 @@ struct SuccessorTests {
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [card2], in: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card1], in: snapshot) == card2)
#expect(SelectionGrammar.successor(afterDeleting: [card2], snapshot: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card1], snapshot: snapshot) == card2)
#expect(
SelectionGrammar.successor(afterDeleting: [card1, card2], in: snapshot) == card3,
SelectionGrammar.successor(afterDeleting: [card1, card2], snapshot: snapshot) == card3,
"a block's successor is the first survivor after its last member"
)
}
@@ -230,8 +230,8 @@ struct SuccessorTests {
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [card4], in: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card3, card4], in: snapshot) == card2)
#expect(SelectionGrammar.successor(afterDeleting: [card4], snapshot: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card3, card4], snapshot: snapshot) == card2)
}
@Test("A survivor between the members is found forwards first")
@@ -242,7 +242,7 @@ struct SuccessorTests {
// Doomed at positions 0 and 2: forward from the last one finds card4, which is what makes
// repeated keep moving down rather than bouncing back up the lane.
#expect(SelectionGrammar.successor(afterDeleting: [card1, card3], in: snapshot) == card4)
#expect(SelectionGrammar.successor(afterDeleting: [card1, card3], snapshot: snapshot) == card4)
}
@Test("An emptied container selects nothing")
@@ -251,10 +251,10 @@ struct SuccessorTests {
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], in: snapshot) == nil)
#expect(SelectionGrammar.successor(afterDeleting: [], in: snapshot) == nil)
#expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], snapshot: snapshot) == nil)
#expect(SelectionGrammar.successor(afterDeleting: [], snapshot: snapshot) == nil)
#expect(
SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], in: snapshot) == nil,
SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], snapshot: snapshot) == nil,
"ids naming nothing name no container either"
)
}
@@ -267,7 +267,7 @@ struct SuccessorTests {
// card5 is later than card2 in flatten order (lane `order`, then card `order`), so the
// container is lane2 the same "last member" anchor N and paste already share.
#expect(SelectionGrammar.successor(afterDeleting: [card2, card5], in: snapshot) == card6)
#expect(SelectionGrammar.successor(afterDeleting: [card2, card5], snapshot: snapshot) == card6)
}
@Test("Lanes follow the same rule in the live lane order")
@@ -276,9 +276,9 @@ struct SuccessorTests {
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [lane1], in: snapshot) == lane2)
#expect(SelectionGrammar.successor(afterDeleting: [lane3], in: snapshot) == lane2, "the last lane's predecessor")
#expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], in: snapshot) == nil)
#expect(SelectionGrammar.successor(afterDeleting: [lane1], snapshot: snapshot) == lane2)
#expect(SelectionGrammar.successor(afterDeleting: [lane3], snapshot: snapshot) == lane2, "the last lane's predecessor")
#expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], snapshot: snapshot) == nil)
}
@Test("⌫ selects the successor immediately, before the reload echoes the tombstone back")
@@ -287,7 +287,7 @@ struct SuccessorTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card2], liveness: .live)
store.select([card2], in: .board)
store.deleteSelection()
#expect(store.selection.ids == [card3])
@@ -306,7 +306,7 @@ struct SuccessorTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card5, card6], liveness: .live)
store.select([card5, card6], in: .board)
store.deleteSelection()
#expect(store.selection.isEmpty)
@@ -324,13 +324,13 @@ struct SelectionHeadTests {
func headDefaults() throws {
let state = TransientBoardState()
state.select([card1], liveness: .live)
state.select([card1], in: .board)
#expect(state.selectionHead == card1)
state.select([card1, card2], liveness: .live)
state.select([card1, card2], in: .board)
#expect(state.selectionHead == nil, "a set with no gesture behind it names no cursor")
state.select([card1, card2], liveness: .live, anchor: card1, head: card2)
state.select([card1, card2], in: .board, anchor: card1, head: card2)
#expect(state.selectionAnchor == card1)
#expect(state.selectionHead == card2, "an explicit head is kept whatever the count")
@@ -346,9 +346,9 @@ struct SelectionHeadTests {
let snapshot = try load(fixture)
let outcome = SelectionGrammar.click(
SelectionTarget(id: card3, kind: .card, side: .live),
SelectionTarget(id: card3, kind: .card, container: .board),
modifier: .shift,
selection: ItemReferenceSet(ids: [card1], liveness: .live),
selection: ItemReferenceSet(ids: [card1], container: .board),
anchor: card1,
snapshot: snapshot
)
@@ -364,7 +364,7 @@ struct SelectionHeadTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live, anchor: card1, head: card2)
store.select([card1, card2], in: .board, anchor: card1, head: card2)
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
store.handleWatcherEvent(.treeChanged(.foreign))
@@ -375,25 +375,16 @@ struct SelectionHeadTests {
#expect(store.transient.selectionAnchor == card1, "the anchor survived — it is still in the tree")
}
@Test("A liveness flip is a vanish for the head too")
func resolveDropsALivenessFlippedHead() async throws {
@Test("A container crossing is a vanish for the head too")
func resolveDropsAContainerCrossedHead() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], liveness: .live)
store.select([card1], in: .board)
#expect(store.transient.selectionHead == card1)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", """
---
schema: 1
title: First
order: 1024
deleted: 2026-03-03T09:00:00Z
---
First body.
""")
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
@@ -416,7 +407,7 @@ struct SortWriteTests {
let untouchedFirst = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
let untouchedFourth = try fixture.indexText("\(Ident.lane1)/\(Ident.card4)")
store.select([card3], liveness: .live)
store.select([card3], in: .board)
store.sortSelection(.up)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card3, card2, card4])
@@ -435,7 +426,7 @@ struct SortWriteTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card2, card4], liveness: .live)
store.select([card2, card4], in: .board)
store.sortSelection(.up)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2, card4, card3])
@@ -447,7 +438,7 @@ struct SortWriteTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live)
store.select([card1, card2], in: .board)
store.sortSelection(.down)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card3, card1, card2, card4])
@@ -467,7 +458,7 @@ struct SortWriteTests {
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2])
store.select([card2], liveness: .live)
store.select([card2], in: .board)
store.sortSelection(.up)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card2, card1])
@@ -482,16 +473,16 @@ struct SortWriteTests {
#expect(store.sortPlan(.up) == nil, "nothing selected")
store.select([lane1], liveness: .live)
store.select([lane1], in: .board)
#expect(store.sortPlan(.up) == nil, "a lane selection — ⌥⌘↑/⌥⌘↓ are inert on lanes")
store.select([card2, card5], liveness: .live)
store.select([card2, card5], in: .board)
#expect(store.sortPlan(.up) == nil, "a card selection spanning lanes — cards never change lanes by ⌘-arrow")
store.select([card1], liveness: .trashed)
store.select([card1], in: .trash)
#expect(store.sortPlan(.up) == nil, "a tombstoned selection")
store.select([card1], liveness: .live)
store.select([card1], in: .board)
#expect(store.sortPlan(.up) == nil, "already at the top")
#expect(store.sortPlan(.down) != nil, "but the other direction is live")
}
@@ -513,7 +504,7 @@ struct MoveLaneConventionTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
let lanes = SelectionGrammar.lanes(in: store.snapshot)
#expect(lanes == [lane1, lane2, lane3])
let from = try #require(lanes.firstIndex(of: lane2))
@@ -527,7 +518,7 @@ struct MoveLaneConventionTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
let lanes = SelectionGrammar.lanes(in: store.snapshot)
let from = try #require(lanes.firstIndex(of: lane1))
store.moveLane(lane1, toIndex: from + 1)