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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user