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:
@@ -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