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:
@@ -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.
|
- **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.)
|
- **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).
|
- **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.)
|
- **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.
|
- **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.
|
- **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
|
## 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
|
## Open questions
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// MARK: - BoardStore
|
||||||
|
|
||||||
/// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the
|
/// 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
|
/// 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
|
/// 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.
|
/// 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
|
/// ### What it deliberately does not own
|
||||||
///
|
///
|
||||||
/// The `FolderWatcher` itself — the registry owns one watcher and one store per board and wires
|
/// 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
|
/// 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
|
/// without a filesystem stream. And the transient state itself, which lives in its own container
|
||||||
/// here for now, and the **new-card placeholder** will live beside it — a pseudo-card with no disk
|
/// (`TransientBoardState`) rather than accreting here as fields: this type knows only *when* to
|
||||||
/// presence and no UUID, overlaid on the snapshot rather than merged into it (02-architecture.md §
|
/// re-resolve it, never what the rules are. That includes the **new-card placeholder** — a
|
||||||
/// Layering, the one named exception to the one-way flow). Nothing here precludes that: `snapshot`
|
/// pseudo-card with no disk presence and no UUID, overlaid on the snapshot rather than merged into
|
||||||
/// is a pure value swap with no identity assumptions, so an overlay can simply be rendered on top of
|
/// it (02-architecture.md § Layering, the one named exception to the one-way flow). Nothing here
|
||||||
/// whatever the latest reload produced.
|
/// 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
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
public final class BoardStore {
|
public final class BoardStore {
|
||||||
@@ -208,8 +128,17 @@ public final class BoardStore {
|
|||||||
|
|
||||||
public var isReadOnly: Bool { readOnlyLock != nil }
|
public var isReadOnly: Bool { readOnlyLock != nil }
|
||||||
|
|
||||||
/// The board's selection, re-resolved against every snapshot this store applies.
|
/// Everything shared across this board's windows that is **not on disk** — selection, drag
|
||||||
public private(set) var selection: Selection
|
/// 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
|
/// 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
|
/// `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.loadWarnings = result.warnings
|
||||||
self.reloadFailure = nil
|
self.reloadFailure = nil
|
||||||
self.readOnlyLock = nil
|
self.readOnlyLock = nil
|
||||||
self.selection = .empty
|
self.transient = TransientBoardState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Inbound signals
|
// MARK: - Inbound signals
|
||||||
@@ -472,11 +401,14 @@ public final class BoardStore {
|
|||||||
// this one did not.
|
// this one did not.
|
||||||
reloadFailure = nil
|
reloadFailure = nil
|
||||||
clearLockIfDisproved(by: origin)
|
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):
|
case let .failure(error):
|
||||||
// `snapshot`, `loadWarnings` and `selection` are untouched: a failed reload never
|
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
|
||||||
// replaces a good snapshot, and a selection over a snapshot that did not change has
|
// never replaces a good snapshot, and state over a snapshot that did not change has
|
||||||
// nothing to re-resolve against.
|
// nothing to re-resolve against.
|
||||||
reloadFailure = error
|
reloadFailure = error
|
||||||
// `readOnlyLock == nil` rather than an unconditional assignment: a root that vanished
|
// `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.
|
// The three thin pass-throughs to `transient`, and the only ones.
|
||||||
///
|
//
|
||||||
/// Minimal on purpose — the transient-state container will absorb this along with drag state and
|
// **Conveniences, not a second home.** The selection is the transient state every command site
|
||||||
/// the pending cut, and give the selection its real grammar (extend, range, successor-on-delete).
|
// touches — menu validation, ⌫, paste anchoring, Select All — and `store.selection` reads better
|
||||||
/// Deliberately *not* filtered against the snapshot: a caller selects what it is rendering, and
|
// at each of them than `store.transient.selection` while meaning exactly the same thing. Nothing
|
||||||
/// `Selection.resolved(against:)` on the next reload is what keeps the set honest over time.
|
// 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) {
|
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).
|
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar).
|
||||||
public func clearSelection() {
|
public func clearSelection() {
|
||||||
selection = .empty
|
transient.clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Quiescence
|
// MARK: - Quiescence
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -448,7 +448,7 @@ struct ItemIDValueSemanticsTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The consequence every `Set`/`Dictionary` keyed by `ItemID` inherits — selection
|
/// The consequence every `Set`/`Dictionary` keyed by `ItemID` inherits — selection
|
||||||
/// membership included (`BoardStore.Selection`).
|
/// membership included (`ItemReferenceSet`).
|
||||||
@Test func aSetCollapsesTheTwoSpellingsToOneMember() {
|
@Test func aSetCollapsesTheTwoSpellingsToOneMember() {
|
||||||
let set: Set<ItemID> = [ItemID(rawValue: Self.lower), ItemID(rawValue: Self.upper)]
|
let set: Set<ItemID> = [ItemID(rawValue: Self.lower), ItemID(rawValue: Self.upper)]
|
||||||
#expect(set.count == 1)
|
#expect(set.count == 1)
|
||||||
|
|||||||
@@ -360,6 +360,12 @@ struct BoardStoreTests {
|
|||||||
|
|
||||||
// MARK: Selection across reloads
|
// 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")
|
@Test("A selected item that vanished from the tree leaves the selection; the survivor stays")
|
||||||
func selectionDropsVanishedMembers() async throws {
|
func selectionDropsVanishedMembers() async throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user