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
+48 -75
View File
@@ -105,18 +105,8 @@ private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane."))
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Fix login", body: "Auth.", deleted: "2026-03-05T10:00:00Z")
)
try fixture.item(
"\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Polish", body: "Wording.", deleted: "2026-03-05T06:00:00Z")
)
try fixture.item(
More.laneX,
tombstoned(order: "2048", title: "Old login lane", body: "Retired.", deleted: "2026-03-05T08:00:00Z")
)
try fixture.item(".trash/\(Ident.card1)", item(order: "1024", title: "Fix login", body: "Auth."))
try fixture.item(".trash/\(Ident.card2)", item(order: "2048", title: "Polish", body: "Wording."))
return fixture
}
@@ -238,17 +228,17 @@ struct SearchFilterPredicateTests {
#expect(!SearchFilter(query: "csv").matches(unrelated))
}
@Test("A lane matches by its own title and body — the trash's rows, not the board's lanes")
func lanesMatchByTheirOwnText() throws {
@Test("There is no lane predicate: the filter is a card predicate, end to end")
func lanesAreNeverFiltered() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let todo = try #require(model.lanes.first { $0.id == lane1 })
#expect(SearchFilter(query: "todo").matches(todo))
#expect(SearchFilter(query: "inbox").matches(todo))
// A lane is not matched through its cards: `card1` says "login", the lane does not.
#expect(!SearchFilter(query: "login").matches(todo))
// 04 § Search filters *cards*. Under the tombstone model the trash held lane *entries* that
// had to be matched like rows, which is why `matches(_: Lane)` existed; lanes are never
// trashed now, so the overload went with them and every lane is always visible.
let visible = SearchFilter(query: "nothing-matches-this").visibleIDs(in: model, container: .board)
#expect(visible == Set(model.lanes.map(\.id)))
}
@Test("The visible universe keeps every live lane and only the matching cards")
@@ -257,7 +247,7 @@ struct SearchFilterPredicateTests {
defer { fixture.tearDown() }
let model = try load(fixture)
let visible = SearchFilter(query: "login").visibleIDs(in: model, on: .live)
let visible = SearchFilter(query: "login").visibleIDs(in: model, container: .board)
// Lanes are never hidden by a card query a lane the filter empties is still a lane on the
// board, so a lane selection survives a query that empties its body.
#expect(visible.isSuperset(of: [lane1, lane2, lane3]))
@@ -277,12 +267,9 @@ struct SearchFilterOrderTests {
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.liveCards(in: model) == [card1, card2, card3, card4, card5])
#expect(SelectionGrammar.liveCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3])
#expect(SelectionGrammar.order(
of: .card,
on: .live,
in: model,
#expect(SelectionGrammar.boardCards(in: model) == [card1, card2, card3, card4, card5])
#expect(SelectionGrammar.boardCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3])
#expect(SelectionGrammar.order(of: .card, in: .board, snapshot: model,
filter: SearchFilter(query: "login")
) == [card1, card3])
}
@@ -295,10 +282,7 @@ struct SearchFilterOrderTests {
// `lane3`'s only card misses the query, and the lane is still in the order: the width
// division is layout, and a `0` badge is the honest report.
#expect(SelectionGrammar.order(
of: .lane,
on: .live,
in: model,
#expect(SelectionGrammar.order(of: .lane, in: .board, snapshot: model,
filter: SearchFilter(query: "login")
) == [lane1, lane2, lane3])
}
@@ -310,16 +294,11 @@ struct SearchFilterOrderTests {
let model = try load(fixture)
// Unfiltered, card1 card3 sweeps card2 up with it.
#expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, on: .live, in: model)
#expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, in: .board, snapshot: model)
== [card1, card2, card3])
// Filtered, the span between the same two endpoints is the two that are on screen.
#expect(SelectionGrammar.range(
from: card1,
to: card3,
kind: .card,
on: .live,
in: model,
#expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, in: .board, snapshot: model,
filter: SearchFilter(query: "login")
) == [card1, card3])
}
@@ -330,34 +309,31 @@ struct SearchFilterOrderTests {
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.range(
from: card1,
to: card2,
kind: .card,
on: .live,
in: model,
#expect(SelectionGrammar.range(from: card1, to: card2, kind: .card, in: .board, snapshot: model,
filter: SearchFilter(query: "login")
) == nil)
}
@Test("Trash entries filter like any lane — card rows and lane rows alike, by their own text")
func trashEntriesNarrow() throws {
@Test("Trash cards participate in the filter exactly like any other card")
func trashCardsNarrow() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.trashEntries(of: .card, in: model) == [card1, card2])
#expect(SelectionGrammar.trashEntries(of: .lane, in: model) == [laneX])
#expect(SelectionGrammar.trashCards(in: model) == [card1, card2])
#expect(SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")) == [card1])
// And there is no lane list in the trash at all "Cards only. Lanes are never trashed".
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model).isEmpty)
}
let filter = SearchFilter(query: "login")
#expect(SelectionGrammar.trashEntries(of: .card, in: model, filter: filter) == [card1])
// The lane row matches on its *own* title, not on the card buried inside it.
#expect(SelectionGrammar.trashEntries(of: .lane, in: model, filter: filter) == [laneX])
#expect(SelectionGrammar.trashEntries(
of: .lane,
in: model,
filter: SearchFilter(query: "polish")
).isEmpty)
@Test("The trash's visible universe is its matching cards")
func trashUniverseNarrows() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SearchFilter(query: "login").visibleIDs(in: model, container: .trash) == [card1])
#expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2])
}
@Test("The delete successor is drawn from what the lane is showing")
@@ -367,12 +343,12 @@ struct SearchFilterOrderTests {
let model = try load(fixture)
// Unfiltered, deleting the untitled card lands on its lane's next card.
#expect(SelectionGrammar.successor(afterDeleting: [card3], in: model) == card4)
#expect(SelectionGrammar.successor(afterDeleting: [card3], snapshot: model) == card4)
// Under `login`, card4 is hidden and there is nothing else visible in that lane, so the
// honest answer is nothing rather than a card the query animated out.
#expect(SelectionGrammar.successor(
afterDeleting: [card3],
in: model,
snapshot: model,
filter: SearchFilter(query: "login")
) == nil)
}
@@ -405,11 +381,11 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
store.select([card2], liveness: .trashed)
store.select([card2], in: .trash)
store.selectAll()
#expect(store.selection.ids == [card1, card2])
store.select([card1], liveness: .trashed)
store.select([card1], in: .trash)
store.searchQuery = "login"
store.selectAll()
#expect(store.selection.ids == [card1])
@@ -421,7 +397,7 @@ struct SearchFilterStoreTests {
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)
store.searchQuery = "login"
#expect(store.selection.ids == [card1])
@@ -437,7 +413,7 @@ struct SearchFilterStoreTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane3], liveness: .live)
store.select([lane3], in: .board)
store.searchQuery = "login"
#expect(store.selection.ids == [lane3])
}
@@ -449,12 +425,12 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.select([card1], in: .board)
store.clearSearch()
#expect(store.searchQuery.isEmpty)
#expect(store.selection.ids == [card1])
#expect(SelectionGrammar.liveCards(in: store.snapshot, filter: store.searchFilter).count == 5)
#expect(SelectionGrammar.boardCards(in: store.snapshot, filter: store.searchFilter).count == 5)
}
@Test("A reload landing under an active query re-applies the filter to the selection")
@@ -464,7 +440,7 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1, card3], liveness: .live, anchor: card1, head: card3)
store.select([card1, card3], in: .board, anchor: card1, head: card3)
// An agent edits the title out of the match. The card is still there this is not a vanish,
// so only the *filter's* universe can eject it.
@@ -502,7 +478,7 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.select([card1], in: .board)
store.transient.beginRename(of: card1, currentTitle: "Fix login")
#expect(store.searchQuery == "login")
@@ -526,7 +502,7 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.select([card1], in: .board)
store.transient.beginRename(of: card1, currentTitle: "Fix login")
store.transient.updateRenameDraft("Fix login again")
@@ -544,7 +520,7 @@ struct SearchFilterStoreTests {
// `Todo` shows both its cards under this query, so the successor is the visible neighbour.
store.searchQuery = "the"
store.select([card1], liveness: .live)
store.select([card1], in: .board)
store.delete([card1])
#expect(store.selection.ids == [card2])
}
@@ -572,7 +548,7 @@ struct SearchFilterExemptionTests {
let filter = SearchFilter(query: "login")
// Every live lane is in the visible universe, `Done` which holds only a miss included.
let visible = filter.visibleIDs(in: model, on: .live)
let visible = filter.visibleIDs(in: model, container: .board)
#expect(visible.isSuperset(of: [lane1, lane2, lane3]))
#expect(!visible.contains(card5))
@@ -623,7 +599,7 @@ struct SearchFilterExemptionTests {
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.select([card1], in: .board)
store.transient.beginRename(of: card1, currentTitle: "Fix login")
store.transient.updateRenameDraft("Fix login thoroughly")
@@ -644,7 +620,7 @@ struct SearchFilterExemptionTests {
#expect(try card(card1, in: store.snapshot).title.value == "Fix login thoroughly")
}
@Test("A vanish still discards the editor — the carve-out is the filter's, not liveness's")
@Test("A vanish still discards the editor — the carve-out is the filter's, not the container's")
func aVanishStillDiscardsTheEditor() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
@@ -652,10 +628,7 @@ struct SearchFilterExemptionTests {
store.searchQuery = "login"
store.transient.beginRename(of: card1, currentTitle: "Fix login")
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Fix login", body: "The auth flow breaks on retry.")
)
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
await reload(store)
#expect(store.transient.renameEditor == nil)