Give transient UI state an explicit home in the store

TransientBoardState, one per store, holds state by how a reload treats
it: item-referencing sets (selection, drag membership, pending cut)
share one shape and one constraint rule — members must exist in the
current universe — applied in two directions by one primitive, so the
search filter's hidden-cards rule and reload survival are one rule
expressed once; derived state is stored as its inputs only (the query,
never its result set); and the new-card placeholder is a lane-anchored
overlay with no UUID until commit, discarded when its lane vanishes or
tombstones, handed off when the created card's UUID appears. Trash
visibility rides along per-open, never persisted. The decision is
written back into DESIGN/02 § Changes from Kanban — the TBD is closed.

Full suite 352 tests in 64 suites green. Two findings filed.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 21:06:26 -04:00
parent 8285e19497
commit 23e761120f
6 changed files with 712 additions and 107 deletions
+45 -104
View File
@@ -68,88 +68,6 @@ public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConv
}
}
/// Which side of the live/tombstoned boundary something sits on.
///
/// A selection is **homogeneous by liveness** (04-interactions.md § The trash): it never mixes live
/// and tombstoned items, so the side is a property of the selection 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.
public enum Liveness: 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: - Selection
/// The board's selection: a set of UUIDs over the snapshot, plus the liveness side it lives on.
///
/// **UUIDs, never indices or copies of items** (02-architecture.md § Live-reload resilience,
/// "Selection survives reloads by UUID"): a reload swaps the whole snapshot as a value, and
/// anything holding positions or item copies would be silently wrong the moment an agent files a
/// card. Re-resolution against the new snapshot is `resolved(against:)`, and it is a *pure
/// function* on purpose the transient-state container (02-architecture.md § Changes from Kanban)
/// will absorb this type and apply the same rule to drag membership and the pending cut, so the
/// rule must be reusable rather than buried in the store's reload path.
public struct Selection: Sendable, Equatable {
public var ids: Set<ItemID>
public var liveness: Liveness
public init(ids: Set<ItemID> = [], liveness: Liveness = .live) {
self.ids = ids
self.liveness = liveness
}
/// Nothing selected, on the live side the state a board opens in and the state
/// `BoardStore.clearSelection()` returns to.
public static let empty = Selection()
public var isEmpty: Bool { ids.isEmpty }
/// This selection re-grounded on `snapshot`: the members that are still there, **on the same
/// liveness side**, and nothing else.
///
/// Two rules, both settled in 02-architecture.md § Live-reload resilience:
///
/// - **Vanished members leave silently.** No substitute is invented, no successor is picked
/// 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 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 selection 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.
public func resolved(against snapshot: BoardModel) -> Selection {
guard !ids.isEmpty else { return self }
var survivors: Set<ItemID> = []
survivors.reserveCapacity(ids.count)
for lane in snapshot.lanes {
if ids.contains(lane.id), Liveness(isDeleted: lane.isDeleted) == liveness {
survivors.insert(lane.id)
}
for card in lane.cards where ids.contains(card.id) {
if Liveness(isDeleted: lane.isDeleted || card.isDeleted) == liveness {
survivors.insert(card.id)
}
}
}
return Selection(ids: survivors, liveness: liveness)
}
}
// MARK: - BoardStore
/// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the
@@ -168,18 +86,20 @@ public struct Selection: Sendable, Equatable {
/// 2. **The failure rules.** A failed reload never replaces a good snapshot; an ordinary failure
/// raises the banner condition and leaves editing alone; a failure after a bracketed wholesale
/// operation locks the board read-only; the next success clears both.
/// 3. **Selection across reloads.** Re-resolved by UUID and liveness on every applied snapshot.
/// 3. **Transient state across reloads.** `transient.resolve(against:)` runs on every applied
/// snapshot, re-grounding the selection, the drag, the pending cut, and the new-card placeholder.
///
/// ### What it deliberately does not own
///
/// The `FolderWatcher` itself the registry owns one watcher and one store per board and wires
/// them together (`watcherBrackets`, `handleWatcherEvent(_:)`), so this type can be built and tested
/// without a filesystem stream. The transient-state container is also still to come: selection lives
/// here for now, and the **new-card placeholder** will live beside it a pseudo-card with no disk
/// presence and no UUID, overlaid on the snapshot rather than merged into it (02-architecture.md §
/// Layering, the one named exception to the one-way flow). Nothing here precludes that: `snapshot`
/// is a pure value swap with no identity assumptions, so an overlay can simply be rendered on top of
/// whatever the latest reload produced.
/// without a filesystem stream. And the transient state itself, which lives in its own container
/// (`TransientBoardState`) rather than accreting here as fields: this type knows only *when* to
/// re-resolve it, never what the rules are. That includes the **new-card placeholder** a
/// pseudo-card with no disk presence and no UUID, overlaid on the snapshot rather than merged into
/// it (02-architecture.md § Layering, the one named exception to the one-way flow). Nothing here
/// makes that awkward: `snapshot` is a pure value swap with no identity assumptions, so an overlay
/// is simply rendered on top of whatever the latest reload produced.
@MainActor
@Observable
public final class BoardStore {
@@ -208,8 +128,17 @@ public final class BoardStore {
public var isReadOnly: Bool { readOnlyLock != nil }
/// The board's selection, re-resolved against every snapshot this store applies.
public private(set) var selection: Selection
/// Everything shared across this board's windows that is **not on disk** selection, drag
/// membership, the pending cut, the search query, the new-card placeholder, trash visibility
/// (02-architecture.md § Changes from Kanban).
///
/// **Created with the store and dying with it**, which is what makes its per-open values per-open
/// without any reset logic: closing the board is the reset. `let`, because it is one container
/// for the store's whole life the windows observe *it*, not a slot on this class.
///
/// The store's only involvement is `resolve(against:)` on every successful reload; the rules that
/// call answers live over there.
public let transient: TransientBoardState
/// Where the board is **now**. Follows the folder: a rename or a move absorbed through
/// `relocate(to:)` updates it, so every URL derived from it the Writer's paths, card-window
@@ -356,7 +285,7 @@ public final class BoardStore {
self.loadWarnings = result.warnings
self.reloadFailure = nil
self.readOnlyLock = nil
self.selection = .empty
self.transient = TransientBoardState()
}
// MARK: - Inbound signals
@@ -472,11 +401,14 @@ public final class BoardStore {
// this one did not.
reloadFailure = nil
clearLockIfDisproved(by: origin)
selection = selection.resolved(against: result.model)
// The one place transient state is re-grounded. It goes last, after `snapshot` is the
// new one, because a view woken by the snapshot's change must never observe a selection
// still pointing at the old tree.
transient.resolve(against: result.model)
case let .failure(error):
// `snapshot`, `loadWarnings` and `selection` are untouched: a failed reload never
// replaces a good snapshot, and a selection over a snapshot that did not change has
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
// never replaces a good snapshot, and state over a snapshot that did not change has
// nothing to re-resolve against.
reloadFailure = error
// `readOnlyLock == nil` rather than an unconditional assignment: a root that vanished
@@ -673,21 +605,30 @@ public final class BoardStore {
}
}
// MARK: - Selection
// MARK: - Selection (delegated)
/// Replaces the selection.
///
/// Minimal on purpose the transient-state container will absorb this along with drag state and
/// the pending cut, and give the selection its real grammar (extend, range, successor-on-delete).
/// Deliberately *not* filtered against the snapshot: a caller selects what it is rendering, and
/// `Selection.resolved(against:)` on the next reload is what keeps the set honest over time.
// The three thin pass-throughs to `transient`, and the only ones.
//
// **Conveniences, not a second home.** The selection is the transient state every command site
// touches menu validation, , paste anchoring, Select All and `store.selection` reads better
// at each of them than `store.transient.selection` while meaning exactly the same thing. Nothing
// is stored here: `selection` is computed and the two mutators forward, so there is no second
// copy to go stale. Drag membership, the pending cut, the query and the placeholder get no such
// shortcuts they have one or two call sites each, and a delegate per field would be the
// grab-bag reassembling itself on this class.
/// The board's selection, re-resolved against every snapshot this store applies
/// `TransientBoardState.selection` under a shorter name.
public var selection: ItemReferenceSet { transient.selection }
/// Replaces the selection `TransientBoardState.select(_:liveness:)`, which owns the semantics.
public func select(_ ids: Set<ItemID>, liveness: Liveness) {
selection = Selection(ids: ids, liveness: liveness)
transient.select(ids, liveness: liveness)
}
/// Selects nothing Escape's last step outward (04-interactions.md Grammar).
public func clearSelection() {
selection = .empty
transient.clearSelection()
}
// MARK: - Quiescence