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:
@@ -2189,6 +2189,98 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The lane restore: `.trash/` out to the strip, at a drop position** (03-board-ui.md § Trash:
|
||||
/// "Restoring is an ordinary move out … drag … a trashed lane row to a lane-strip slot";
|
||||
/// 04-interactions.md ▸ The trash: "dropping … a trashed lane row onto its own board's strip — is
|
||||
/// an ordinary move to the drop position").
|
||||
///
|
||||
/// **Its own method rather than a branch inside `moveLanes`**, because the two are different
|
||||
/// arithmetic wearing one word: a reorder permutes the strip's own lanes and has to drop the
|
||||
/// dragged lanes' rungs before consulting the neighbours, while a restore is an *arrival* — the
|
||||
/// lane is not on the ladder at all — which is exactly `receiveLanes`' shape. What it does not
|
||||
/// borrow from `receiveLanes` is that method's cross-board posture: this is a within-board move,
|
||||
/// so the identity travels untouched (no import boundary, no remint) and the gesture earns an
|
||||
/// undo step, which an arrival deliberately does not (13-native-undo.md's inverse inventory names
|
||||
/// "restore-by-move → move back in").
|
||||
///
|
||||
/// The cards ride along inside the folder, unread and unwritten, exactly as they did on the way
|
||||
/// in (`moveLanesToTrash`).
|
||||
public func restoreLanes(_ ids: Set<ItemID>, toIndex index: Int) {
|
||||
let members = snapshot.trashedLanes.filter { ids.contains($0.id) }
|
||||
guard !members.isEmpty else { return }
|
||||
|
||||
let root = rootURL
|
||||
let rendered = snapshot.lanes
|
||||
let target = min(max(0, index), rendered.count)
|
||||
|
||||
// What an undo puts back: the rank the row held in the trash, captured before the write.
|
||||
var arrivals: [(id: ItemID, order: Double, trashRank: Double, title: String?)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// The shared two-step; the arriving lanes are not among the renumbered children.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: rendered.map(\.order),
|
||||
compacting: root,
|
||||
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: members.count) }
|
||||
) else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: ItemPath.trashLane(member.id).folder(under: root),
|
||||
toParent: root,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
)
|
||||
arrivals.append((
|
||||
id: member.id,
|
||||
order: rank,
|
||||
trashRank: member.order,
|
||||
title: member.title.value
|
||||
))
|
||||
}
|
||||
}
|
||||
guard landed != nil, !arrivals.isEmpty else { return }
|
||||
|
||||
// restore-by-move → **move back in** (13-native-undo.md ▸ Interaction with the trash), at the
|
||||
// trash rank the row was holding — the delete's step read from the other end.
|
||||
let trashFolder = Self.parentFolder(of: .trashLane(arrivals[0].id), under: root)
|
||||
let steps = arrivals.map { arrival in
|
||||
(
|
||||
restored: ItemPath.lane(arrival.id).folder(under: root),
|
||||
trashed: ItemPath.trashLane(arrival.id).folder(under: root),
|
||||
order: arrival.order,
|
||||
trashRank: arrival.trashRank
|
||||
)
|
||||
}
|
||||
registerStep(
|
||||
HistoryPhrase.name(.move, kind: .lane, count: steps.count),
|
||||
subject: arrivals.count == 1 ? arrivals[0].title : nil,
|
||||
undoExpects: steps.map { .present($0.restored, .order($0.order)) },
|
||||
redoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) }
|
||||
) { _ in
|
||||
for step in steps {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: step.restored,
|
||||
toParent: trashFolder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: step.trashRank
|
||||
)
|
||||
}
|
||||
} redo: { _ in
|
||||
for step in steps {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: step.trashed,
|
||||
toParent: root,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: step.order
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag & drop commits
|
||||
|
||||
// The writes a released drag performs (04-interactions.md ▸ Drag and drop; the geometry that
|
||||
@@ -2484,9 +2576,24 @@ public final class BoardStore: HealHost {
|
||||
// fractal layout fixes the depth (`<root>/<lane>` and `<root>/<lane>/<card>`), so the URL
|
||||
// already carries it and a second parameter could only ever disagree with the first.
|
||||
|
||||
/// The board root a lane folder sits directly under.
|
||||
/// The board root a lane folder sits directly under — **or two levels under, when the lane is in
|
||||
/// a trash** (03-board-ui.md § Trash: `.trash/` is flat, so a trashed lane sits one level deeper
|
||||
/// than a live one).
|
||||
///
|
||||
/// The depth is a fact about the path and the fractal layout fixes both shapes, which is why this
|
||||
/// reads the parent's name rather than taking a second parameter that could disagree with the
|
||||
/// first. It matters at exactly one place and matters a lot there: the import boundary turns on
|
||||
/// source-root-versus-destination-root (`BoardWriter.moveItem`), so a trashed lane pasted back
|
||||
/// into its **own** board must answer `<root>` — answering `<root>/.trash` would make the restore
|
||||
/// an import, and the import would find the row's own identity in the destination (the trash
|
||||
/// counts in `identityOccurrences`) and remint the lane it was restoring.
|
||||
///
|
||||
/// The card twin needs no such clause: `<root>/.trash/<card>` is already two levels down, which
|
||||
/// is the depth a lane's card sits at.
|
||||
nonisolated static func boardRoot(ofLaneFolder folder: URL) -> URL {
|
||||
folder.deletingLastPathComponent()
|
||||
let parent = folder.deletingLastPathComponent()
|
||||
guard parent.lastPathComponent == IntegrityRules.trashFolderName else { return parent }
|
||||
return parent.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
/// The board root a card folder sits two levels under.
|
||||
@@ -3207,6 +3314,21 @@ public final class BoardStore: HealHost {
|
||||
_ = moveToTrash(ItemPath.resolve(Set(cardIDs), in: .board, snapshot: snapshot).compactMap(Self.cardMove(of:)))
|
||||
}
|
||||
|
||||
/// **Drop-on-trash deletes, 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").
|
||||
///
|
||||
/// `deleteByDrag`'s twin exactly, and for its reasons: the write is `moveLanesToTrash`, so a lane
|
||||
/// deleted by drop and one deleted by ⌫ are byte-indistinguishable afterwards, and it says
|
||||
/// **nothing about the selection** — a drag has no keystroke to keep repeatable and its run is not
|
||||
/// necessarily the selection at all.
|
||||
public func deleteLanesByDrag(laneIDs: [ItemID]) {
|
||||
let lanes = ItemPath.resolve(Set(laneIDs), in: .board, snapshot: snapshot).compactMap { path -> ItemID? in
|
||||
guard case let .lane(id) = path else { return nil }
|
||||
return id
|
||||
}
|
||||
_ = moveLanesToTrash(lanes)
|
||||
}
|
||||
|
||||
/// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — moves the card
|
||||
/// to the trash … the window then dismisses itself").
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user