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:
@@ -43,9 +43,9 @@ import Foundation
|
||||
/// agent that re-files a card *and* retitles it made one change to the board, and two fragments
|
||||
/// counting the same card would read as two cards. Position wins because it is the change the
|
||||
/// board's shape shows.
|
||||
/// - **Crossing the trash boundary is a departure or an arrival, never a move** — see
|
||||
/// `between(_:_:includingTrash:)`, whose flag decides whether the trash is walked at all but never
|
||||
/// what an event that touches it is *called*.
|
||||
/// - **Crossing the trash boundary is a departure or an arrival, never a move** — at **both levels**
|
||||
/// (lanes rejoined the trash 2026-07-29) — see `between(_:_:includingTrash:)`, whose flag decides
|
||||
/// whether the trash is walked at all but never what an event that touches it is *called*.
|
||||
public struct BoardDiff: Sendable, Equatable {
|
||||
|
||||
/// One kind's four buckets. Disjoint by construction — see the type's note on precedence.
|
||||
@@ -129,7 +129,9 @@ public struct BoardDiff: Sendable, Equatable {
|
||||
/// - A **purge** and an **Empty Trash** are `deleted`, an outside writer dropping a folder
|
||||
/// straight into `.trash/` is `added`, and a trash card retitled or reordered in place is
|
||||
/// `edited` or `moved` — all of them silent while the column is hidden, because then the trash
|
||||
/// is not in the universe at all.
|
||||
/// is not in the universe at all. **Lane rows count in the lane buckets** the same way, so an
|
||||
/// Empty Trash over a trash holding two lanes says "2 lanes deleted" alongside its cards rather
|
||||
/// than understating what went.
|
||||
///
|
||||
/// The implied-events rule reaches the crossings too: a lane deleted by moving its cards into
|
||||
/// `.trash/` and then removing the folder is still one event, "1 lane deleted", not that plus
|
||||
@@ -142,8 +144,8 @@ public struct BoardDiff: Sendable, Equatable {
|
||||
var diff = BoardDiff()
|
||||
diff.boardChanged = boardSideDiffers(old, new, includingTrash: includingTrash)
|
||||
|
||||
let oldLanes = laneIndex(of: old)
|
||||
let newLanes = laneIndex(of: new)
|
||||
let oldLanes = laneIndex(of: old, includingTrash: includingTrash)
|
||||
let newLanes = laneIndex(of: new, includingTrash: includingTrash)
|
||||
|
||||
for id in newLanes.keys where oldLanes[id] == nil {
|
||||
diff.lanes.added.insert(id)
|
||||
@@ -153,7 +155,19 @@ public struct BoardDiff: Sendable, Equatable {
|
||||
}
|
||||
for (id, newLane) in newLanes {
|
||||
guard let oldLane = oldLanes[id] else { continue }
|
||||
if oldLane.order != newLane.order {
|
||||
// **The crossing rule, at the lane level** (lanes rejoined the trash 2026-07-29): a lane
|
||||
// that crossed into `.trash/` is a *delete* and one that came back out is a *restore*,
|
||||
// which is what the user watched happen — never "1 lane moved". It reads the same shown
|
||||
// or hidden, because a lane entering the trash leaves `lanes` either way; what the shown
|
||||
// column adds is the churn that never leaves the container — a purged row, an Empty
|
||||
// Trash, a foreign lane folder dropped straight into `.trash/`.
|
||||
if oldLane.container != newLane.container {
|
||||
if newLane.container == .trash {
|
||||
diff.lanes.deleted.insert(id)
|
||||
} else {
|
||||
diff.lanes.added.insert(id)
|
||||
}
|
||||
} else if oldLane.order != newLane.order {
|
||||
diff.lanes.moved.insert(id)
|
||||
} else if contentDiffers(oldLane, newLane) {
|
||||
diff.lanes.edited.insert(id)
|
||||
@@ -212,8 +226,44 @@ public struct BoardDiff: Sendable, Equatable {
|
||||
|
||||
// MARK: - Indices
|
||||
|
||||
private static func laneIndex(of snapshot: BoardModel) -> [ItemID: Lane] {
|
||||
Dictionary(uniqueKeysWithValues: snapshot.lanes.map { ($0.id, $0) })
|
||||
/// A lane the digest can see: on the strip, or — while the trash is being walked — as one of the
|
||||
/// column's opaque rows.
|
||||
///
|
||||
/// The sum type exists because the two are genuinely different values (`TrashedLane` is not a
|
||||
/// `Lane`, and deliberately so), while the three questions the comparison asks of a lane —
|
||||
/// which container, which rank, does its rendered content differ — have an answer for both.
|
||||
private enum LaneEntry {
|
||||
case live(Lane)
|
||||
case trashed(TrashedLane)
|
||||
|
||||
var container: ItemContainer {
|
||||
switch self {
|
||||
case .live: .board
|
||||
case .trashed: .trash
|
||||
}
|
||||
}
|
||||
|
||||
var order: Double {
|
||||
switch self {
|
||||
case let .live(lane): lane.order
|
||||
case let .trashed(lane): lane.order
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every lane the digest can see, with where it sits — `cardIndex`'s twin, and with the same
|
||||
/// flag: with `includingTrash` off the column's rows are simply not walked, which is what makes
|
||||
/// a purge in a hidden trash silent rather than something filtered back out downstream.
|
||||
private static func laneIndex(of snapshot: BoardModel, includingTrash: Bool) -> [ItemID: LaneEntry] {
|
||||
var index: [ItemID: LaneEntry] = [:]
|
||||
for lane in snapshot.lanes {
|
||||
index[lane.id] = .live(lane)
|
||||
}
|
||||
guard includingTrash else { return index }
|
||||
for lane in snapshot.trashedLanes {
|
||||
index[lane.id] = .trashed(lane)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/// Every card the digest can see, with **where it sits** — the lane it is in, or the trash.
|
||||
@@ -261,6 +311,28 @@ public struct BoardDiff: Sendable, Equatable {
|
||||
|| old.attachments != new.attachments
|
||||
}
|
||||
|
||||
/// Two lane entries' rendered content.
|
||||
///
|
||||
/// **A trashed row's rendered content is its title and nothing else** — the row draws a title and
|
||||
/// a held-card count and takes no styling accents (03-board-ui.md § Trash), so a `background` an
|
||||
/// agent wrote onto a folder sitting in the trash changes nothing anyone can see and must not
|
||||
/// announce. The count would be visible, but it is a fact about the subtree the loader counts and
|
||||
/// not a field on the row; a card added inside a trashed lane is churn in a container the design
|
||||
/// calls opaque, and the digest is deliberately as opaque about it.
|
||||
///
|
||||
/// A crossing never reaches here — it is decided one level up — so the mixed pair is unreachable
|
||||
/// and answers "nothing differs" rather than inventing a comparison between two kinds.
|
||||
private static func contentDiffers(_ old: LaneEntry, _ new: LaneEntry) -> Bool {
|
||||
switch (old, new) {
|
||||
case let (.live(old), .live(new)):
|
||||
contentDiffers(old, new)
|
||||
case let (.trashed(old), .trashed(new)):
|
||||
old.schema != new.schema || old.title != new.title
|
||||
case (.live, .trashed), (.trashed, .live):
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A lane's rendered content — the card's list, minus attachments (a lane has none) plus
|
||||
/// `width`, which is a lane's own visible property (03-board-ui.md § Lane). A lane's *cards*
|
||||
/// are diffed as cards, never folded in here.
|
||||
|
||||
Reference in New Issue
Block a user