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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user