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:
2026-07-30 17:04:30 -04:00
parent 8014bde7c6
commit f7c8088783
26 changed files with 1825 additions and 176 deletions
+222
View File
@@ -21,6 +21,22 @@ private enum More {
static let newer = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
}
/// A lane already sitting in `<root>/.trash/` the opaque unit, `kind: lane` being the only thing
/// that tells it from a card in the flat container (01-storage-format.md § Deletion).
private func trashedLane(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
kind: lane
project: lanework # agent overlay
---
\(title) body.
"""
}
/// A card already sitting in `<root>/.trash/` an ordinary card in a special place, with an unknown
/// key so the verbatim-preservation claims have something to preserve.
private func trashResident(order: String, title: String) -> String {
@@ -346,6 +362,33 @@ struct DeleteLaneTests {
#expect(store.selection.ids == [lane3])
}
/// "Dropping a live card **or lane** on the shown trash deletes it" (04-interactions.md The
/// trash, lanes extended 2026-07-29): the drop writes exactly what writes, and like the card
/// gesture says nothing about the selection, because a drag has no keystroke to keep repeatable
/// and its run is not necessarily the selection at all.
@Test("Drop-on-trash writes exactly what ⌫ writes at the lane level, and leaves the selection alone")
func laneDropOnTrashMatchesTheKeystroke() throws {
let byKey = try makeBoard()
defer { byKey.tearDown() }
let byDrag = try makeBoard()
defer { byDrag.tearDown() }
let keyStore = try BoardStore(rootURL: byKey.root)
let dragStore = try BoardStore(rootURL: byDrag.root)
dragStore.select([card1], in: .board)
keyStore.delete([lane3])
dragStore.deleteLanesByDrag(laneIDs: [lane3])
// Same folder, same subtree, same rank `moveLanesToTrash` is the one write both take.
#expect(byDrag.exists(".trash/\(Ident.lane3)/\(Ident.card4)"))
#expect(try untouchedLines(byDrag.indexText(".trash/\(Ident.lane3)"))
== untouchedLines(byKey.indexText(".trash/\(Ident.lane3)")))
#expect(try loaded(byDrag).trashedLanes.map(\.order) == loaded(byKey).trashedLanes.map(\.order))
// moved the selection to the successor lane; the drag left it exactly where it was.
#expect(dragStore.selection.ids == [card1])
#expect(dragStore.banners.oneShots.isEmpty)
}
@Test("A set naming both a lane and a card acts on the lane — the selection is cards XOR lanes")
func lanesWinAMixedSet() throws {
let fixture = try makeBoard()
@@ -488,6 +531,185 @@ struct PurgeTests {
}
}
// MARK: - The trash's other kind of row
/// A board whose trash holds **both kinds, interleaved by rank**: a card on top, then a lane row
/// carrying two cards, then a second card (03-board-ui.md § Trash, lanes rejoined 2026-07-29).
///
/// The lane row sits between the two cards deliberately every claim below about crossing kinds
/// needs a row of the other kind on both sides of it.
@MainActor
private func makeTrashedLaneBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item(".trash/\(More.newer)", trashResident(order: "512", title: "Newer"))
try fixture.item(".trash/\(Ident.lane2)", trashedLane(order: "768", title: "Doing"))
try fixture.item(".trash/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
try fixture.item(".trash/\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fourth"))
try fixture.item(".trash/\(Ident.indexless)", trashResident(order: "1024", title: "Trashed"))
return fixture
}
/// The permanent delete, the confirmation that stands in front of it, and the successor it leaves
/// on a **trashed lane row** (03-board-ui.md § Trash: "it restores whole or purges whole").
@MainActor
@Suite("BoardStore ▸ purging a trashed lane row")
struct PurgeTrashedLaneTests {
@Test("The row purges whole — its subtree with it — and registers no undo step")
func rowPurgesWhole() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
store.deleteTrashEntries([lane2])
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
#expect(!fixture.exists(".trash/\(Ident.lane2)/\(Ident.card3)"), "the freight went with it")
let model = try loaded(fixture)
#expect(model.trashedLanes.isEmpty)
#expect(model.trash.map(\.id) == [newer, trashed], "the column's cards are untouched")
// 13-native-undo.md Rules: "lanes and their freight included" the confirm is the safety.
#expect(!history.canUndo)
}
/// "Confirms name the freight honestly a trashed lane's alert counts its cards" (03 § Trash).
@Test("The row's confirmation names the lane and counts its cards")
func rowConfirmationCountsTheFreight() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([lane2], in: .trash)
confirmations.requestDelete(in: store)
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete lane \u{201C}Doing\u{201D} and its 2 cards?")
#expect(pending.action == .deleteTrashEntries([lane2]))
confirmations.confirm(in: store)
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
}
/// Empty Trash's own sentence, with lanes in the container: " 41 cards and 2 lanes containing 9
/// more cards" (03 § Trash) and the walk really removes the subtrees.
@Test("Empty Trash counts both kinds and walks the lane subtrees")
func emptyTrashCountsBothKinds() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
let confirmations = TrashConfirmations()
#expect(store.canEmptyTrash)
confirmations.requestEmptyTrash(in: store)
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete 2 cards and 1 lane containing 2 more cards?")
confirmations.confirm(in: store)
let model = try loaded(fixture)
#expect(model.trash.isEmpty)
#expect(model.trashedLanes.isEmpty)
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
}
/// **The interim successor** (`SelectionGrammar.successor`'s trash branch): 04 settles navigation
/// and extension for the column's two kinds but not the successor, so it follows *navigation*
/// the next row down whatever its kind rather than stranding the selection. Gap card
/// 7b5cbc90 tracks the ruling; this test is the interim's marker as much as its cover.
@Test("The successor after purging a lane row is the next row down, kind notwithstanding")
func successorCrossesKinds() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane2], in: .trash)
store.deleteTrashEntries([lane2])
// The column reads [newer, lane2, trashed]; forward-first lands on the card below.
#expect(store.selection == ItemReferenceSet(ids: [trashed], container: .trash))
}
@Test("A row purged from the bottom falls back to its predecessor, and an emptied column clears")
func successorFallsBackAndClears() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(".trash/\(More.newer)", trashResident(order: "512", title: "Newer"))
try fixture.item(".trash/\(Ident.lane2)", trashedLane(order: "1024", title: "Doing"))
let store = try BoardStore(rootURL: fixture.root)
store.select([lane2], in: .trash)
store.deleteTrashEntries([lane2])
#expect(store.selection.ids == [newer], "nothing below, so the row above")
// The successor is drawn from the *pre-write* snapshot, so the second purge needs the reload
// that tells the store the row is gone the lane-delete suite's rule, on the trash side.
await reload(store)
store.deleteTrashEntries([newer])
#expect(store.selection.isEmpty, "an emptied container selects nothing")
}
}
/// The lane row's own menu validation "everything edit-shaped is disabled on trash selections
/// **and lane width ops on lane rows**" (04-interactions.md The trash).
@MainActor
@Suite("Edit-shaped commands on a trashed lane row")
struct TrashedLaneRowValidationTests {
@Test("Rename, Style… and Open Card all refuse a lane row")
func editShapedRefusals() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane2], in: .trash)
// The container answers all three, which is why none of them needed a kind clause.
#expect(store.renameTarget == nil)
#expect(store.boardStyleTarget == nil)
#expect(store.openCardTarget == nil)
// The within-lane sort is inert too its plan reads a *board* card selection.
#expect(store.sortPlan(.up) == nil)
#expect(store.sortPlan(.down) == nil)
}
/// The width pair batches over "the selected live lanes", which is `snapshot.lanes` narrowed by a
/// **board** selection so a trashed row contributes nothing and both items disable.
@Test("The width stepper's batch is empty for a lane row")
func widthOpsRefuse() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane2], in: .trash)
let selected = store.snapshot.lanes.filter { store.selection.ids.contains($0.id) }
#expect(selected.isEmpty)
#expect(store.selection.container == .trash)
// And the row is not a lane on the strip at all, so Move Left/Right cannot name it either.
#expect(!SelectionGrammar.lanes(in: store.snapshot).contains(lane2))
}
/// Delete stays enabled and honest the row is a real entry the container holds, so the one
/// Delete predicate answers `true` and the write it stages is the permanent one.
@Test("Delete is enabled on a lane row and stages the permanent write")
func deleteStaysHonest() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane2], in: .trash)
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
#expect(ItemPath.resolve(store.selection.ids, in: .trash, snapshot: store.snapshot)
== [.trashLane(lane2)])
}
}
// MARK: - The legacy tombstone migration
/// 01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write, never