Materialize the trash — store, undo, and the container universe

Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 17:47:56 -04:00
parent 16c10d61c3
commit 53bc71f7fb
53 changed files with 3459 additions and 3655 deletions
+67 -155
View File
@@ -1,82 +1,9 @@
import Foundation
import Observation
// MARK: - Liveness
/// Which side of the live/tombstoned boundary something sits on.
///
/// An item-referencing set is **homogeneous by liveness** (04-interactions.md § The trash): it never
/// mixes live and tombstoned items, so the side is a property of the set as a whole rather than of
/// each member which is exactly what makes re-resolution across a reload a matching rule rather
/// than a partition.
///
/// **`String`-backed and `Codable` because the clipboard manifest carries one** (04-interactions.md
/// Clipboard: a manifest records its entries' "source side (live/trashed)"). The raw spellings are
/// therefore pasteboard API a manifest written before a quit is decoded after the relaunch and
/// they are the case names so nothing has to remember a second vocabulary.
public enum Liveness: String, Codable, Sendable, Equatable {
case live
case trashed
/// The side an item's own tombstone flag puts it on. `Lane.isDeleted`/`Card.isDeleted` are
/// presence-of-the-key, not validity, so a malformed `deleted:` still reads as trashed see
/// their doc comments in `BoardModel.swift`.
init(isDeleted: Bool) {
self = isDeleted ? .trashed : .live
}
}
// 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
/// A set of UUIDs over the snapshot plus the container it lives in **the one shape every
/// piece of transient state that points at items wears**: the selection, drag membership, and the
/// pending cut are three values of this type, not three hand-rolled near-copies
/// (02-architecture.md § Live-reload resilience, "Selection and every transient state that
@@ -92,46 +19,52 @@ extension Liveness {
/// and everything else here is that primitive with a universe supplied. Only the universe differs
/// between the two callers:
///
/// - **Reload survival**: the universe is the new snapshot's ids on this set's liveness side, which
/// - **Reload survival**: the universe is the new snapshot's ids in this set's container, which
/// is what `resolved(against:)` computes before delegating.
/// - **The live search filter**: the universe is the visible ids the predicate produced, so
/// 04-interactions.md § Search's "hidden cards leave the selection" needs no second rule it is
/// this one, with a different universe (m5 wires that caller).
/// this one, with a different universe.
///
/// Both stay **pure value functions**. Deciding *when* to apply them belongs to the caller, and
/// storing the result belongs to `TransientBoardState` a set that filtered itself would need to
/// know about snapshots, and the whole point of the value-type snapshot is that nothing has to.
public struct ItemReferenceSet: Sendable, Equatable {
public var ids: Set<ItemID>
public var liveness: Liveness
public init(ids: Set<ItemID> = [], liveness: Liveness = .live) {
/// Which container the members live in the board, or `.trash/` (`ItemContainer`).
///
/// A property of the set as a whole rather than of each member, because 04-interactions.md The
/// trash keeps exactly one boundary: "a selection never mixes trash cards with board cards".
/// That is what makes re-resolution across a reload a matching rule rather than a partition.
public var container: ItemContainer
public init(ids: Set<ItemID> = [], container: ItemContainer = .board) {
self.ids = ids
self.liveness = liveness
self.container = container
}
/// Nothing referenced, on the live side the state a board opens in, the state a drag with
/// Nothing referenced, on the board side the state a board opens in, the state a drag with
/// nothing in flight is in, and the state `TransientBoardState.clearSelection()` returns to.
public static let empty = ItemReferenceSet()
public var isEmpty: Bool { ids.isEmpty }
/// This set narrowed to `universe`: members that are in it, **side unchanged**.
/// This set narrowed to `universe`: members that are in it, **container unchanged**.
///
/// The primitive both directions are built from intersection and nothing else. It is
/// deliberately ignorant of what a universe *is*: a snapshot's ids on one liveness side
/// deliberately ignorant of what a universe *is*: a snapshot's ids in one container
/// (`resolved(against:)`) and a search predicate's visible ids are the same argument as far as
/// the rule is concerned, which is what lets one rule be stated once and mean both.
///
/// The liveness side survives even when the membership does not: an emptied set is still a set
/// on a side, and re-populating it (a fresh click, a new drag) is the caller's business.
/// The container survives even when the membership does not: an emptied set is still a set in a
/// container, and re-populating it (a fresh click, a new drag) is the caller's business.
public func constrained(to universe: Set<ItemID>) -> ItemReferenceSet {
guard !ids.isEmpty else { return self }
return ItemReferenceSet(ids: ids.intersection(universe), liveness: liveness)
return ItemReferenceSet(ids: ids.intersection(universe), container: container)
}
/// This set re-grounded on `snapshot`: the members that are still there, **on the same liveness
/// side**, and nothing else.
/// This set re-grounded on `snapshot`: the members that are still there, **in the same
/// container**, and nothing else.
///
/// Two rules, both settled in 02-architecture.md § Live-reload resilience:
///
@@ -139,35 +72,19 @@ public struct ItemReferenceSet: Sendable, Equatable {
/// an empty result is a legitimate outcome. (App-mediated deletion is deliberately different:
/// selects the successor sibling, because that is an act rather than a surprise
/// 04-interactions.md The map. That belongs to the delete command, not here.)
/// - **A liveness flip is a vanish.** A foreign edit that tombstones a selected live card or
/// restores a selected tombstoned one ejects it, keeping 04-interactions.md's
/// homogeneous-by-liveness invariant true across reloads so menu validation never sees a
/// mixed selection. The pending cut inherits the same rule for free (04 Clipboard: "a cut
/// 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).
/// - **A container crossing is a vanish** (resettled 2026-07-28, the materialized trash): "a
/// foreign move that trashes a selected board card or restores a selected trash card
/// ejects it from the selection (and from the pending cut)", keeping 04's container-boundary
/// invariant true across reloads so menu validation never sees a mixed selection. Drag
/// membership inherits it for free (04 Drag and drop's emptied-drag rule).
///
/// 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.
/// **Presence is the whole test.** There is no ancestor walk and no effective liveness left to
/// compute "the old effective-liveness ancestor walk is retired with the tombstone model"
/// (02-architecture.md) because a deleted card's folder has actually moved, and a folder is
/// either in the container or it is not.
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` on `side` the reload direction's universe.
///
/// 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> = []
side.walk(snapshot) { lane, card in universe.insert(card?.id ?? lane.id) }
return universe
return constrained(to: container.ids(in: snapshot))
}
}
@@ -442,8 +359,8 @@ public final class TransientBoardState {
// MARK: Per-open values
/// The lane that most recently held selection or a creation **in this window session**
/// 04-interactions.md's N target rule's fallback when nothing (or a tombstoned something) is
/// selected, before the last resort of the first lane.
/// 04-interactions.md's N target rule's fallback when nothing (or a trash selection, which
/// never anchors creation) is selected, before the last resort of the first lane.
///
/// It is a *memory of a gesture*, not derived state: with an empty selection there is nothing
/// in the snapshot that could reconstruct which lane the user was last working in, which is
@@ -454,7 +371,7 @@ public final class TransientBoardState {
/// is no target at all; `NewCardTarget` then falls through to the first lane.
public private(set) var lastActiveLaneID: ItemID?
/// Whether the trash quasi-lane is showing (03-board-ui.md Trash).
/// Whether the trash column is showing (03-board-ui.md Trash).
///
/// **Hidden on every open, never persisted**: visiting the trash is an errand, not a layout
/// choice, so it does not belong in the board registry beside window frames
@@ -480,8 +397,8 @@ public final class TransientBoardState {
///
/// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
/// `resolve(against:)` on the next reload is what keeps the set honest over time.
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) {
selection = ItemReferenceSet(ids: ids, liveness: liveness)
public func select(_ ids: Set<ItemID>, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil) {
selection = ItemReferenceSet(ids: ids, container: container)
let sole = ids.count == 1 ? ids.first : nil
selectionAnchor = anchor ?? sole
selectionHead = head ?? sole
@@ -666,16 +583,15 @@ public final class TransientBoardState {
///
/// **Every item-referencing set is resolved independently.** They are re-grounded against the
/// same snapshot but never against each other: a card leaving the selection must not disturb a
/// drag in flight or a pending cut that also held it, and each set carries its own liveness
/// side. Independence is what makes that a property of the code rather than of the order the
/// lines happen to be in.
/// drag in flight or a pending cut that also held it, and each set carries its own container.
/// Independence is what makes that a property of the code rather than of the order the lines
/// happen to be in.
///
/// **The placeholder has its own two rules**, because it references a lane rather than items:
///
/// - **Discarded when its anchor lane is gone** absent from the snapshot, or effectively
/// tombstoned. A tombstoned lane renders nowhere (03-board-ui.md collapses it to a single
/// trash entry), so its lane "vanished" in every sense 02-architecture.md means: "if the
/// placeholder's lane vanished in the reload, it is discarded".
/// - **Discarded when its anchor lane is gone** absent from the snapshot. "If the
/// placeholder's lane vanished in the reload, it is discarded" (02-architecture.md); a lane
/// delete is physical now, so gone is the only way a lane goes.
/// - **Discarded as a hand-off** when it is `.awaitingArrival(id)` and `id`'s card is in the
/// snapshot. The real card arrived; the overlay's whole job was covering the gap between the
/// Writer's create and the watcher's round trip, and holding it a moment longer would draw the
@@ -687,25 +603,25 @@ public final class TransientBoardState {
///
/// **The rename editor has one rule, and it is the vanish rule** (04-interactions.md
/// Grammar, "Inline rename tracks its target by UUID, and vanishing discards it"): a target
/// that is tombstoned, deleted, or gone discards the editor and its keystrokes silently.
/// A foreign *move* is deliberately not a vanish the editor follows the UUID and the commit
/// writes wherever the item now lives which falls out for free from matching on identity
/// rather than on position. Liveness is **effective**, so a card under a lane an agent just
/// tombstoned vanishes with it.
/// that is trashed, deleted, or gone discards the editor and its keystrokes silently
/// "entering the trash is a vanish from the board; nothing is ever written into a vanished
/// folder". A foreign *move between lanes* is deliberately not a vanish the editor follows
/// the UUID and the commit writes wherever the card now lives which falls out for free from
/// matching on identity within the board container.
///
/// **`lastActiveLaneID` is cleared when its lane goes**, for the reason 02-architecture.md
/// gives every item-referencing piece of transient state: nothing may reference an item the
/// current universe does not have. It is not an `ItemReferenceSet` only because it is one
/// optional rather than a set on a side the rule it obeys is the same one.
///
/// **`selectionAnchor` obeys it too**, on the *selection's* side: a range origin that vanished
/// or flipped liveness is gone, and the next -click acts as a plain click rather than ranging
/// from somewhere that renders nowhere. It deliberately does **not** have to stay *in* the
/// selection a -click that toggles the anchor's neighbour out leaves the anchor selected and
/// a range from it is still exactly what the user asked for.
/// **`selectionAnchor` obeys it too**, in the *selection's* container: a range origin that
/// vanished or crossed containers is gone, and the next -click acts as a plain click rather
/// than ranging from somewhere that renders nowhere. It deliberately does **not** have to stay
/// *in* the selection a -click that toggles the anchor's neighbour out leaves the anchor
/// selected and a range from it is still exactly what the user asked for.
///
/// **The style editor tracks its target set live** (03-board-ui.md § Styling Controls,
/// settled): a member that vanishes or flips liveness leaves the set so the editor's
/// settled): a member that vanishes or crosses containers leaves the set so the editor's
/// mixed-state display recomputes off the survivors and a set emptied by a foreign reload
/// clears the session, which is how "the popover dismisses when it empties" reaches the screen.
/// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns
@@ -725,25 +641,22 @@ public final class TransientBoardState {
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
styleEditor = styleEditor?.resolved(against: snapshot)
// One universe computed once and asked three questions the rename target's liveness, the
// One universe computed once and asked three questions the rename target's container, the
// last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
let live = ItemReferenceSet.idUniverse(of: snapshot, on: .live)
if let editor = renameEditor, !live.contains(editor.targetID) {
let board = ItemContainer.board.ids(in: snapshot)
if let editor = renameEditor, !board.contains(editor.targetID) {
renameEditor = nil
}
if let lane = lastActiveLaneID, !live.contains(lane) {
if let lane = lastActiveLaneID, !board.contains(lane) {
lastActiveLaneID = nil
}
if selectionAnchor != nil || selectionHead != nil {
// The selection's side, because that is the side both cursors live on by construction
// every route that sets either sets the selection to the same side in the same call. A
// vanished or liveness-flipped cursor is gone, which is the rule every item reference
// here gets: "a flip is a vanish from its side of the boundary". The head then re-derives
// from the selection's last member on the next arrow, which is the same fallback an
// anchorless -arrow already uses.
let universe = selection.liveness == .live
? live
: ItemReferenceSet.idUniverse(of: snapshot, on: selection.liveness)
// The selection's container, because that is where both cursors live by construction
// every route that sets either sets the selection to the same container in the same
// call. A vanished or container-crossed cursor is gone, which is the rule every item
// reference here gets. The head then re-derives from the selection's last member on the
// next arrow, which is the same fallback an anchorless -arrow already uses.
let universe = selection.container == .board ? board : selection.container.ids(in: snapshot)
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
}
@@ -776,13 +689,14 @@ public final class TransientBoardState {
/// under a live filter is a gesture in flight, not a set the filter has any claim on. The
/// editor's absence is the settled ruling in person: "an open inline rename survives the filter
/// hiding its card the vanish-discard rule stays reserved for true liveness flips"
/// (`RenameEditor`), so a foreign edit that stops the renaming card matching drops it from the
/// selection here and leaves the keystrokes exactly where the user left them.
/// (`RenameEditor`) read for the materialized trash, true container crossings so a foreign
/// edit that stops the renaming card matching drops it from the selection here and leaves the
/// keystrokes exactly where the user left them.
public func constrainToSearch(in snapshot: BoardModel) {
let filter = SearchFilter(query: searchQuery)
guard filter.isActive else { return }
let universe = filter.visibleIDs(in: snapshot, on: selection.liveness)
let universe = filter.visibleIDs(in: snapshot, container: selection.container)
selection = selection.constrained(to: universe)
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
@@ -792,9 +706,7 @@ public final class TransientBoardState {
private func resolvedPlaceholder(against snapshot: BoardModel) -> NewCardPlaceholder? {
guard let placeholder = newCardPlaceholder else { return nil }
guard let anchor = snapshot.lanes.first(where: { $0.id == placeholder.laneID }),
!anchor.isDeleted
else { return nil }
guard snapshot.lanes.contains(where: { $0.id == placeholder.laneID }) else { return nil }
if case let .awaitingArrival(id) = placeholder.phase,
snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) {