Lanes delete into the trash — rendering, grammar, drag, clipboard, a11y
Phase 2 completes the lanes-in-trash card. TrashEntry merges the trash's two kinds by rank in exactly ONE place (ItemPath.resolve's own merge deleted in favor of it — the three-merge-points finding shrinks instead of growing). TrashLaneRowView renders the opaque row — tertiary plate, level-default lane glyph never the lane's own icon, title + card count, no accents, no expansion; the column badge counts rendered rows. Selection grammar: kind-homogeneous trash selections — ranges skip the other kind, ⇧-extension stops at the kind boundary, plain arrows walk the merged order, marquee stays card-only (now load-bearing: rows register frames for arrows), Select All card-scoped; successor-on-purge crosses kinds like navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop accepts lane sessions (drop on shown trash deletes), restoreLanes routes a trash-sourced strip drop as an arrival-ranked within-board move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as the root — a same-board restore looked like an import and would have reminted the lane it was restoring (pinned by test). A11y: row = one flattened "title, deleted lane, N cards" element with Delete/Reveal actions; BoardDiff crossings read lanes as deleted/restored, shown-trash churn digested at row level. Agent guide stays v7 — the literal already teaches lanes-trash-by-move and kind stamping; drift-guard pins those lines. README trash paragraph notes lanes. Both schemes 1893 tests / 322 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -96,16 +96,24 @@ private func makeBoard() throws -> WriterFixture {
|
||||
return fixture
|
||||
}
|
||||
|
||||
/// A trash holding both row kinds, one of each matching `login` — the shape "participates in the
|
||||
/// filter like any lane" needs, since a lane entry is filtered by its *own* title and body.
|
||||
/// A trash holding **both row kinds**, interleaved by rank — the shape the column's own filter rule
|
||||
/// needs (03-board-ui.md § Trash, lanes rejoined 2026-07-29: "The row matches the search filter by
|
||||
/// lane title only").
|
||||
///
|
||||
/// `TrashModel`'s sort is newest first, so the row order is `[card1, laneX, card2]`.
|
||||
/// `laneX`'s body says `login` and its title does not, which is what makes the title-only rule
|
||||
/// checkable rather than merely stated: a query of `login` leaves `card1` alone.
|
||||
///
|
||||
/// The ranks put the lane row between the two cards, so the merged order is `[card1, laneX, card2]`.
|
||||
@MainActor
|
||||
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(".trash/\(Ident.card1)", item(order: "1024", title: "Fix login", body: "Auth."))
|
||||
try fixture.item(
|
||||
".trash/\(More.laneX)",
|
||||
"---\nschema: 1\ntitle: Archive\norder: 1536\nkind: lane\n---\nOld login notes.\n"
|
||||
)
|
||||
try fixture.item(".trash/\(Ident.card2)", item(order: "2048", title: "Polish", body: "Wording."))
|
||||
return fixture
|
||||
}
|
||||
@@ -322,18 +330,34 @@ struct SearchFilterOrderTests {
|
||||
|
||||
#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)
|
||||
}
|
||||
|
||||
@Test("The trash's visible universe is its matching cards")
|
||||
/// "The row matches the search filter by lane title only" (03-board-ui.md § Trash) — the opaque
|
||||
/// unit's own rule, and the one place a lane *is* filtered: `laneX`'s body carries the query and
|
||||
/// the row still leaves the column, because its body is not on screen.
|
||||
@Test("A trashed lane row filters by title alone, and its own list is kind-scoped")
|
||||
func trashLaneRowsFilterByTitle() throws {
|
||||
let fixture = try makeTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let model = try load(fixture)
|
||||
|
||||
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model) == [laneX])
|
||||
#expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "archive")) == [laneX])
|
||||
// The body says "login". The row does not.
|
||||
#expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "login")).isEmpty)
|
||||
// And the kind-scoped lists are disjoint slices of one rank order, which is what makes a
|
||||
// ⇧-range skip the other kind (04-interactions.md ▸ The trash).
|
||||
#expect(SelectionGrammar.trashRows(in: model) == [card1, laneX, card2])
|
||||
}
|
||||
|
||||
@Test("The trash's visible universe is its matching rows, both kinds")
|
||||
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])
|
||||
#expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2, laneX])
|
||||
}
|
||||
|
||||
/// The trash *column* narrows through the same predicate, which is the whole of "shown, its cards
|
||||
@@ -347,15 +371,17 @@ struct SearchFilterOrderTests {
|
||||
defer { fixture.tearDown() }
|
||||
let model = try load(fixture)
|
||||
|
||||
#expect(TrashLaneView.rendered(model.trash, filter: .inactive).map(\.id) == [card1, card2])
|
||||
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "login")).map(\.id) == [card1])
|
||||
// Both kinds, interleaved purely by rank (03-board-ui.md § Trash).
|
||||
#expect(TrashLaneView.rendered(model, filter: .inactive).map(\.id) == [card1, laneX, card2])
|
||||
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "login")).map(\.id) == [card1])
|
||||
// The column and the arrow grammar cannot disagree about what is on screen.
|
||||
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "login")).map(\.id)
|
||||
== SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")))
|
||||
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "login")).map(\.id)
|
||||
== SelectionGrammar.trashRows(in: model, filter: SearchFilter(query: "login")))
|
||||
// A query nothing matches empties the column without emptying the container — which is why
|
||||
// Empty Trash's validation reads `.trash/` and not this list.
|
||||
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "zzzz")).isEmpty)
|
||||
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "zzzz")).isEmpty)
|
||||
#expect(!model.trash.isEmpty)
|
||||
#expect(!model.trashedLanes.isEmpty)
|
||||
}
|
||||
|
||||
@Test("The delete successor is drawn from what the lane is showing")
|
||||
|
||||
Reference in New Issue
Block a user