From 23e761120f17bb54fd2749d4bf1d893e6cc68fb6 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 21:06:26 -0400 Subject: [PATCH] Give transient UI state an explicit home in the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DESIGN/02-architecture.md | 4 +- Kanban/LiveStore/BoardStore.swift | 149 +++------ Kanban/LiveStore/TransientBoardState.swift | 369 +++++++++++++++++++++ KanbanTests/BoardLoaderTests.swift | 2 +- KanbanTests/BoardStoreTests.swift | 6 + KanbanTests/TransientBoardStateTests.swift | 289 ++++++++++++++++ 6 files changed, 712 insertions(+), 107 deletions(-) create mode 100644 Kanban/LiveStore/TransientBoardState.swift create mode 100644 KanbanTests/TransientBoardStateTests.swift diff --git a/DESIGN/02-architecture.md b/DESIGN/02-architecture.md index e74ba3f..97e107d 100644 --- a/DESIGN/02-architecture.md +++ b/DESIGN/02-architecture.md @@ -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 diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index abc6b99..0954b65 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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 - public var liveness: Liveness - - public init(ids: Set = [], 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 = [] - 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, 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 diff --git a/Kanban/LiveStore/TransientBoardState.swift b/Kanban/LiveStore/TransientBoardState.swift new file mode 100644 index 0000000..5ad9b9c --- /dev/null +++ b/Kanban/LiveStore/TransientBoardState.swift @@ -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 + public var liveness: Liveness + + public init(ids: Set = [], 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) -> 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 { + var universe: Set = [] + 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, 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 + } +} diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index 773d81d..c64c438 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -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(rawValue: Self.lower), ItemID(rawValue: Self.upper)] #expect(set.count == 1) diff --git a/KanbanTests/BoardStoreTests.swift b/KanbanTests/BoardStoreTests.swift index 9dd310b..cd068be 100644 --- a/KanbanTests/BoardStoreTests.swift +++ b/KanbanTests/BoardStoreTests.swift @@ -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() diff --git a/KanbanTests/TransientBoardStateTests.swift b/KanbanTests/TransientBoardStateTests.swift new file mode 100644 index 0000000..5e100d8 --- /dev/null +++ b/KanbanTests/TransientBoardStateTests.swift @@ -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 = [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") + } +}