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:
@@ -163,9 +163,11 @@ struct BoardDropContext {
|
||||
/// measured frame (03-board-ui.md § Motion).
|
||||
///
|
||||
/// **The terminal slot is clamped before the trash.** The column consumes one unit while
|
||||
/// shown and is never a landing spot for anything (04-interactions.md ▸ The trash: "no move or
|
||||
/// shown and is never a *position* on the strip (04-interactions.md ▸ The trash: "no move or
|
||||
/// paste ever targets the trash"), so it is absent from the slot list by construction and the end
|
||||
/// slot's uncapped reach past the last real lane lands *before* it.
|
||||
/// slot's uncapped reach past the last real lane lands *before* it. The delete a lane drag can
|
||||
/// propose over the column is not a slot at all — it is the column's own target answering
|
||||
/// (`retargetTrash`), and it names `.trash` rather than an index in this list.
|
||||
func retargetLanes() {
|
||||
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
@@ -508,9 +510,10 @@ struct BoardDropContext {
|
||||
/// set's size (DRAG-REORDER.md § The drop commits).
|
||||
///
|
||||
/// **One of the containers is not a destination but a verb.** A proposal naming the trash commits
|
||||
/// a delete — the same write ⌫ performs, through the same `BoardWriter.deleteCardToTrash` in the same
|
||||
/// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk
|
||||
/// from one deleted by keystroke (04-interactions.md ▸ The trash, settled 2026-07-28).
|
||||
/// a delete — the same write ⌫ performs, through the same `BoardWriter.deleteCardToTrash` /
|
||||
/// `deleteLaneToTrash` in the same bracket (`BoardStore.deleteByDrag`, `deleteLanesByDrag`), so an
|
||||
/// item deleted by drop is indistinguishable on disk from one deleted by keystroke
|
||||
/// (04-interactions.md ▸ The trash, settled 2026-07-28; lanes extended 2026-07-29).
|
||||
///
|
||||
/// The write is the first half; the second is the **committed-overlay hold**
|
||||
/// (`DragSession.commit`). The write is still in flight when this returns, so the session flips
|
||||
@@ -539,18 +542,53 @@ struct BoardDropContext {
|
||||
|
||||
switch kind {
|
||||
case .lanes:
|
||||
// **A lane drag never targets the trash**, and never a masonry either — a lane session
|
||||
// proposes only lane slots (04-interactions.md ▸ The trash). True by construction, since
|
||||
// `retargetLanes` is the only thing that proposes for one and the trash column is absent
|
||||
// from its slot list; written down because a commit that trusted the container implicitly
|
||||
// would be the one place the invariant could break silently.
|
||||
if target.isTrash {
|
||||
// **The pointer's delete gesture, at the lane level** (04-interactions.md ▸ The
|
||||
// trash, lanes extended 2026-07-29: "a lane drag over the shown trash proposes the
|
||||
// delete alongside its strip slots"): release moves the lane's folder — subtree
|
||||
// intact — into `.trash/`, exactly the ⌫ delete (`BoardStore.deleteLanesByDrag`).
|
||||
//
|
||||
// The gate is re-asked here rather than trusted from the hover, the card branch's
|
||||
// rule for its reason: the modifiers can change after the proposal stood, and no
|
||||
// callback reports it. A refusal cancels — the lane returns, nothing is written.
|
||||
guard TrashDrop.accepts(
|
||||
kind: kind,
|
||||
container: session.container,
|
||||
isWithinBoard: within,
|
||||
operation: operation,
|
||||
isTrashShown: store.transient.isTrashVisible,
|
||||
acceptsMutations: store.acceptsBoardMutations
|
||||
) else {
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
store.deleteLanesByDrag(laneIDs: ids)
|
||||
break
|
||||
}
|
||||
// **A lane drag never targets a masonry** — a lane session proposes lane slots and the
|
||||
// trash column, and nothing else (04-interactions.md ▸ The trash). True by construction,
|
||||
// since `retargetLanes` and `retargetTrash` are the only things that propose for one;
|
||||
// written down because a commit that trusted the container implicitly would be the one
|
||||
// place the invariant could break silently.
|
||||
guard target.container == .strip else {
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
if within {
|
||||
store.moveLanes(Set(ids), toIndex: target.index)
|
||||
// **The trash side is the restore, and it is a different write** (04 ▸ The trash:
|
||||
// "a trashed lane row [dropped] onto its own board's strip — is an ordinary move to
|
||||
// the drop position"): the folder comes *out* of `.trash/`, where `moveLanes`'
|
||||
// rank arithmetic — a permutation of the strip's own lanes — has nothing to say
|
||||
// about it. `restoreLanes` is that move, with the same one-bracket, one-step shape.
|
||||
if session.container == .trash {
|
||||
store.restoreLanes(Set(ids), toIndex: target.index)
|
||||
} else {
|
||||
store.moveLanes(Set(ids), toIndex: target.index)
|
||||
}
|
||||
} else {
|
||||
// Cross-board, from either container: the ordinary arrival, copy by default and
|
||||
// move under ⌘ — "Dropped on *another* board it follows the copy default … ⌘-drag
|
||||
// forces the true cross-board restore-move" (04 ▸ The trash).
|
||||
store.receiveLanes(folders, operation: operation, at: target.index)
|
||||
}
|
||||
|
||||
@@ -762,11 +800,11 @@ struct StripDropDelegate: DropDelegate {
|
||||
/// gives the deepest region the session whether it wants it or not (see the note above), and it
|
||||
/// routes them three ways:
|
||||
///
|
||||
/// - **card sessions** through `retargetTrash`, which proposes the topmost row for the ones the trash
|
||||
/// takes and falls through to the strip's own answer for the rest;
|
||||
/// - **lane sessions** the same way, and `TrashDrop.accepts` refuses them there, so what actually
|
||||
/// runs is the strip's `retargetLanes` — lane reordering keeps working across the column exactly as
|
||||
/// it did when the column was a hole in the strip's target;
|
||||
/// - **card and lane sessions alike** through `retargetTrash`, which proposes the topmost row for the
|
||||
/// ones the trash takes — a live item from this board, unmodified, either kind (lanes extended
|
||||
/// 2026-07-29) — and falls through to the strip's own answer for the rest, so a *trashed* row being
|
||||
/// dragged out keeps reordering against the strip across the column exactly as it did when the
|
||||
/// column was a hole in the strip's target;
|
||||
/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment
|
||||
/// import) on trash cards are inert" (▸ The trash), and the column has nothing else to offer
|
||||
/// them — no lane, no card, nothing to attach to.
|
||||
|
||||
Reference in New Issue
Block a user