Unify the trashed universe on the trash rows
02 rules the trashed side has exactly one definition: the set with trash rows — a card carrying its own deleted: under a tombstoned lane is in neither universe, so an anchor or selection can never survive on an item that renders nowhere. The membership rule now lives once, in Liveness.walk, and ItemReferenceSet.idUniverse, TrashModel.entries, paths, and emptyTrashTargets all derive from it — the old lane-OR-card logic that admitted subsumed cards to the trashed side is gone, and the two universes deliberately no longer partition the board. Put Back, Delete Immediately, and Empty Trash outcomes are unchanged: a tombstoned lane still moves and purges whole, its nested tombstones with it. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -1936,10 +1936,10 @@ public final class BoardStore {
|
||||
/// carrying their own `deleted:` stay tombstoned — and their rows reappear in the trash, which
|
||||
/// is exactly the two-step recovery the design settled on.
|
||||
///
|
||||
/// A card whose lane is itself tombstoned cannot be reached this way through the UI (it has no
|
||||
/// row — `TrashModel.entries`), but the path resolution admits it, and the outcome is the pure
|
||||
/// view's honest one: the key is removed, and the card still renders nowhere because its lane
|
||||
/// is still tombstoned. Putting the lane back then shows it.
|
||||
/// A card whose lane is itself tombstoned is **not reachable here at all**, by construction
|
||||
/// rather than by the UI happening not to offer it: it has no trash row, and the trashed side of
|
||||
/// `TrashModel.paths` is that row set exactly (`Liveness.walk`). Recovering it stays the two-step
|
||||
/// the design settled on — put the lane back, then put the card back from the row it regains.
|
||||
///
|
||||
/// The selection is deliberately left alone: the restored items flip liveness, and the reload's
|
||||
/// resolve rule ejects them from a `.trashed` set as a vanish — the same silent shrink an
|
||||
|
||||
@@ -26,6 +26,54 @@ public enum Liveness: String, Codable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The one definition of a side
|
||||
|
||||
extension Liveness {
|
||||
|
||||
/// Every item `snapshot` has on this side, visited in **board order** — lanes left to right,
|
||||
/// each lane immediately before its own cards — as the lane it lives under and, for a card, the
|
||||
/// card itself.
|
||||
///
|
||||
/// **This is the definition, and it is the only one** (02-architecture.md § Changes from Kanban,
|
||||
/// settled: "universe and rows are one function, never a broader set with a pointer-side
|
||||
/// subset"). Everything that asks what is on a side is this walk with a different accumulator:
|
||||
/// `ItemReferenceSet.idUniverse` collects ids, `TrashModel.entries` builds the trash's rows,
|
||||
/// `TrashModel.paths` and `emptyTrashTargets` build folders. Two spellings of the rule would be
|
||||
/// two things to keep in step, and the one they would eventually disagree about is precisely the
|
||||
/// item below.
|
||||
///
|
||||
/// **The whole rule is the `continue`: a tombstoned lane subsumes its subtree.** It contributes
|
||||
/// one item to the trashed side — its own row — and its cards contribute nothing to *either*
|
||||
/// side, whatever their own flags say. That is 03-board-ui.md § Trash's absolute ancestor walk
|
||||
/// stated as code: "a card that carries its own `deleted:` under a tombstoned lane has **no row
|
||||
/// of its own**".
|
||||
///
|
||||
/// **So the two sides do not partition the board, and that is the point.** A card beneath a
|
||||
/// tombstoned lane is in *neither* universe, because it renders nowhere — no row, no membership.
|
||||
/// A selection, a drag, a pending cut, a range anchor or a navigation head can therefore never
|
||||
/// survive a reload sitting on something no surface would draw, and 04-interactions.md § Search's
|
||||
/// hidden-cards-leave-the-selection rule and 02's reload-survival rule stay one rule rather than
|
||||
/// two that happen to agree.
|
||||
///
|
||||
/// Non-escaping and accumulator-driven rather than array-returning: the callers below run on
|
||||
/// every reload and on every menu validation, and none of them wants a board-sized copy of the
|
||||
/// model to throw away.
|
||||
func walk(_ snapshot: BoardModel, visiting visit: (Lane, Card?) -> Void) {
|
||||
for lane in snapshot.lanes {
|
||||
if lane.isDeleted {
|
||||
// The subsumption, both halves of it: the lane is a trash row, and its cards are
|
||||
// nobody's — so the loop below never runs for them.
|
||||
if self == .trashed { visit(lane, nil) }
|
||||
continue
|
||||
}
|
||||
if self == .live { visit(lane, nil) }
|
||||
for card in lane.cards where Liveness(isDeleted: card.isDeleted) == self {
|
||||
visit(lane, card)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ItemReferenceSet
|
||||
|
||||
/// A set of UUIDs over the snapshot plus the liveness side it lives on — **the one shape every
|
||||
@@ -98,32 +146,27 @@ public struct ItemReferenceSet: Sendable, Equatable {
|
||||
/// item that is tombstoned or vanishes externally before paste drops out of the pending
|
||||
/// cut"), and so does drag membership (04 ▸ Drag and drop's emptied-drag rule).
|
||||
///
|
||||
/// The liveness that is matched is **effective — ancestor-walked** (settled): a card counts as
|
||||
/// trashed if its own flag *or its lane's* says so. Tombstoning a lane therefore ejects its
|
||||
/// cards from a live set even though their own flags never changed — the card renders nowhere
|
||||
/// once 03-board-ui.md collapses the lane to a single trash entry, and nothing invisible may
|
||||
/// stay selected, drag-included, or pending-cut.
|
||||
/// The liveness that is matched is **effective — ancestor-walked** (settled), and the trashed
|
||||
/// side is exactly the trash's rows: `Liveness.walk` is the one definition both sides read.
|
||||
/// Tombstoning a lane therefore ejects its cards from a live set even though their own flags
|
||||
/// never changed — and does **not** hand them to a trashed set, because the lane's single entry
|
||||
/// subsumes them (03-board-ui.md). A card under a tombstoned lane renders nowhere on either
|
||||
/// side, and nothing invisible may stay selected, drag-included, or pending-cut.
|
||||
public func resolved(against snapshot: BoardModel) -> ItemReferenceSet {
|
||||
guard !ids.isEmpty else { return self }
|
||||
return constrained(to: Self.idUniverse(of: snapshot, on: liveness))
|
||||
}
|
||||
|
||||
/// Every id in `snapshot` whose **effective** liveness is `side` — the reload direction's
|
||||
/// universe, and the only place the ancestor walk lives.
|
||||
/// Every id in `snapshot` on `side` — the reload direction's universe.
|
||||
///
|
||||
/// A lane contributes itself on the side its own flag names, and each of its cards on the side
|
||||
/// `lane.isDeleted || card.isDeleted` names — the walk being one level deep is the whole of it,
|
||||
/// because the tree is (01-storage-format.md § Fractal layout: board → lane → card).
|
||||
/// One line over `Liveness.walk`, which is where the rule itself lives and is stated: the live
|
||||
/// side is the live lanes and their unflagged cards, and the trashed side is *exactly* the trash's
|
||||
/// rows — tombstoned lanes, plus cards carrying their own `deleted:` under a live lane. Nothing
|
||||
/// else is in either, so a card hidden beneath a tombstoned lane belongs to no universe and no
|
||||
/// set may go on referencing it.
|
||||
static func idUniverse(of snapshot: BoardModel, on side: Liveness) -> Set<ItemID> {
|
||||
var universe: Set<ItemID> = []
|
||||
for lane in snapshot.lanes {
|
||||
if Liveness(isDeleted: lane.isDeleted) == side {
|
||||
universe.insert(lane.id)
|
||||
}
|
||||
for card in lane.cards where Liveness(isDeleted: lane.isDeleted || card.isDeleted) == side {
|
||||
universe.insert(card.id)
|
||||
}
|
||||
}
|
||||
side.walk(snapshot) { lane, card in universe.insert(card?.id ?? lane.id) }
|
||||
return universe
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,11 @@ public enum TrashEntry: Identifiable, Sendable, Equatable {
|
||||
///
|
||||
/// 1. **The absolute ancestor walk.** A tombstoned lane's entry subsumes everything beneath it — "a
|
||||
/// card that carries its own `deleted:` under a tombstoned lane has **no row of its own**". There
|
||||
/// is no trash carve-out from 01-storage-format.md's consumer rule.
|
||||
/// is no trash carve-out from 01-storage-format.md's consumer rule. This rule is not spelled
|
||||
/// here: it is `Liveness.walk`'s, because the trashed **universe** every item-referencing set is
|
||||
/// held to *is* this row set — "universe and rows are one function" (02-architecture.md § Changes
|
||||
/// from Kanban, settled). Everything below that asks what is in the trash asks that walk, so the
|
||||
/// rows a user sees and the ids a selection may hold cannot drift apart.
|
||||
/// 2. **The returning count.** A lane entry's number "counts what Put Back returns to the board —
|
||||
/// cards without their own flag; individually tombstoned descendants aren't in that number, since
|
||||
/// they come back to the *trash*".
|
||||
@@ -121,13 +125,23 @@ public enum TrashModel {
|
||||
|
||||
/// The trash's rows, in the order the quasi-lane shows them.
|
||||
///
|
||||
/// The walk is one level deep because the tree is (01-storage-format.md § Fractal layout), and
|
||||
/// the branch on `lane.isDeleted` *is* the ancestor walk: a tombstoned lane contributes exactly
|
||||
/// one entry and its cards contribute none, whatever their own flags say.
|
||||
/// **Which items are rows is not decided here** — it is `Liveness.trashed.walk`, the same walk
|
||||
/// `ItemReferenceSet.idUniverse(of:on:)` reads, because the trashed universe and this row set are
|
||||
/// one function. Its `continue` on a tombstoned lane is the absolute ancestor walk: such a lane
|
||||
/// contributes exactly one entry and its cards contribute none, whatever their own flags say.
|
||||
///
|
||||
/// What is left for this function is what a row *carries* — the returning count and the sort
|
||||
/// inputs — and the order it shows in, neither of which any other caller of the walk wants.
|
||||
public static func entries(of snapshot: BoardModel) -> [TrashEntry] {
|
||||
var rows: [Row] = []
|
||||
for lane in snapshot.lanes {
|
||||
if lane.isDeleted {
|
||||
Liveness.trashed.walk(snapshot) { lane, card in
|
||||
if let card {
|
||||
rows.append(Row(
|
||||
entry: .card(card, laneID: lane.id),
|
||||
deleted: card.deleted.value,
|
||||
name: card.id.rawValue
|
||||
))
|
||||
} else {
|
||||
// Rule 2: only the cards *without* their own flag come back with the lane. The ones
|
||||
// that carry a flag stay tombstoned and get their rows back in the trash — which is
|
||||
// why Put Back on such a card is deliberately two steps.
|
||||
@@ -137,14 +151,6 @@ public enum TrashModel {
|
||||
deleted: lane.deleted.value,
|
||||
name: lane.id.rawValue
|
||||
))
|
||||
continue
|
||||
}
|
||||
for card in lane.cards where card.isDeleted {
|
||||
rows.append(Row(
|
||||
entry: .card(card, laneID: lane.id),
|
||||
deleted: card.deleted.value,
|
||||
name: card.id.rawValue
|
||||
))
|
||||
}
|
||||
}
|
||||
return rows.sorted(by: isOrdered).map(\.entry)
|
||||
@@ -153,7 +159,11 @@ public enum TrashModel {
|
||||
/// Whether the board has anything in its trash at all — the "non-empty" half of Empty Trash…'s
|
||||
/// menu validation, which "reads the board's tombstones, not the filtered view" (03 ▸ Trash).
|
||||
///
|
||||
/// Cheaper than building the entries and used where only the answer matters.
|
||||
/// The walk's non-emptiness in **short-circuit form**, which is the one place the rule is
|
||||
/// restated and only because stopping early is the whole point: a tombstoned lane is a row
|
||||
/// outright, and under a live lane any own-flagged card is one. There is deliberately no third
|
||||
/// clause for a card beneath a tombstoned lane — the lane has already answered `true` for it.
|
||||
/// `TrashModelTests` pins the equivalence to `entries(of:).isEmpty` so the shortcut cannot drift.
|
||||
public static func isEmpty(_ snapshot: BoardModel) -> Bool {
|
||||
!snapshot.lanes.contains { lane in
|
||||
lane.isDeleted || lane.cards.contains(where: \.isDeleted)
|
||||
@@ -193,27 +203,31 @@ public enum TrashModel {
|
||||
|
||||
// MARK: - Paths for the trash's writes
|
||||
|
||||
/// The folders `ids` names, in display order, restricted to one **effective** liveness side.
|
||||
/// The folders `ids` names, in display order, restricted to one liveness side.
|
||||
///
|
||||
/// The universe rule is `ItemReferenceSet.idUniverse`'s, spelled here in terms of paths rather
|
||||
/// than ids because a write needs to know *where* the item is: a lane contributes itself on the
|
||||
/// side its own flag names, and a card on the side `lane.isDeleted || card.isDeleted` names.
|
||||
/// **The membership rule is not restated here** — it is `Liveness.walk`'s, the same one
|
||||
/// `ItemReferenceSet.idUniverse` and `entries(of:)` read — spelled in *paths* rather than ids
|
||||
/// because a write needs to know where the item is. So the trashed side is the trash's rows and
|
||||
/// nothing besides: a card beneath a tombstoned lane is not individually addressable, which costs
|
||||
/// nothing (it has no row for a user to act on, so no command can name it) and buys the guarantee
|
||||
/// that every path this hands a writer names something the board would draw.
|
||||
///
|
||||
/// **A tombstoned lane still takes its subtree with it**, and that is subsumption rather than
|
||||
/// omission: its path is the lane *folder*, and removing a folder removes what is inside it. Put
|
||||
/// Back on it restores the lane and every card that rode along; Delete Immediately on it purges
|
||||
/// the whole tree, own-flag cards included — the outcome the lane entry's confirmation sentence
|
||||
/// exists to warn about (`message(lanes:unrecoverable:)`).
|
||||
///
|
||||
/// Display order — lanes left to right, each lane then its cards — rather than the caller's set
|
||||
/// iteration order, which is not an order at all: a batch that fails partway must fail the same
|
||||
/// way twice (`BoardStore.styleSubjects` makes the same choice for the same reason).
|
||||
/// way twice (`BoardStore.styleSubjects` makes the same choice for the same reason). The walk
|
||||
/// visits in exactly that order, so this is a filter over it and never a sort.
|
||||
public static func paths(of ids: Set<ItemID>, on side: Liveness, in snapshot: BoardModel) -> [ItemPath] {
|
||||
guard !ids.isEmpty else { return [] }
|
||||
var result: [ItemPath] = []
|
||||
for lane in snapshot.lanes {
|
||||
let laneSide = Liveness(isDeleted: lane.isDeleted)
|
||||
if laneSide == side, ids.contains(lane.id) {
|
||||
result.append(ItemPath(laneID: lane.id, cardID: nil))
|
||||
}
|
||||
for card in lane.cards
|
||||
where Liveness(isDeleted: lane.isDeleted || card.isDeleted) == side && ids.contains(card.id) {
|
||||
result.append(ItemPath(laneID: lane.id, cardID: card.id))
|
||||
}
|
||||
side.walk(snapshot) { lane, card in
|
||||
guard ids.contains(card?.id ?? lane.id) else { return }
|
||||
result.append(ItemPath(laneID: lane.id, cardID: card?.id))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -221,20 +235,19 @@ public enum TrashModel {
|
||||
/// Every folder Empty Trash removes — "emptying purges every tombstone on the board, filter or
|
||||
/// no filter" (03 ▸ Trash).
|
||||
///
|
||||
/// `paths(of:on:in:)` on the trashed side with **no id filter at all**, which is the strongest
|
||||
/// form of that guarantee: the command's scope is the trashed universe itself, so it cannot
|
||||
/// narrow to a selection any more than it can narrow to the search.
|
||||
///
|
||||
/// **A tombstoned lane contributes only itself**, and that is not an omission: removing the lane
|
||||
/// folder removes the tree beneath it, own-flag cards included. Listing those cards as well would
|
||||
/// be redundant purges of paths the first removal already took (harmless — `purgeItem` treats a
|
||||
/// folder that is already gone as success — but noise).
|
||||
/// folder that is already gone as success — but noise) and would require a second, broader
|
||||
/// definition of "in the trash" than the one every other caller reads.
|
||||
public static func emptyTrashTargets(in snapshot: BoardModel) -> [ItemPath] {
|
||||
var result: [ItemPath] = []
|
||||
for lane in snapshot.lanes {
|
||||
if lane.isDeleted {
|
||||
result.append(ItemPath(laneID: lane.id, cardID: nil))
|
||||
continue
|
||||
}
|
||||
for card in lane.cards where card.isDeleted {
|
||||
result.append(ItemPath(laneID: lane.id, cardID: card.id))
|
||||
}
|
||||
Liveness.trashed.walk(snapshot) { lane, card in
|
||||
result.append(ItemPath(laneID: lane.id, cardID: card?.id))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user