Give transient UI state an explicit home in the store
TransientBoardState, one per store, holds state by how a reload treats it: item-referencing sets (selection, drag membership, pending cut) share one shape and one constraint rule — members must exist in the current universe — applied in two directions by one primitive, so the search filter's hidden-cards rule and reload survival are one rule expressed once; derived state is stored as its inputs only (the query, never its result set); and the new-card placeholder is a lane-anchored overlay with no UUID until commit, discarded when its lane vanishes or tombstones, handed off when the created card's UUID appears. Trash visibility rides along per-open, never persisted. The decision is written back into DESIGN/02 § Changes from Kanban — the TBD is closed. Full suite 352 tests in 64 suites green. Two findings filed. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -68,88 +68,6 @@ public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConv
|
||||
}
|
||||
}
|
||||
|
||||
/// Which side of the live/tombstoned boundary something sits on.
|
||||
///
|
||||
/// A selection is **homogeneous by liveness** (04-interactions.md § The trash): it never mixes live
|
||||
/// and tombstoned items, so the side is a property of the selection as a whole rather than of each
|
||||
/// member — which is exactly what makes re-resolution across a reload a matching rule rather than a
|
||||
/// partition.
|
||||
public enum Liveness: Sendable, Equatable {
|
||||
case live
|
||||
case trashed
|
||||
|
||||
/// The side an item's own tombstone flag puts it on. `Lane.isDeleted`/`Card.isDeleted` are
|
||||
/// presence-of-the-key, not validity, so a malformed `deleted:` still reads as trashed — see
|
||||
/// their doc comments in `BoardModel.swift`.
|
||||
init(isDeleted: Bool) {
|
||||
self = isDeleted ? .trashed : .live
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
/// The board's selection: a set of UUIDs over the snapshot, plus the liveness side it lives on.
|
||||
///
|
||||
/// **UUIDs, never indices or copies of items** (02-architecture.md § Live-reload resilience,
|
||||
/// "Selection survives reloads by UUID"): a reload swaps the whole snapshot as a value, and
|
||||
/// anything holding positions or item copies would be silently wrong the moment an agent files a
|
||||
/// card. Re-resolution against the new snapshot is `resolved(against:)`, and it is a *pure
|
||||
/// function* on purpose — the transient-state container (02-architecture.md § Changes from Kanban)
|
||||
/// will absorb this type and apply the same rule to drag membership and the pending cut, so the
|
||||
/// rule must be reusable rather than buried in the store's reload path.
|
||||
public struct Selection: Sendable, Equatable {
|
||||
public var ids: Set<ItemID>
|
||||
public var liveness: Liveness
|
||||
|
||||
public init(ids: Set<ItemID> = [], liveness: Liveness = .live) {
|
||||
self.ids = ids
|
||||
self.liveness = liveness
|
||||
}
|
||||
|
||||
/// Nothing selected, on the live side — the state a board opens in and the state
|
||||
/// `BoardStore.clearSelection()` returns to.
|
||||
public static let empty = Selection()
|
||||
|
||||
public var isEmpty: Bool { ids.isEmpty }
|
||||
|
||||
/// This selection re-grounded on `snapshot`: the members that are still there, **on the same
|
||||
/// liveness side**, and nothing else.
|
||||
///
|
||||
/// Two rules, both settled in 02-architecture.md § Live-reload resilience:
|
||||
///
|
||||
/// - **Vanished members leave silently.** No substitute is invented, no successor is picked —
|
||||
/// an empty result is a legitimate outcome. (App-mediated deletion is deliberately different:
|
||||
/// ⌫ selects the successor sibling, because that is an act rather than a surprise —
|
||||
/// 04-interactions.md ▸ The map. That belongs to the delete command, not here.)
|
||||
/// - **A liveness flip is a vanish.** A foreign edit that tombstones a selected live card — or
|
||||
/// restores a selected tombstoned one — ejects it, keeping 04-interactions.md's
|
||||
/// homogeneous-by-liveness invariant true across reloads so menu validation never sees a
|
||||
/// mixed selection.
|
||||
///
|
||||
/// The liveness that is matched is **effective — ancestor-walked** (settled): a card counts as
|
||||
/// trashed if its own flag *or its lane's* says so. Tombstoning a lane therefore ejects its
|
||||
/// cards from a live selection even though their own flags never changed — the card renders
|
||||
/// nowhere once 03-board-ui.md collapses the lane to a single trash entry, and nothing
|
||||
/// invisible may stay selected, drag-included, or pending-cut.
|
||||
public func resolved(against snapshot: BoardModel) -> Selection {
|
||||
guard !ids.isEmpty else { return self }
|
||||
|
||||
var survivors: Set<ItemID> = []
|
||||
survivors.reserveCapacity(ids.count)
|
||||
for lane in snapshot.lanes {
|
||||
if ids.contains(lane.id), Liveness(isDeleted: lane.isDeleted) == liveness {
|
||||
survivors.insert(lane.id)
|
||||
}
|
||||
for card in lane.cards where ids.contains(card.id) {
|
||||
if Liveness(isDeleted: lane.isDeleted || card.isDeleted) == liveness {
|
||||
survivors.insert(card.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Selection(ids: survivors, liveness: liveness)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BoardStore
|
||||
|
||||
/// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the
|
||||
@@ -168,18 +86,20 @@ public struct Selection: Sendable, Equatable {
|
||||
/// 2. **The failure rules.** A failed reload never replaces a good snapshot; an ordinary failure
|
||||
/// raises the banner condition and leaves editing alone; a failure after a bracketed wholesale
|
||||
/// operation locks the board read-only; the next success clears both.
|
||||
/// 3. **Selection across reloads.** Re-resolved by UUID and liveness on every applied snapshot.
|
||||
/// 3. **Transient state across reloads.** `transient.resolve(against:)` runs on every applied
|
||||
/// snapshot, re-grounding the selection, the drag, the pending cut, and the new-card placeholder.
|
||||
///
|
||||
/// ### What it deliberately does not own
|
||||
///
|
||||
/// The `FolderWatcher` itself — the registry owns one watcher and one store per board and wires
|
||||
/// them together (`watcherBrackets`, `handleWatcherEvent(_:)`), so this type can be built and tested
|
||||
/// without a filesystem stream. The transient-state container is also still to come: selection lives
|
||||
/// here for now, and the **new-card placeholder** will live beside it — a pseudo-card with no disk
|
||||
/// presence and no UUID, overlaid on the snapshot rather than merged into it (02-architecture.md §
|
||||
/// Layering, the one named exception to the one-way flow). Nothing here precludes that: `snapshot`
|
||||
/// is a pure value swap with no identity assumptions, so an overlay can simply be rendered on top of
|
||||
/// whatever the latest reload produced.
|
||||
/// without a filesystem stream. And the transient state itself, which lives in its own container
|
||||
/// (`TransientBoardState`) rather than accreting here as fields: this type knows only *when* to
|
||||
/// re-resolve it, never what the rules are. That includes the **new-card placeholder** — a
|
||||
/// pseudo-card with no disk presence and no UUID, overlaid on the snapshot rather than merged into
|
||||
/// it (02-architecture.md § Layering, the one named exception to the one-way flow). Nothing here
|
||||
/// makes that awkward: `snapshot` is a pure value swap with no identity assumptions, so an overlay
|
||||
/// is simply rendered on top of whatever the latest reload produced.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class BoardStore {
|
||||
@@ -208,8 +128,17 @@ public final class BoardStore {
|
||||
|
||||
public var isReadOnly: Bool { readOnlyLock != nil }
|
||||
|
||||
/// The board's selection, re-resolved against every snapshot this store applies.
|
||||
public private(set) var selection: Selection
|
||||
/// Everything shared across this board's windows that is **not on disk** — selection, drag
|
||||
/// membership, the pending cut, the search query, the new-card placeholder, trash visibility
|
||||
/// (02-architecture.md § Changes from Kanban).
|
||||
///
|
||||
/// **Created with the store and dying with it**, which is what makes its per-open values per-open
|
||||
/// without any reset logic: closing the board is the reset. `let`, because it is one container
|
||||
/// for the store's whole life — the windows observe *it*, not a slot on this class.
|
||||
///
|
||||
/// The store's only involvement is `resolve(against:)` on every successful reload; the rules that
|
||||
/// call answers live over there.
|
||||
public let transient: TransientBoardState
|
||||
|
||||
/// Where the board is **now**. Follows the folder: a rename or a move absorbed through
|
||||
/// `relocate(to:)` updates it, so every URL derived from it — the Writer's paths, card-window
|
||||
@@ -356,7 +285,7 @@ public final class BoardStore {
|
||||
self.loadWarnings = result.warnings
|
||||
self.reloadFailure = nil
|
||||
self.readOnlyLock = nil
|
||||
self.selection = .empty
|
||||
self.transient = TransientBoardState()
|
||||
}
|
||||
|
||||
// MARK: - Inbound signals
|
||||
@@ -472,11 +401,14 @@ public final class BoardStore {
|
||||
// this one did not.
|
||||
reloadFailure = nil
|
||||
clearLockIfDisproved(by: origin)
|
||||
selection = selection.resolved(against: result.model)
|
||||
// The one place transient state is re-grounded. It goes last, after `snapshot` is the
|
||||
// new one, because a view woken by the snapshot's change must never observe a selection
|
||||
// still pointing at the old tree.
|
||||
transient.resolve(against: result.model)
|
||||
|
||||
case let .failure(error):
|
||||
// `snapshot`, `loadWarnings` and `selection` are untouched: a failed reload never
|
||||
// replaces a good snapshot, and a selection over a snapshot that did not change has
|
||||
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
|
||||
// never replaces a good snapshot, and state over a snapshot that did not change has
|
||||
// nothing to re-resolve against.
|
||||
reloadFailure = error
|
||||
// `readOnlyLock == nil` rather than an unconditional assignment: a root that vanished
|
||||
@@ -673,21 +605,30 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
// MARK: - Selection (delegated)
|
||||
|
||||
/// Replaces the selection.
|
||||
///
|
||||
/// Minimal on purpose — the transient-state container will absorb this along with drag state and
|
||||
/// the pending cut, and give the selection its real grammar (extend, range, successor-on-delete).
|
||||
/// Deliberately *not* filtered against the snapshot: a caller selects what it is rendering, and
|
||||
/// `Selection.resolved(against:)` on the next reload is what keeps the set honest over time.
|
||||
// The three thin pass-throughs to `transient`, and the only ones.
|
||||
//
|
||||
// **Conveniences, not a second home.** The selection is the transient state every command site
|
||||
// touches — menu validation, ⌫, paste anchoring, Select All — and `store.selection` reads better
|
||||
// at each of them than `store.transient.selection` while meaning exactly the same thing. Nothing
|
||||
// is stored here: `selection` is computed and the two mutators forward, so there is no second
|
||||
// copy to go stale. Drag membership, the pending cut, the query and the placeholder get no such
|
||||
// shortcuts — they have one or two call sites each, and a delegate per field would be the
|
||||
// grab-bag reassembling itself on this class.
|
||||
|
||||
/// The board's selection, re-resolved against every snapshot this store applies —
|
||||
/// `TransientBoardState.selection` under a shorter name.
|
||||
public var selection: ItemReferenceSet { transient.selection }
|
||||
|
||||
/// Replaces the selection — `TransientBoardState.select(_:liveness:)`, which owns the semantics.
|
||||
public func select(_ ids: Set<ItemID>, liveness: Liveness) {
|
||||
selection = Selection(ids: ids, liveness: liveness)
|
||||
transient.select(ids, liveness: liveness)
|
||||
}
|
||||
|
||||
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar).
|
||||
public func clearSelection() {
|
||||
selection = .empty
|
||||
transient.clearSelection()
|
||||
}
|
||||
|
||||
// MARK: - Quiescence
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user