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
+171
View File
@@ -765,3 +765,174 @@ struct CrossBoardRestoreTests {
#expect(store.banners.oneShots.isEmpty)
}
}
// MARK: - Restoring a trashed lane
/// A board whose trash holds a **lane row** the opaque unit, subtree intact (03-board-ui.md §
/// Trash) beside an ordinary trashed card, so the restore's rank arithmetic has a strip to land
/// on and the container has more than one kind in 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(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item(
".trash/\(Ident.lane3)",
"---\nschema: 1\ntitle: Done\norder: 512\nkind: lane\nproject: lanework\n---\nDone body.\n"
)
try fixture.item(".trash/\(Ident.lane3)/\(Ident.card2)", Item.rich(order: "1024", title: "Freight"))
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Trashed"))
return fixture
}
/// **Drag-to-restore at the lane level** (04-interactions.md The trash: "dropping a trashed lane
/// row onto its own board's strip is an ordinary move to the drop position"), which is
/// `BoardStore.restoreLanes` an arrival's rank arithmetic with a within-board move's identity
/// posture and an undo step, since 13-native-undo.md's inverse inventory names "restore-by-move
/// move back in".
@MainActor
@Suite("BoardStore ▸ restoring a trashed lane")
struct RestoreLaneTests {
@Test("The drop slot sets the restored lane's order, and its cards ride along")
func dropSlotSetsTheOrder() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Slot 1: between the board's two lanes.
store.restoreLanes([lane3], toIndex: 1)
let model = try loaded(fixture)
#expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane3, Ident.lane2])
#expect(model.trashedLanes.isEmpty)
#expect(!fixture.exists(".trash/\(Ident.lane3)"), "the folder physically left the trash")
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"), "the freight came back inside it")
#expect(model.trash.map(\.id.rawValue) == [Ident.card3], "the column's cards are untouched")
#expect(store.banners.oneShots.isEmpty)
}
@Test("The identity travels — a within-board restore is a move, never an import")
func identityTravels() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.restoreLanes([lane3], toIndex: 99)
// The lane keeps its UUID and so does its card: the import boundary's remint is for
// *arrivals from another board*, and a row coming out of this board's own trash is not one.
#expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Ident.lane3])
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"))
#expect(try fixture.indexText(Ident.lane3).contains("project: lanework"), "unknown keys ride along")
}
@Test("Its undo is the ordinary move back in, at the trash rank the row was holding")
func undoMovesItBackIn() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
store.restoreLanes([lane3], toIndex: 0)
#expect(history.undoActionName == "Move Lane")
history.undo()
#expect(fixture.exists(".trash/\(Ident.lane3)/\(Ident.card2)"), "back in, subtree intact")
#expect(!fixture.exists(Ident.lane3))
#expect(try order(fixture, ".trash/\(Ident.lane3)") == .valid(512), "at the rank it was holding")
history.redo()
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"))
#expect(try loaded(fixture).trashedLanes.isEmpty)
}
@Test("A row that is not in the trash, and an empty set, write nothing")
func skipsWhatIsNotThere() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let untouched = try stat(fixture, Ident.lane1)
store.restoreLanes([lane1], toIndex: 0)
store.restoreLanes([], toIndex: 0)
#expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2])
#expect(try stat(fixture, Ident.lane1) == untouched)
#expect(store.banners.oneShots.isEmpty)
}
/// The keyboard restore's write (04-interactions.md The trash: "X in the trash, V a trashed
/// lane pastes after the anchor lane"), whose armed-cut path lands in `receiveLanes` with a
/// source folder inside this board's own `.trash/`.
///
/// **The identity has to survive that**, which is what the trash-aware source-root derivation
/// buys: `.trash/` counts in the destination's identity scan, so a restore mistaken for an import
/// would find the row's own UUID resident and remint the very lane it was restoring.
@Test("A within-board paste out of the trash keeps the lane's identity")
func pasteRestoreKeepsTheIdentity() throws {
let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.receiveLanes(
[.folder(fixture.url(".trash/\(Ident.lane3)"))],
operation: .move,
at: 2,
normalizingLooseFiles: true
)
#expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Ident.lane3])
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"))
#expect(try loaded(fixture).trashedLanes.isEmpty)
}
}
/// The cross-board half the ordinary arrival, exactly as for a live lane: "Dropped on *another*
/// board it follows the copy default -drag forces the true cross-board restore-move"
/// (04-interactions.md The trash).
@MainActor
@Suite("BoardStore ▸ cross-board lane restore")
struct CrossBoardLaneRestoreTests {
@Test("A cross-board copy out of the trash mints fresh identities and leaves the original")
func copyLeavesTheOriginal() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeTrashedLaneBoard()
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveLanes([source.url(".trash/\(Ident.lane3)")], operation: .copy, at: 99)
let model = try loaded(destination)
let landed = try #require(model.lanes.last)
#expect(landed.title.value == "Done")
#expect(landed.id.rawValue != Ident.lane3, "a copy out of the trash is still a copy")
#expect(landed.cards.map(\.title.value) == ["Freight"])
#expect(source.exists(".trash/\(Ident.lane3)"), "the original stays in the source trash")
}
@Test("A ⌘-drag move carries the lane's identity and empties the source trash")
func moveCarriesTheIdentity() throws {
let destination = try makeBoard()
defer { destination.tearDown() }
let source = try makeTrashedLaneBoard()
defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root)
store.receiveLanes([source.url(".trash/\(Ident.lane3)")], operation: .move, at: 0)
let landed = try #require(loaded(destination).lanes.first)
#expect(landed.id.rawValue == Ident.lane3, "the lane's identity travels")
// Its card carried `Ident.card2`, which this destination already holds so the import
// boundary remints that folder and only that folder, per-folder at the finest grain.
#expect(landed.cards.map(\.title.value) == ["Freight"])
#expect(landed.cards.map(\.id.rawValue) != [Ident.card2])
#expect(!source.exists(".trash/\(Ident.lane3)"))
#expect(try loaded(source).trashedLanes.isEmpty)
}
}