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