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