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:
@@ -301,6 +301,57 @@ struct PasteFromTrashTests {
|
||||
#expect(lane.cards.map(\.id).contains(clipboardCard3), "identity travels — it is a move")
|
||||
#expect(harness.store.transient.pendingCut.isEmpty, "the cut was consumed")
|
||||
}
|
||||
|
||||
/// **The lane row's keyboard restore** (04-interactions.md ▸ The trash: "a card pastes into a
|
||||
/// lane, a trashed lane pastes after the anchor lane (the lane-paste rule above, verbatim)").
|
||||
///
|
||||
/// The identity is the load-bearing assertion: the armed cut hands `receiveLanes` a folder inside
|
||||
/// this board's own `.trash/`, and `.trash/` counts in the destination board's identity scan — so
|
||||
/// a restore mistaken for an import would remint the very lane it was restoring.
|
||||
@Test("Cut a trashed lane row and paste: it lands after the anchor lane, identity and freight intact")
|
||||
func cutALaneRowAndPasteIsTheRestore() async throws {
|
||||
let harness = try makeTrashedLaneHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.store.transient.isTrashVisible = true
|
||||
harness.store.select([clipboardTrashedLane], in: .trash)
|
||||
harness.clipboard.cut(from: harness.store)
|
||||
// The anchor: the paste lands *after* the selected lane, exactly as a live lane paste does.
|
||||
harness.store.select([clipboardLane1], in: .board)
|
||||
await harness.clipboard.paste(into: harness.store)?.value
|
||||
|
||||
let model = try pasted(harness.fixture)
|
||||
#expect(model.trashedLanes.isEmpty, "the folder left the trash")
|
||||
#expect(model.lanes.map(\.id) == [clipboardLane1, clipboardTrashedLane, clipboardLane2])
|
||||
let restored = try #require(model.lanes.first { $0.id == clipboardTrashedLane })
|
||||
#expect(restored.cards.map(\.title.value) == ["Freight"], "the subtree came back with it")
|
||||
#expect(restored.cards.map(\.id.rawValue) == [Ident.indexless], "and kept its own identities")
|
||||
#expect(harness.store.transient.pendingCut.isEmpty, "the cut was consumed")
|
||||
}
|
||||
|
||||
/// ⌘C on a row copies out like any lane copy: fresh GUIDs throughout, original left in the trash
|
||||
/// (04 ▸ The trash's copy default, ▸ Clipboard's "a pasted *copy* takes fresh GUIDs throughout").
|
||||
@Test("A trashed lane copies out as a fresh-GUID lane, carrying its cards")
|
||||
func laneRowCopiesOut() async throws {
|
||||
let harness = try makeTrashedLaneHarness()
|
||||
defer { harness.tearDown() }
|
||||
let destination = try makeDestination()
|
||||
defer { destination.tearDown() }
|
||||
let target = try BoardStore(rootURL: destination.root)
|
||||
|
||||
harness.store.transient.isTrashVisible = true
|
||||
harness.store.select([clipboardTrashedLane], in: .trash)
|
||||
harness.clipboard.copy(from: harness.store)
|
||||
await harness.clipboard.paste(into: target)?.value
|
||||
|
||||
let model = try pasted(destination)
|
||||
let landed = try #require(model.lanes.last)
|
||||
#expect(landed.title.value == "Done")
|
||||
#expect(landed.id != clipboardTrashedLane, "a copy mints fresh identities at every level")
|
||||
#expect(landed.cards.map(\.title.value) == ["Freight"])
|
||||
#expect(try pasted(harness.fixture).trashedLanes.map(\.id) == [clipboardTrashedLane],
|
||||
"the original stays in the source trash")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The destination's search, and the stale pasteboard
|
||||
|
||||
Reference in New Issue
Block a user