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
+2 -2
View File
@@ -30,7 +30,7 @@ The **one named exception** is transient UI state rendering things that don't ex
- **BoardLoader** — walks the folder tree, applies the fail-fast/skip rules, produces an immutable `BoardModel` snapshot. Pure function of the tree.
- **BoardWriter** — every mutation (create, move, reorder, tombstone, style) as an explicit filesystem operation. No hidden state; a write is done when the file is on disk. (The EchoLedger's receipts are not this bullet's "hidden state": a receipt describes a *completed* write, and the ledger lives beside the Writer, not in it — no write is ever pending in memory.)
- **EchoLedger** — the write-provenance ledger (the "Writer/echo machinery" that 06-history-undo.md ▸ Commit attribution and 10-accessibility.md ▸ Live board announcements consume; settled). Every BoardWriter operation drops a receipt of its expected on-disk outcome before returning: path → content hash for writes (attachment imports hash during the copy — the bytes stream through the app anyway), an absence marker for deletes, an old→new pair for folder moves; a newer app write to the same path supersedes the receipt. Classification runs per observed changed file in a debounce window: current on-disk content matches the receipt → **app-mediated**, receipt consumed; no receipt, or mismatch → **foreign**. Final content deciding is what settles the races: an agent writing byte-identical bytes over a fresh app write matches and classifies app-mediated — with identical bytes the misattribution is unobservable in the tree, accepted; a foreign edit landing on an app-written path inside the same window misses the hash and the file classifies foreign — last writer wins the file, the app's subsumed intermediate never separately recorded (the diff compares snapshots, not a journal — 06-history-undo.md). Consumers: the auto-committer's author field and two-commit split (06), and the announcement filter (10) — on no-git boards the ledger runs identically with the announcer as its only consumer. **In-memory, per-store, dies with the session** — losing it costs attribution and nothing else, so the launch catch-up commit (06) classifies everything foreign: the app never vouches for changes it didn't witness. Bracketed operations don't consult it (they commit themselves and announce once at completion), and the reload-granularity origin tag (Live-reload resilience below) is orthogonal: it classifies *reloads*, the ledger classifies *files*. Feeds attribution and announcements only — never the render path (Layering above).
- **BoardStore** — per-board `@Observable` object holding the current snapshot plus transient UI state that must be shared across that board's windows (selection, drag state, search query, pending cut, the new-card placeholder, trash visibility). Coalesces watcher reloads — at most one tree walk in flight, signals landing mid-walk fold into one follow-up; the debounce itself lives in FolderWatcher (above).
- **BoardStore** — per-board `@Observable` object holding the current snapshot plus transient UI state that must be shared across that board's windows (TransientBoardState see Changes from Kanban). Coalesces watcher reloads — at most one tree walk in flight, signals landing mid-walk fold into one follow-up; the debounce itself lives in FolderWatcher (above).
- **BoardStoreRegistry** — refcounted registry so a board window and its card windows share one live store and one watcher. **The board window owns the board** (settled): card windows never outlive it — closing the board window closes its card windows too, so the last-window teardown and board-window close coincide. (The refcount still earns its keep ordering teardown while multiple windows close.)
- **FolderWatcher** — FSEvents (debounced: **200 ms trailing**, the timer restarting per event so a burst yields one reload after quiet, over 50 ms FSEvents latency — settled numbers), attached best-effort to whatever path the board lives at. **Events under any `.git` path component are filtered out** (settled): the board's own root-level repo (a worktree-link `.git` file included) is the app's auto-commit churn, and a repo nested deeper — a card folder containing a clone, a submodule — is a stray (01-storage-format.md) whose internals never render; neither can alter the rendered tree, so neither drives reloads. (A nested repo's *working files* still fire events like any stray's — those reloads are value-equal and quiet.) There is only this one watching path: no NSMetadataQuery for iCloud Drive, no polling fallback for network volumes — on those warned-against locations (07-sync-collab.md) FSEvents delivery is unreliable and live reload silently degrades, accepted per 07's no-accommodations stance.
- **Ranks** — gapped fractional ordering math + compaction. Pure.
@@ -107,7 +107,7 @@ The old app loaded boards fast enough that the planned SwiftData cache was never
## Changes from Kanban
- The store's transient-state grab-bag (selection, drag, search) gets an explicit home rather than accreting — exact shape TBD during implementation planning.
- The store's transient-state grab-bag gets an explicit home (settled, m3): **TransientBoardState**, one per store, holding state by how a reload treats it. **Item-referencing sets** — selection, drag membership, the pending cut — share one shape (a UUID set plus the liveness side it lives on) and one constraint rule, *members must exist in the current universe*, applied in two directions by one primitive: a reload re-resolves each set independently against the new snapshot (present on the same effective-liveness side, ancestor-walked), and the search filter constrains the selection to its visible set — the hidden-cards-leave-the-selection rule and the reload-survival rule are one rule, expressed once. **Derived state is stored as its inputs only**: the search query is kept, its result set never is — the predicate re-runs against each snapshot (04-interactions.md's live filter). **The overlay** — the new-card placeholder — is anchored to its lane, not to items: no UUID until the title commits, discarded when a reload drops or effectively tombstones its lane, and handed off by discarding itself the moment the created card's UUID appears in a snapshot. Trash visibility rides along as a plain per-open value: hidden on every open, never persisted — visiting the trash is an errand, not a layout choice.
## Open questions
+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
+369
View File
@@ -0,0 +1,369 @@
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.
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: - ItemReferenceSet
/// A set of UUIDs over the snapshot plus the liveness side it lives on **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
/// references items (drag state, pending cut) is a set of UUIDs over the snapshot").
///
/// **UUIDs, never indices or copies of items.** 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.
///
/// ### One rule, applied in two directions
///
/// `constrained(to:)` *is* the rule **state may only reference items in the current universe**
/// 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
/// 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).
///
/// 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) {
self.ids = ids
self.liveness = liveness
}
/// Nothing referenced, on the live 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**.
///
/// 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
/// (`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.
public func constrained(to universe: Set<ItemID>) -> ItemReferenceSet {
guard !ids.isEmpty else { return self }
return ItemReferenceSet(ids: ids.intersection(universe), liveness: liveness)
}
/// This set 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 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).
///
/// 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.
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.
///
/// 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).
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)
}
}
return universe
}
}
// MARK: - NewCardPlaceholder
/// The card being created: an inline editor rendered as a pseudo-card **overlaid** on the snapshot,
/// with no disk presence and no UUID until the title commits 02-architecture.md § Layering's *one
/// named exception* to the one-way flow, and the only thing in `TransientBoardState` that is not a
/// set of ids.
///
/// **Anchored to a lane, never to an item set**, and that asymmetry is the point: an unborn card
/// has no identity to re-resolve, so the only question a reload can ask about it is whether the
/// place it is being born into still exists. `TransientBoardState.resolve(against:)` owns the
/// answer; see its doc comment for the two discard rules.
public struct NewCardPlaceholder: Sendable, Equatable {
/// How far along the birth is and therefore what a reload has to check.
public enum Phase: Sendable, Equatable {
/// The inline editor is open and the draft is in flight. Nothing has been written; abandoning
/// (Escape, empty commit, click-away) discards it and disk was never touched
/// (04-interactions.md Grammar).
case editing
/// The Writer's create ran, so the real card's id is minted but the watcher has not
/// round-tripped it yet, and until it does the snapshot has no such card. The overlay stands
/// in for it across that gap ("the placeholder stays visible until the real card arrives,
/// then hands off"), which is what keeps the one-way flow from showing a hole where the user
/// just typed a title.
case awaitingArrival(ItemID)
}
/// The lane the card is being created in the anchor, and the only item identity a placeholder
/// has before its title commits.
public var laneID: ItemID
/// What the user has typed so far. Lives here and nowhere else: there is no file to hold it.
public var draftTitle: String
public var phase: Phase
public init(laneID: ItemID, draftTitle: String = "", phase: Phase = .editing) {
self.laneID = laneID
self.draftTitle = draftTitle
self.phase = phase
}
}
// MARK: - TransientBoardState
/// Everything a board's windows share that is **not on disk** one per `BoardStore`, created with
/// it and dying with it (02-architecture.md § Changes from Kanban, settled m3).
///
/// Its reason for existing is that the store's transient state was becoming a grab-bag. Every member
/// below is filed by **how a reload treats it**, and there are exactly three kinds plus a remainder:
///
/// 1. **Item-referencing sets** `selection`, `dragMembers`, `pendingCut`. One shape
/// (`ItemReferenceSet`) and one constraint rule, *members must exist in the current universe*.
/// `resolve(against:)` applies it to each of them **independently**: a card vanishing from the
/// selection has no business disturbing a drag in flight or a pending cut, and independence is
/// the only way that stays true without three orders of operations to reason about.
/// 2. **Derived state, stored as its inputs only** `searchQuery` is kept; its *result set* is
/// deliberately absent. The filter is a live predicate re-run against each new snapshot
/// (04-interactions.md § Search), so a card an agent files mid-search appears the moment the
/// reload lands and a card edited to no longer match animates out. A stored result set would be
/// a second, staler answer to a question the snapshot can always answer, and would need a
/// re-resolution rule of its own which is exactly the accretion this type exists to stop.
/// 3. **The overlay** `newCardPlaceholder`, anchored to a lane rather than to items, discarded
/// when the lane it is anchored to goes away and handed off when the real card arrives.
///
/// The remainder is plain per-open values: `isTrashVisible` is hidden on every open and **never
/// persisted** visiting the trash is an errand, not a layout choice. It needs no reset logic
/// because this object is built fresh with its store; closing the board is the reset.
///
/// `@MainActor` because it is read by SwiftUI on the main actor and mutated by gestures there;
/// `@Observable` so the board window and its card windows re-render off the same truth.
@MainActor
@Observable
public final class TransientBoardState {
// MARK: Item-referencing sets
/// What the user has selected, re-resolved against every snapshot the store applies.
///
/// `private(set)` with mutators below because the selection is the one set with a *grammar*
/// coming (extend, range, successor-on-delete 04-interactions.md Grammar), and every one of
/// those rules will want a single funnel. Its siblings are plain `var`s: a drag and a cut are
/// set wholesale by the gesture that owns them.
public private(set) var selection: ItemReferenceSet = .empty
/// The items a drag is carrying **empty when no drag is in flight**, which is what "no drag"
/// means here rather than a separate flag.
///
/// Reload-resolved like any set: partial vanishing drops the survivors, and when the last
/// member goes the drag has emptied itself and cancels (04-interactions.md Drag and drop,
/// "an emptied drag cancels itself"). Deciding what an emptied drag *does* is the drag
/// controller's job (m5); losing the members is this rule's.
public var dragMembers: ItemReferenceSet = .empty
/// The X staging set: cut items dim in place until a paste moves them (04-interactions.md
/// Clipboard, "Cut is Finder-style deferred").
///
/// The pasteboard and the staged folder snapshots are not here they are app-wide, outlive this
/// store, and are 04's own story. This is only the *in-board* half: which of this board's items
/// are showing as cut. "Deletion voids per item" is the reload rule above, applied here for
/// free.
public var pendingCut: ItemReferenceSet = .empty
// MARK: Derived state, stored as its inputs
/// The live search field's text (04-interactions.md § Search). Empty means no search is active.
///
/// **The result set is not stored** see this type's doc comment, kind 2. The predicate runs
/// against whatever snapshot is current, so the filter is never stale, and the selection is kept
/// honest against it by `ItemReferenceSet.constrained(to:)` with the visible ids as the universe
/// the same rule a reload uses, which is why "hidden cards leave the selection" needs no code
/// of its own.
public var searchQuery: String = ""
// MARK: The overlay
/// The new-card placeholder, or `nil` when no card is being created. See `NewCardPlaceholder`
/// for what it is and `resolve(against:)` for what a reload does to it.
///
/// `private(set)`: its four legal transitions are the lifecycle methods below, and an unborn
/// card is exactly the kind of state that rots if anyone may assign it.
public private(set) var newCardPlaceholder: NewCardPlaceholder?
// MARK: Per-open values
/// Whether the trash quasi-lane 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
/// (02-architecture.md § Per-board app state). Nothing resets it a fresh store means a fresh
/// container means `false`.
public var isTrashVisible: Bool = false
public init() {}
// MARK: - Selection
/// Replaces the selection.
///
/// Minimal on purpose the selection's real grammar (extend, range, successor-on-delete) lands
/// with the board UI. 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) {
selection = ItemReferenceSet(ids: ids, liveness: liveness)
}
/// Selects nothing Escape's last step outward (04-interactions.md Grammar).
public func clearSelection() {
selection = .empty
}
// MARK: - The placeholder's lifecycle
/// Opens the inline editor for a new card in `laneID`, replacing any placeholder already open.
///
/// Replacing rather than refusing: two placeholders can never be open at once (one inline editor,
/// one focus), so a second begin is the first one being abandoned 04-interactions.md's
/// click-away discard, arriving as a new creation instead of a click.
public func beginPlaceholder(inLane laneID: ItemID) {
newCardPlaceholder = NewCardPlaceholder(laneID: laneID)
}
/// Records what the user has typed. A no-op with no placeholder open the draft has nowhere to
/// live, and inventing a placeholder to hold it would put an editor on screen that nobody asked
/// for.
public func updateDraft(_ title: String) {
newCardPlaceholder?.draftTitle = title
}
/// Marks the placeholder as waiting for the card the Writer just created.
///
/// **The Writer call is not made here.** This type stores no URLs and performs no I/O; the create
/// runs through `BoardStore.performWrite` at the UI's call site (m5), which is also the only
/// place that can decide what a *failed* create should do with the editor. All this records is
/// the id to watch for, so the overlay knows when its job is done.
///
/// A no-op with no placeholder open: nothing is awaiting anything.
public func commitPlaceholder(expecting id: ItemID) {
newCardPlaceholder?.phase = .awaitingArrival(id)
}
/// Abandons the placeholder Escape, an empty commit, a click-away (04-interactions.md
/// Grammar). Disk was never touched, so there is nothing to undo.
public func discardPlaceholder() {
newCardPlaceholder = nil
}
// MARK: - Reload
/// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot.
///
/// Called by `BoardStore` on each *successful* reload and nowhere else a failed reload leaves
/// the snapshot alone, and state over a snapshot that did not change has nothing to re-resolve
/// against.
///
/// **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.
///
/// **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 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
/// card twice. The card is looked for anywhere in the snapshot rather than only under the
/// anchor lane it arrived, and where it landed is the snapshot's business.
///
/// Otherwise the placeholder survives untouched: reloads swap the snapshot *underneath* the
/// overlay, exactly as they do underneath the selection.
///
/// `searchQuery` and `isTrashVisible` are deliberately not mentioned below. Neither references
/// an item, so no snapshot can invalidate either the query's *results* change with every
/// snapshot, which is precisely why the results are not stored here.
public func resolve(against snapshot: BoardModel) {
selection = selection.resolved(against: snapshot)
dragMembers = dragMembers.resolved(against: snapshot)
pendingCut = pendingCut.resolved(against: snapshot)
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
}
/// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot.
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 }
if case let .awaitingArrival(id) = placeholder.phase,
snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) {
return nil
}
return placeholder
}
}
+1 -1
View File
@@ -448,7 +448,7 @@ struct ItemIDValueSemanticsTests {
}
/// The consequence every `Set`/`Dictionary` keyed by `ItemID` inherits selection
/// membership included (`BoardStore.Selection`).
/// membership included (`ItemReferenceSet`).
@Test func aSetCollapsesTheTwoSpellingsToOneMember() {
let set: Set<ItemID> = [ItemID(rawValue: Self.lower), ItemID(rawValue: Self.upper)]
#expect(set.count == 1)
+6
View File
@@ -360,6 +360,12 @@ struct BoardStoreTests {
// MARK: Selection across reloads
/// The rules themselves belong to `ItemReferenceSet`/`TransientBoardState` and are proved in
/// `TransientBoardStateTests` across all three referencing sets. These stay because what they
/// pin here is the *store's* half: that `land(_:generation:origin:)` re-resolves on success and
/// only on success, read through the `selection`/`select`/`clearSelection` conveniences the
/// command sites use.
@Test("A selected item that vanished from the tree leaves the selection; the survivor stays")
func selectionDropsVanishedMembers() async throws {
let fixture = try makeBoard()
+289
View File
@@ -0,0 +1,289 @@
import Foundation
import Testing
@testable import Kanban
/// `TransientBoardState` is one claim stated four ways: **transient state may only reference items
/// the current snapshot has**. So these tests are about what survives a reload and what doesn't
/// per set, independently, and with the placeholder's lane-anchored variant of the same idea.
///
/// They drive a **real `BoardStore` over a real temp board**, exactly as `BoardStoreTests` does,
/// rather than calling `resolve(against:)` on a hand-built model: the container's contract includes
/// being called by the store's reload path, and a suite that never went through `land(_:...)` could
/// pass with that wire cut. The one exception is the same-rule test, which compares two *pure value*
/// functions and has to call one of them directly to have anything to compare.
// MARK: - Fixtures
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
/// A lane or card whose `deleted:` key is present the tombstone an agent or a hand-edit adds to a
/// file the user currently has selected, cut, or is creating a card under.
private func tombstoned(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
deleted: 2026-03-03T09:00:00Z
---
\(title) body.
"""
}
/// Two lanes and three cards enough that "overlapping but different" member sets are expressible
/// and that a set can survive the reload that empties another.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
return fixture
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4)
/// One foreign reload, start to settled the only way anything in this suite gets re-resolved.
@MainActor
private func reload(_ store: BoardStore) async {
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
}
// MARK: - Tests
@MainActor
@Suite("TransientBoardState")
struct TransientBoardStateTests {
// MARK: The sets
@Test("A vanished item leaves only the sets that held it — each set resolves independently")
func setsResolveIndependently() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Overlapping but different: card1 is selected and cut, card2 is selected and dragged,
// card3 is dragged and cut. Whatever happens to one member, two of the three sets are
// always the control.
store.transient.select([card1, card2], liveness: .live)
store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], liveness: .live)
store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], liveness: .live)
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
await reload(store)
#expect(store.transient.selection.ids == [card2], "the selection loses the member it held")
#expect(store.transient.pendingCut.ids == [card3], "so does the cut, on its own")
#expect(
store.transient.dragMembers.ids == [card2, card3],
"the drag never held card1 and must come through untouched — a member leaving one set may not disturb another"
)
#expect(store.selection == store.transient.selection, "the store's convenience is the same value")
}
@Test("The filter's universe and a reload's universe are the same rule, expressed once")
func constrainedAndResolvedAgree() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let seeded = ItemReferenceSet(ids: [card1, card2], liveness: .live)
// Direction one the live search filter (04-interactions.md § Search): card1's title and
// body both miss the query, so it is not in the visible set the predicate produced, and
// "hidden cards leave the selection" is just this intersection.
let visible: Set<ItemID> = [lane1, lane2, card2, card3]
let filtered = seeded.constrained(to: visible)
// Direction two reload survival (02-architecture.md § Live-reload resilience): the same
// member, gone from the tree instead of hidden by a predicate.
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
await reload(store)
let resolved = seeded.resolved(against: store.snapshot)
#expect(filtered.ids == [card2])
#expect(filtered == resolved, "one primitive, two universes — the two rules are one rule")
}
@Test("Tombstoning a lane ejects its cards from every referencing set — liveness is effective")
func effectiveLivenessEjectsFromEverySet() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1, card2], liveness: .live)
store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], liveness: .live)
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
await reload(store)
// The cards' own flags never changed, but their lane's did and liveness is ancestor-walked
// (02, settled): they render nowhere once 03 collapses the lane to a single trash entry, and
// nothing invisible may stay selected or pending-cut.
let survivor = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }
#expect(survivor?.isDeleted == false, "the card's own flag is untouched")
#expect(store.transient.selection.ids.isEmpty)
#expect(store.transient.pendingCut.ids == [card3], "card3's lane is untouched, so card3 stays cut")
}
@Test("Every set resolves to empty against a board whose lanes all vanished")
func everythingResolvesToNothingOnAnEmptyBoard() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1, lane1], liveness: .live)
store.transient.dragMembers = ItemReferenceSet(ids: [card2], liveness: .live)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .live)
store.transient.beginPlaceholder(inLane: lane2)
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store)
#expect(store.snapshot.lanes.isEmpty)
#expect(store.transient.selection == .empty)
#expect(store.transient.dragMembers == .empty)
#expect(store.transient.pendingCut == .empty)
#expect(store.transient.newCardPlaceholder == nil)
}
// MARK: The placeholder
@Test("A placeholder whose lane is deleted from the tree is discarded")
func placeholderDiscardedWhenItsLaneIsRemoved() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: lane2)
store.transient.updateDraft("Half a title")
// Not a tombstone the folder is gone, the way an agent's `rm -rf` or a Finder delete
// leaves it. Nothing was ever on disk for the placeholder, so there is nothing to clean up.
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store)
#expect(store.snapshot.lanes.count == 1)
#expect(store.transient.newCardPlaceholder == nil, "the placeholder's lane vanished, so it goes with it")
}
@Test("A placeholder whose lane is tombstoned is discarded — a tombstoned lane renders nowhere")
func placeholderDiscardedWhenItsLaneIsTombstoned() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: lane2)
store.transient.updateDraft("Half a title")
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
await reload(store)
// Still in the snapshot the trash renders its single entry but the lane the editor was
// sitting in is not on the board any more, which is the same vanish as far as an overlay
// anchored to it is concerned.
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.isDeleted == true)
#expect(store.transient.newCardPlaceholder == nil)
}
@Test("The placeholder hands off by discarding itself the moment its real card arrives")
func placeholderHandsOffWhenTheCardArrives() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: lane2)
store.transient.updateDraft("Filed at last")
store.transient.commitPlaceholder(expecting: card4)
#expect(store.transient.newCardPlaceholder?.phase == .awaitingArrival(card4))
// What the Writer's create leaves behind, and what the watcher then round-trips: a real
// card folder under the anchor lane, carrying the minted id.
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed at last"))
await reload(store)
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.cards.count == 2)
#expect(store.transient.newCardPlaceholder == nil, "the overlay's job ended when the card it stood in for landed")
}
@Test("The placeholder stands until its card actually arrives — a reload without it changes nothing")
func placeholderStandsUntilItsCardArrives() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: lane2)
store.transient.updateDraft("Filed at last")
store.transient.commitPlaceholder(expecting: card4)
// A reload lands in the gap between the Writer's create and the watcher noticing it the
// exact window the `.awaitingArrival` phase exists to cover. The lane is intact and the card
// is not there yet, so neither discard rule fires.
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
await reload(store)
#expect(store.snapshot.lanes.count == 3)
#expect(
store.transient.newCardPlaceholder
== NewCardPlaceholder(laneID: lane2, draftTitle: "Filed at last", phase: .awaitingArrival(card4))
)
}
@Test("An unrelated reload swaps the snapshot underneath the placeholder, draft intact")
func placeholderSurvivesAnUnrelatedReload() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginPlaceholder(inLane: lane1)
store.transient.updateDraft("Half a ti")
// An agent files a card in the other lane mid-typing. The overlay is not a card and is not
// in the snapshot, so it has nothing to lose here including the keystrokes, which live
// nowhere else.
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed by an agent"))
await reload(store)
#expect(
store.transient.newCardPlaceholder
== NewCardPlaceholder(laneID: lane1, draftTitle: "Half a ti", phase: .editing)
)
}
// MARK: Per-open values
@Test("Trash visibility and the search query default per-open and pass through a reload untouched")
func perOpenValuesDefaultAndSurviveResolve() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Fresh container, fresh store the trash is hidden on every open and there is no query.
// Nothing resets these; being built with the store is the reset.
#expect(store.transient.isTrashVisible == false)
#expect(store.transient.searchQuery.isEmpty)
store.transient.isTrashVisible = true
store.transient.searchQuery = "log"
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fix login"))
await reload(store)
// Neither references an item, so no snapshot can invalidate either and the query's
// *results* are recomputed rather than stored, which is why there is nothing else to check.
#expect(store.transient.isTrashVisible)
#expect(store.transient.searchQuery == "log")
}
}