Files
lanework/Kanban/LiveStore/BoardStore.swift
T
rzen c339b4cecf Implement live accessibility announcements
The board speaks when files change under the user, per DESIGN/10 § Live
board announcements. BoardDiff is the pure snapshot summarizer (identity
sets for cards/lanes added/edited/moved/deleted — ids, not tallies, so
pro-m1's semantic commit engine can build on it; edited = rendered
content only, moved beats edited, implied events don't steal the
subject). BoardAnnouncer is the decision seam: focusOutcome computes the
vanishing-focus sentence and the walk-up-then-sideways recovery (next
lane by order, else previous, board container only when none remain,
never the trash); speech(for:) is the one-sentence precedence ladder —
raised condition > bracket completion > cleared condition > vanished
focus > digest — foreign-only for the last two rungs, so app-mediated
echoes stay silent.

BoardStore.land assembles ReloadFacts and posts exactly one sentence per
reload through the injectable announce outlet (AccessibilityAnnouncer,
medium priority, never interrupting). Selection recovery layers on top
of ItemReferenceSet re-resolution — survivors veto, the emptied
selection lands on the vanished item's lane and re-arms ⌘N's active-lane
memory. performWholesale(announcing:) arms a completion phrase consumed
by the closing reload — nil on every base bracket today; pro-m1 fills
git phrasings. Locks raised outside the reload path (vanished root,
unwritable location) announce through the same ladder, and the banner
strip is a labeled "Board status" container whose row labels are the
announced sentences (AccessibilityPhrases.bannerLabel — one string for
eye and ear).

Announcements classify at reload granularity (WatchOrigin) as a
deliberate interim: DESIGN/02's EchoLedger (per-file classification, the
announcer's specified input, git-free) was scheduled with the
auto-committer that the edition split moved to pro-m1 — filed on the
Redesign board for a ruling. 1533 unit tests green, both schemes build.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 08:15:53 -04:00

3784 lines
212 KiB
Swift

import Foundation
import Observation
import os
// The one UI import in the store layer, and it earns its place: 03-board-ui.md § Motion puts the
// animate-or-snap split on the *reload*, and a reload lands here. `Motion` owns the decision and
// every curve; this file owns nothing but the `withAnimation` around the assignment (see `land`).
import SwiftUI
// MARK: - Vocabulary
/// Why a board is refusing writes — the read-only lock's cause, and now the whole of the
/// vocabulary 02-architecture.md names.
///
/// The three cases share one *scope* (§ "The lock's scope") — every mutating command disabled
/// across every window sharing the store, drops refused, ⌘-drag moves degraded to copies, editor
/// buffers kept but their debounced saves suspended — and differ only in cause and in **what
/// clears them**, which is the one thing this enum's cases are actually asked about (see
/// `BoardStore.land(_:generation:origin:)`). The banner turns a case of this into user-facing
/// phrasing (§ The banner surface); the store only owns the truth of it.
public enum ReadOnlyLockReason: Sendable, Equatable {
/// A reload that followed a bracketed wholesale operation failed, so the last-good snapshot on
/// screen may describe a tree that no longer exists — after a branch switch, a different branch
/// entirely. Writes derived from it would land nonsense, so every write is refused until a
/// reload succeeds (02-architecture.md § Live-reload resilience, "A failed reload after a
/// bracketed operation locks the board read-only").
///
/// **Clears on the next successful reload, whatever its origin** — typically once the offending
/// file is fixed.
case bracketedReloadFailed
/// The board's root is gone and its bookmark re-resolution found nothing: the volume unmounted,
/// or the folder was deleted in Finder while the board was open (02-architecture.md §
/// Write-failure surfacing, "A vanished board root locks the board read-only"). Every write
/// would land nowhere, so the last-good snapshot stays on screen, read-only.
///
/// **Clears on the next successful reload, whatever its origin**: a reload can only succeed if
/// the root is back, so success *is* the return signal. Pending dirty buffers then save
/// normally.
case vanishedRoot
/// The board opened somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a
/// read-only share) or a permission-denied folder (02-architecture.md § Write-failure
/// surfacing, "An unwritable board location enters the read-only lock at open"). Failing
/// loudly, specifically, *once* beats letting every gesture fail one at a time.
///
/// **Clears only on a successful *reconciling* reload whose writability re-probe passes** —
/// unlike its two siblings, whose cause a successful reload disproves by itself. A board on a
/// read-only DMG reloads perfectly all day long; only the probe (§ "Writability re-probes on
/// every reconciling reload" — wake, activation) can tell that the permission or the mount
/// actually changed.
case unwritableLocation
}
/// The refusal `BoardStore.performWrite` throws when the board is locked read-only.
///
/// **Deliberately temporary, and deliberately not a `BoardWriteError`.** The lock is a *store*
/// condition, not a filesystem outcome: nothing was attempted, no path failed, and folding it into
/// `BoardWriteError.io(message:)` would misrepresent a policy refusal as an I/O error in the one
/// place — the banner — where the distinction is the whole point. Widening `BoardWriteError` to
/// carry a refusal case is the banner card's job (02-architecture.md § Write-failure surfacing:
/// "The operation is a closed enum, not a string"), and it will unify this vocabulary with the
/// Writer's. Until then this thin error keeps `performWrite`'s honesty at the cost of an untyped
/// `throws` on its signature.
public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConvertible {
case readOnlyLocked(ReadOnlyLockReason)
public var description: String {
switch self {
case let .readOnlyLocked(reason):
"the board is read-only (\(reason))"
}
}
}
/// What became of a card-body save — the card window's Edit buffer meeting disk
/// (05-card-window.md ▸ Edit; `BoardStore.writeCardBody(inCard:body:)`).
///
/// A returned value rather than a thrown error, because **four of the five cases are not failures**
/// and the caller's response to each differs: only `.written` and `.unchanged` mean the buffer may
/// stop being held dirty. Making them one enum is what keeps that decision in one `switch` rather
/// than spread across a `try?` and two guards.
public enum CardBodyWriteOutcome: Sendable, Equatable {
/// The bytes landed. The buffer matches disk; the echoing reload is now on its way.
case written
/// **Nothing to write** — the body on disk already reads exactly like the buffer. The three-gate
/// write rule's outcome (05 ▸ Write rules: untouched, reverted, or the echo of an external
/// edit), and as good as `.written` from the buffer's point of view: disk says what the user
/// means it to say, and nothing was re-serialized to make that true.
case unchanged
/// The board is locked read-only, so the save is **suspended, not failed** (02-architecture.md §
/// the lock's scope: "editor buffers kept but their debounced saves suspended"). The buffer stays
/// dirty, the standing lock row already explains why, and nothing is posted — a banner per
/// suppressed tick would bury the row that matters under echoes of itself.
case suspended(ReadOnlyLockReason)
/// The card is not in this board's tree at all any more — hard-deleted in Finder, or moved to
/// another board. **Not a failure either**: there is nowhere for the text to land, which is 05 ▸
/// Deletion & lifecycle's own answer ("A card hard-deleted externally (folder gone) discards
/// both — nowhere left to write"). A *tombstoned* card is not this case; it is still on disk and
/// is written to.
case vanished
/// The write was attempted and failed. The banner has already been posted by `performWrite`; the
/// buffer must stay dirty, and a close standing on it is `DirtyBufferGuard`'s modal moment.
case failed(BoardWriteError)
}
/// What came of opening a card's file in the raw-source outlet (05-card-window.md ▸ Raw source
/// outlet; `BoardStore.readCardSource(inCard:)`).
///
/// Three cases because the *entry* can be refused, which is the half of the outlet the design leaves
/// to the implementation: 05 fixes what Apply does with a bad buffer and says nothing about a file
/// that cannot be shown at all. The settled reading is that source mode does not open — see
/// `CardRawSourceSession.enter()`.
public enum RawSourceReadOutcome: Sendable, Equatable {
/// The file, byte-honest, as the editor will show it.
case read(String)
/// The card is not live in this board any more — hard-deleted, moved away, or tombstoned. The
/// window is dismissing itself in the same breath; there is nothing to open.
case vanished
/// The file could not be read, or is not UTF-8. The alert names it and the toggle stays
/// unchecked; nothing on disk was touched.
case failed(BoardWriteError)
}
/// What came of a raw-source Apply (05-card-window.md ▸ Raw source outlet;
/// `BoardStore.applyCardSource(inCard:text:)`).
///
/// `CardBodyWriteOutcome`'s shape and for its reason — the caller holds a buffer and has to know
/// whether it may stop holding it — plus the one case the body write cannot have: a proposal that
/// would not load. **Only `.applied`, `.unchanged` and `.vanished` leave source mode**; the other
/// three keep the buffer on screen with its text intact.
public enum RawSourceApplyOutcome: Sendable, Equatable {
/// The bytes landed exactly as typed. The echoing reload refreshes every window.
case applied
/// The file already read exactly like the buffer, so nothing was written — an Apply on a buffer
/// that was only read. As good as `.applied`: disk says what the user means it to say, and no
/// `mtime` churn, watcher round-trip or empty commit was spent making that true.
case unchanged
/// **The text would not load** — the fail-fast parse refused it (`BoardLoader.validateCardIndex`).
/// Nothing was attempted and nothing changed: source mode stays open with the detailed alert, and
/// the toggle stays checked (05: "a failed validation keeps source mode open").
case invalid(BoardLoadError)
/// The board is locked read-only. Suspended rather than failed, `CardBodyWriteOutcome.suspended`'s
/// rule: the buffer is kept, the standing lock row is the message, and nothing is posted.
case suspended(ReadOnlyLockReason)
/// The card left the board (or was tombstoned) under the open buffer. 05 ▸ Deletion & lifecycle
/// is explicit that this buffer discards rather than writes — "a foreign delete is never reverted
/// by a stale buffer" — so source mode closes with nothing written.
case vanished
/// The write was attempted and failed; `performWrite` has already posted the banner. The buffer
/// stays on screen, because the text is only in it.
case failed(BoardWriteError)
}
/// What a cross-board drop is doing to the items it carries — the **effective** operation the
/// locality model resolved (04-interactions.md ▸ Drag and drop, settled).
///
/// Not a modifier and not a direction: by the time a store sees one of these the Finder volume
/// model has already been applied — within a board a drag is a move, between boards a copy, ⌥
/// forces copy and ⌘ forces move, each a no-op where it is already the default — and the badge the
/// user was looking at said exactly this. Two cases and no `.none`: a drag with no valid proposal
/// never reaches a commit at all (▸ Drag and drop, rule 2: "release with no valid proposal
/// cancels").
public enum TransferOperation: Sendable, Equatable {
/// Fresh-GUID duplicates land at the drop, originals stay, `created` is kept — a copy is a
/// fork (01-storage-format.md).
case copy
/// A real filesystem move: identity travels, and only the import boundary remints, per folder
/// (01-storage-format.md's per-folder degradation).
case move
}
// MARK: - BoardStore
/// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the
/// board window renders (02-architecture.md § Layering ▸ Components).
///
/// **It enforces the one-way flow — files → watcher → loader → store → views — by never mutating
/// its snapshot from the write path.** A user action runs the Writer, the Writer touches disk, the
/// watcher notices, and the change arrives here as a reload like any external edit. The app trusts
/// its own writes no more than anyone else's; that is what makes external editors and agents
/// first-class, and it is why there is no `snapshot` setter anywhere below `apply(_:generation:)`.
///
/// ### What this type actually owns
///
/// 1. **The reload pipeline.** At most one tree walk in flight, off the main actor; signals arriving
/// during one coalesce into a single follow-up; only the newest result applies.
/// 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. **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. 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 {
// MARK: Read-side state
/// The last good tree walk. Replaced wholesale by a successful reload and **never** by the write
/// path — see the type's doc comment for why. A failed reload leaves it exactly as it was.
public private(set) var snapshot: BoardModel
/// How many snapshots this store has applied, ever — a counter, not a version.
///
/// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold):
/// a drop's overlay stands until "the next snapshot application on that store", and *application*
/// is the event, not change. A reload that produced an identical model still ends the round trip
/// the overlay was covering — comparing `snapshot` values would leave the overlay standing
/// exactly when the write turned out to be a no-op.
public private(set) var snapshotGeneration: Int = 0
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
/// describe the tree currently on screen.
public private(set) var loadWarnings: [LoadWarning]
/// The cards the load that produced `snapshot` found holding loose files, exactly as the loader
/// reported them — the loose-file carve-out's detection channel (01-storage-format.md § Fractal
/// layout ▸ Rules, settled 2026-07-28). Replaced with the snapshot, like `loadWarnings`, so it
/// always describes the tree currently on screen.
///
/// **Nothing renders it.** A loose file is not content — it reaches no view, and the card it
/// sits in draws exactly as it would without it. Its one consumer is
/// `relocateLooseCardFiles()`, immediately below the reload that produced it.
public private(set) var looseCardFiles: [LooseCardFiles]
/// The legacy `deleted:` keys the load that produced `snapshot` found — the retired tombstone
/// model's migration input (01-storage-format.md § Deletion, resettled 2026-07-28), in the
/// loose-file channel's idiom and replaced with the snapshot exactly as it is.
///
/// **Nothing renders it either.** Its one consumer is `migrateLegacyTombstones()`, immediately
/// below the reload that produced it.
public private(set) var legacyTombstones: [LegacyTombstone]
/// The standing read-side condition: the error from the last reload that failed, `nil` when the
/// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path
/// and what is wrong with it — which is the whole of what the banner needs to render
/// (02-architecture.md § Live-reload resilience). This is a *condition*, not a one-shot: it
/// stands until a reload succeeds, and it heals without ceremony when one does.
public private(set) var reloadFailure: BoardLoadError?
/// Non-`nil` while the board refuses writes. Cleared by the next successful reload, per "the
/// next successful reload clears both the banner and the lock".
public private(set) var readOnlyLock: ReadOnlyLockReason?
public var isReadOnly: Bool { readOnlyLock != nil }
/// 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
/// keys, Reveal in Finder — re-derives at the new location (02-architecture.md §
/// Write-failure surfacing, "A renamed or moved board root follows its file identity").
///
/// Observed, deliberately: the window title's folder-name fallback reads this, and a rename in
/// Finder should be visible in the title bar without anything else being told.
///
/// `snapshot.rootURL` is the root the *last successful reload* walked, and therefore lags this
/// by exactly one reload during an absorption. That is not a second source of truth: the
/// relocation is always followed by the watcher reattach whose reconciling reload rebuilds the
/// snapshot at the new root, after which the two agree again.
public private(set) var rootURL: URL
// MARK: Banners
/// The board window's banner strip, as a model (02-architecture.md § The banner surface).
///
/// **Owned, not injected**, and the reason is the hosting rule: the strip is "hosted by the
/// window of origin", and a board window's strip has exactly one lifetime — this store's. A
/// card window (m6) gets its *own* center for its own save, attachment, and raw-source Apply
/// failures, and re-homes those rows here when it closes; injecting a shared center would
/// erase precisely that distinction.
///
/// It holds only what nothing else does — one-shot write failures, loss rows, the history
/// suspension, in-progress operations, passive signposts. The lock and the reload breakage stay
/// this store's own state and are composed in at render time by `bannerRows`.
public let banners = BannerCenter()
/// The rows the board window's strip renders, in precedence order.
///
/// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and
/// `banners` holds the rest, so a stored array would be a third copy waiting to go stale. The
/// ordering rule itself lives in `BannerCenter.rows(...)`, which is pure and tested on its own.
public var bannerRows: [BannerRow] {
BannerCenter.rows(
lock: readOnlyLock,
breakage: reloadFailure,
oneShots: banners.oneShots,
losses: banners.losses,
suspension: banners.historySuspension,
operations: banners.operations,
signposts: banners.signposts
)
}
// MARK: Wiring
/// The watcher's bracket calls, injected rather than owned: the registry holds the watcher and
/// the store together, and a store that reached into a watcher it did not own could not be
/// tested without one. `nil` means "no watcher attached" — every operation below still behaves,
/// it simply has nothing to suspend, which is exactly the shape unit tests want.
@ObservationIgnored
public var watcherBrackets: (begin: @MainActor () -> Void, end: @MainActor () -> Void)?
/// What to do when the watched root changes identity — injected for the same reason the
/// brackets are: the response needs the board's **security-scoped bookmark**, and this store
/// does not own one (the registry does, along with the watcher that must be re-attached and the
/// last-known path that arms the return detection). A store that reached for a bookmark it did
/// not hold could not be built or tested without a registry.
///
/// `nil` keeps the documented no-op: the last-good snapshot stays on screen, which is what
/// every other failure path here does, and which is exactly the shape unit tests and any
/// storeless use want. `BoardStoreRegistry` wires it to its own recovery loop.
@ObservationIgnored
public var rootChangeDelegate: (@MainActor () -> Void)?
/// The registry's live write-through for this board's title, icon, and iconColor
/// (02-architecture.md § Per-board app state, "these three refresh whenever an open board's
/// reload changes them"). Injected the same way `rootChangeDelegate` is, and for the same
/// reason: the write-through needs this board's **registry record id**, which this store does
/// not own — `BoardWindowHost.configureWindow` wires it once a session's recordID exists,
/// mirroring how it wires `windowController.onFrameChanged` right beside it.
///
/// Called on **every** successful reload, whether or not the board-level display state
/// actually changed. The "did it change" comparison is against the registry's *cached*
/// record, not against this store's own previous snapshot — the registry is the only side
/// that knows the cached value, so `BoardRegistry.syncDisplayState` is what turns a call that
/// changed nothing into a no-op. `nil` (no watcher-backed session, a storeless test) simply
/// means nothing is listening, exactly like `rootChangeDelegate`'s `nil`.
@ObservationIgnored
public var displayStateDelegate: (@MainActor () -> Void)?
/// **Where this board's inverses go** — the undo/redo substrate every write below registers into
/// (13-native-undo.md ▸ Rules: "Registration at the Writer boundary … each Writer call site
/// registers the inverse operation, computed from the pre-write snapshot the store already
/// holds").
///
/// The store is the Writer boundary: every app-mediated mutation in the app goes through one of
/// the methods below and out through `performWrite`, which is precisely the set of call sites 13
/// names. So the sink belongs here, injected like `watcherBrackets` and for the same reason — the
/// stack is **the session's**, "one stack per board, owned by the board session", and a store that
/// made its own would be a second answer to which stack a board has.
/// `AppModel.beginSession` wires it the moment the session's provider exists.
///
/// **Weak, deliberately.** The session owns both the store and the provider, and the provider's
/// steps hold closures over *this* store: a strong reference here would close that loop, leaving a
/// board that could only be freed by remembering to empty its undo stack first. `nil` — no session
/// yet, a storeless test, a board whose stack has been cleared away — keeps every method below
/// behaving exactly as it did before this milestone, registering nothing, which is `watcherBrackets`'
/// `nil` rule restated for a second seam.
@ObservationIgnored
public weak var history: (any HistoryProviding)?
// MARK: Reload machinery
/// Monotonic id of the most recently *started* reload — and therefore also the number of tree
/// walks this store has run since it opened, which is what makes the coalescing rule assertable
/// from a test rather than merely plausible.
///
/// Two rules are expressed as comparisons against it: **only the newest result applies** (a
/// result whose generation is no longer the current one is dropped), and **the wholesale
/// expectation binds to a load started after it was armed** (`wholesaleReloadFloor`).
@ObservationIgnored
private(set) var reloadGeneration = 0
/// Whether a tree walk is running. At most one ever is: a second concurrent walk would buy
/// nothing (both would produce the same snapshot) and would make "the newest result wins" a race
/// rather than a rule.
@ObservationIgnored
private var reloadInFlight = false
/// A reload owed but not started, because one was already running when the signal arrived — a
/// **flag, not a queue**: any number of signals during one walk coalesce into exactly one
/// follow-up, because the follow-up is a full tree walk that covers all of them. The origin is
/// merged by the watcher's own precedence rule (`reconciling > appMediated > foreign`) so a
/// foreign event folding into a pending reconciling one never downgrades it.
@ObservationIgnored
private var pendingReload: WatchOrigin?
/// The generation from which a bracketed wholesale operation's expectation applies, or `nil`
/// when no operation is outstanding.
///
/// **Why a floor and not a boolean.** The rule is "the reload that ends this wholesale operation
/// must succeed, or the board locks" — and a boolean cannot tell that reload apart from one that
/// was *already in flight* when the operation ended, which observed a tree from before the
/// operation touched it and therefore proves nothing about the result. Arming stores
/// `reloadGeneration + 1`: the next walk to be *started*. An in-flight walk's generation is
/// below the floor and passes through without consuming it; the first walk started at or after
/// it consumes it — succeeding clears everything as usual, failing engages the lock.
///
/// Ordinary (non-wholesale) reload failures never set the lock, because ordinary breakage is
/// per-file: the snapshot still describes the tree and editing around the broken file is safe.
@ObservationIgnored
private var wholesaleReloadFloor: Int?
/// What the outstanding wholesale operation wants said when its reload lands, or `nil` for one
/// that has nothing to announce — **"bracketed operations announce once, at completion … never
/// their internal churn"** (10-accessibility.md ▸ Live board announcements).
///
/// Stored beside the floor and consumed by the same reload, because the announcement's whole
/// claim is that the operation *finished*: a phrase spoken when `performWholesale` returns would
/// be describing a tree the store has not read yet, and one spoken per file would be the churn
/// the design rules out. It is dropped along with the floor whichever way that reload went — a
/// failed closing reload locks the board and says so instead (`BoardAnnouncer`'s ladder puts the
/// raised lock above the completion), and the phrase must not survive to be spoken by some
/// later, unrelated reload.
///
/// **`nil` on every base-edition bracket today.** Base has no git operations, and the design's
/// examples ("Pulled 3 commits", "Switched to branch 'redesign'") are pro-m1's; the parameter
/// exists so that milestone supplies phrasing rather than re-plumbing the seam.
@ObservationIgnored
private var wholesaleCompletion: String?
/// Consumers suspended in `awaitQuiescence()`, resumed together the moment nothing is running
/// and nothing is owed.
@ObservationIgnored
private var quiescenceWaiters: [CheckedContinuation<Void, Never>] = []
/// The loose-file set the last relocation attempt was made against — the loop guard
/// `relocateLooseCardFiles()` documents. Empty means "nothing has been attempted against the
/// current picture", which is both the opening state and what a clean board resets it to.
@ObservationIgnored
private var attemptedRelocation: Set<String> = []
/// The tombstone set the last migration attempt was made against — `attemptedRelocation`'s twin,
/// documented at `migrateLegacyTombstones()`.
@ObservationIgnored
private var attemptedTombstoneMigration: Set<String> = []
/// The board root's agent-guide picture the last refresh acted on — the third of the same loop
/// guard, documented at `refreshAgentGuide()`. `nil` means "nothing has been acted on against
/// the current picture", which is both the opening state and what a board whose guide is already
/// current resets it to (so the state it holds is never a guide's own text for longer than the
/// one reload that wrote it).
@ObservationIgnored
private var attemptedGuideRefresh: AgentGuide.State?
/// Awaited off the main actor **after** a tree walk finishes and **before** its result is
/// applied — the one seam this type keeps, `nil` in production.
///
/// It exists because two of the contracts above are ordering claims about work that runs
/// concurrently with the main actor ("only the newest result applies"; "the wholesale
/// expectation never binds to a walk already in flight"), and a test that cannot pin a finished
/// walk open can only approximate them with sleeps — which would make the suite slow, flaky, and
/// silent about the very race it exists to rule out.
@ObservationIgnored
var loadBarrier: (@Sendable () async -> Void)?
/// **This board's one outlet for spoken announcements** — `AccessibilityAnnouncer.post` in
/// production, and the second seam this type keeps (`loadBarrier` is the first, and this is the
/// same bargain).
///
/// Every *decision* about what the board says is already a pure function of values
/// (`BoardAnnouncer.speech(for:)`, `AccessibilityPhrases`), so the rules are not here and are not
/// tested through here. What this makes assertable is the **wiring**: that a foreign reload's
/// sentence actually reaches an outlet, that an app-mediated echo produces none, and that a
/// bracket's completion phrase is spoken by the reload that closed it and by no later one. Those
/// are claims about the reload path rather than about phrasing, and the alternative way to check
/// them is a screen reader and a human ear.
///
/// One outlet rather than a call per producer, for `setTrashVisible`'s own reason: the board's
/// announcements are one voice, and a producer that posted around this would be a second voice
/// nothing could see.
@ObservationIgnored
var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store")
// MARK: - Init
/// Opens a board: one synchronous tree walk, and **no fallback if it fails**.
///
/// Fail-fast is the *initial-load* contract (01-storage-format.md § Malformed input): there is
/// no last-good snapshot to keep on screen yet, so a broken board throws its `BoardLoadError`
/// instead of constructing a store that would have nothing to show. Every rule below — the
/// banner, the lock, "a failed reload never replaces a good snapshot" — exists only *because*
/// this one succeeded.
///
/// The walk is synchronous because the caller has nothing to render until it lands; the
/// asynchronous, off-main pipeline starts with the first reload.
///
/// **It writes nothing, the opened board's loose files included.** `looseCardFiles` is recorded
/// here and acted on by whoever wired this store up — `BoardStoreRegistry.acquire` calls
/// `relocateLooseCardFiles()` once the watcher and the brackets exist, so the relocation is a
/// bracketed write with a reload behind it rather than a write into a board nothing is watching
/// yet. A store built directly (a test, a storeless consumer) relocates when it is asked to, and
/// on every reload thereafter.
public init(rootURL: URL) throws(BoardLoadError) {
let result = try BoardLoader.load(boardRoot: rootURL)
self.rootURL = rootURL
self.snapshot = result.model
self.loadWarnings = result.warnings
self.looseCardFiles = result.looseCardFiles
self.legacyTombstones = result.legacyTombstones
self.reloadFailure = nil
self.readOnlyLock = nil
self.transient = TransientBoardState()
}
// MARK: - Inbound signals
/// The single inbound signal — everything the outside world tells this store arrives here.
///
/// Deliberately one door: the watcher, a test, and (later) the registry's wake/activation
/// reconciliation all speak the same two-case vocabulary, so there is exactly one place where a
/// filesystem change becomes a reload.
public func handleWatcherEvent(_ event: WatcherEvent) {
switch event {
case let .treeChanged(origin):
requestReload(origin)
case .rootChanged:
// Delegated, never guessed at. The settled response is to re-resolve the board's
// security-scoped bookmark and either `reattach(to:)` the watcher at the new location —
// a rename absorbed with no banner and no lock — or enter the vanished-root read-only
// lock (02-architecture.md § Write-failure surfacing). Both halves need a bookmark this
// store does not own, and a guess here would be a *wrong* guess: treating a rename as a
// vanish would lock a board that is merely somewhere else.
//
// No reload is started either way. The delegate's two outcomes both end in one —
// `reattach(to:)`'s reconciling reload at the re-resolved root, or the vanished-root
// lock's eventual clearance when the root returns — and a reload fired from here would
// walk a path that just stopped being the board.
guard let rootChangeDelegate else {
Self.logger.debug("rootChanged ignored — no delegate is wired (storeless use)")
return
}
rootChangeDelegate()
}
}
/// Starts a reload, or banks one if a walk is already running.
private func requestReload(_ origin: WatchOrigin) {
guard !reloadInFlight else {
pendingReload = WatchOrigin.merged(pendingReload, origin)
return
}
startReload(origin)
}
// MARK: - The reload pipeline
/// Runs one tree walk **off the main actor** and applies its result on it.
///
/// Off-main because a board of any size is a directory walk plus a YAML parse per item, and the
/// whole point of the value-type snapshot is that this work can happen anywhere: `BoardLoader`
/// is stateless statics and `LoadResult` is `Sendable`, so the only thing that has to be on the
/// main actor is the assignment at the end. `Task.detached` rather than `Task { }`: a task
/// created inside a `@MainActor` method inherits that isolation and would run the walk on the
/// main actor — the exact thing this is avoiding.
///
/// `origin` is not branched on: every reload is a full tree walk, so no origin is less safe than
/// another. It is carried because the *policy* around a reload differs later — a `.reconciling`
/// sweep re-probes writability (02-architecture.md § Write-failure surfacing) — and because it is
/// the one thing that makes a reload's provenance legible in the log.
private func startReload(_ origin: WatchOrigin) {
reloadGeneration += 1
let generation = reloadGeneration
let root = rootURL
let barrier = loadBarrier
reloadInFlight = true
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
Task.detached(priority: .userInitiated) { [weak self] in
// `do throws(BoardLoadError)`: without the annotation the `catch` widens to `any Error`
// and the loader's typed error is lost on the way into `Result`.
let outcome: Result<LoadResult, BoardLoadError>
do throws(BoardLoadError) {
outcome = .success(try BoardLoader.load(boardRoot: root))
} catch {
outcome = .failure(error)
}
await barrier?()
await self?.apply(outcome, generation: generation, origin: origin)
}
}
/// Lands one walk's result and starts whatever it uncovered.
private func apply(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
reloadInFlight = false
// The stale-apply guard. Serialization means this should not trigger today, but "only the
// newest result applies" is the rule the wholesale floor and every future overlapping-load
// change are written against, so it is enforced rather than assumed.
if generation == reloadGeneration {
land(outcome, generation: generation, origin: origin)
}
startPendingReload()
resumeQuiescenceWaitersIfQuiet()
}
private func land(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
// Consumed here, before the branch, because *both* outcomes end the expectation: a wholesale
// operation gets exactly one reload to prove itself, and a second failure after it is
// ordinary per-file breakage again.
let endsWholesaleOperation: Bool
let completion: String?
if let floor = wholesaleReloadFloor, generation >= floor {
wholesaleReloadFloor = nil
completion = wholesaleCompletion
wholesaleCompletion = nil
endsWholesaleOperation = true
} else {
completion = nil
endsWholesaleOperation = false
}
// The two standing conditions as they stood *before* this reload touched them —
// 10-accessibility.md makes the live-reload-resilience banner an announced element "when it
// appears and when it clears", and appearing and clearing are transitions, not states. Read
// here rather than at each mutation below so there is one before-picture for the whole
// landing, whichever branch it takes.
var facts = BoardAnnouncer.ReloadFacts(origin: origin)
facts.endsBracketedOperation = endsWholesaleOperation
facts.completion = completion
facts.lockBefore = readOnlyLock
facts.breakageBefore = reloadFailure
switch outcome {
case let .success(result):
// **What changed, and what it cost the cursor** — both computed against the *outgoing*
// snapshot, so they have to be taken before the assignment below replaces it. Both are
// pure functions of two value types; nothing here decides whether anyone is told.
//
// Asked only of a `.foreign` reload that is not closing a bracket, which is the only
// reload either answer is used by: 10-accessibility.md gives the app's own echoes
// silence, gives a bracket one sentence at completion rather than a description of its
// churn, and phrases the vanishing-focus case as "deleted *externally*" — a sentence that
// would be a lie about an app-mediated delete, whose own command already chose a
// successor (04-interactions.md ▸ The map's ⌫ rule) and must not have it overridden here.
// Skipping the comparison on the other origins keeps the ordinary echo's landing exactly
// as cheap as it was.
let focus: BoardAnnouncer.FocusOutcome
if origin == .foreign, !endsWholesaleOperation {
facts.diff = BoardDiff.between(snapshot, result.model)
focus = BoardAnnouncer.focusOutcome(
old: snapshot,
new: result.model,
selection: transient.selection,
focused: focusedItem
)
} else {
focus = .survived
}
facts.vanishedFocus = focus.vanished
// **The motion language's one decision point** (03-board-ui.md § Motion, via `Motion`).
// "User-initiated structural changes animate; foreign changes snap" cannot live at the
// call sites here the way it did in the pathfinder — the one-way flow means the user's
// own delete arrives back through the watcher exactly like an agent's edit — so it lives
// at the *reload*, whose origin is already classified. `Motion` owns which origins
// perform and in which voice; this store owns only the assignment they wrap.
//
// A `nil` animation is not a special case: `withAnimation(nil)` is the bare assignment,
// which is what the snapping origins and the Reduce Motion variant both want.
withAnimation(Motion.reloadAnimation(
origin: origin,
endsBracketedOperation: endsWholesaleOperation,
reduced: Motion.prefersReducedMotion
)) {
snapshot = result.model
snapshotGeneration += 1
loadWarnings = result.warnings
// 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.
//
// Inside the transaction deliberately: 03 § Motion has the selection highlight
// "ride whatever transaction is active rather than easing on its own", and a
// re-grounding that landed outside this one would be exactly the independent ease
// that rules out.
transient.resolve(against: result.model)
// And *then* the recovery, on top of the set rule rather than instead of it: the
// resolution leaves an emptied selection wherever the focused item used to be, and
// this is 10-accessibility.md's answer to the hole ("focus recovers to the card's
// lane … walks up then sideways"). Inside the same transaction for the highlight's
// sake, exactly like the resolution it follows.
recoverFocus(focus.recovery)
}
// Outside it, equally deliberately — 03 keys transactions narrowly, and the banner
// conditions are not board structure. A lock clearing is not a thing the strip should
// slide out on the back of a card being deleted.
//
// Breakage always heals on a success — it *is* the claim "the last reload failed", and
// this one did not.
reloadFailure = nil
looseCardFiles = result.looseCardFiles
legacyTombstones = result.legacyTombstones
clearLockIfDisproved(by: origin)
// The registry write-through, for the same "not board structure" reason the lock
// clearing sits out here: whether this board's row needs a new title, icon, or
// iconColor is the registry's question to answer (`syncDisplayState`'s own no-op
// guard), not a decision this store makes by comparing against its own prior
// snapshot.
displayStateDelegate?()
// Last, and after `clearLockIfDisproved` deliberately: this is the seam the two
// deferred app-initiated writes are armed on. A board that was locked read-only
// tolerated its loose files and its legacy tombstones for exactly as long as the lock
// stood, and the reload that clears the lock is the reload that lets them move — see
// `relocateLooseCardFiles()` and `migrateLegacyTombstones()`.
//
// The migration goes second only because the relocation is the older rule; they touch
// disjoint files (loose files beside an `index.md` vs the `deleted:` key inside one) and
// share one bracket-per-call posture, so neither can see the other's work half-done —
// each opens its own bracket and each is re-armed by the reload the other's write
// produces.
//
// The agent guide joins them last, and is the one of the three that runs on *every*
// board rather than only on one an older version or an outside writer left work in: it
// re-checks a single board-root file and writes only when the version marker says to
// (08-agent-integration.md ▸ The agent guide). Running it here rather than at open alone
// is what makes it self-healing — see `refreshAgentGuide()`.
relocateLooseCardFiles()
migrateLegacyTombstones()
refreshAgentGuide()
case let .failure(error):
// `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. Nothing is performed here either, for the same reason —
// and neither is anything on the lock paths below (`enterVanishedRootLock`,
// `enterUnwritableLock`, `relocate`), none of which touch the snapshot at all. A board
// that did not change has no motion to show.
reloadFailure = error
// `readOnlyLock == nil` rather than an unconditional assignment: a root that vanished
// mid-bracket already raised its own, truer lock, and 02-architecture.md is explicit
// that the bracket's final reload becomes a no-op there rather than a redundant
// failure. Overwriting `.vanishedRoot` with `.bracketedReloadFailed` would also break
// the clearing rules — the vanished root's lock must not clear on a reload that never
// proves the root came back.
if endsWholesaleOperation, readOnlyLock == nil {
readOnlyLock = .bracketedReloadFailed
}
Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
}
// **One announcement per reload**, chosen by `BoardAnnouncer`'s precedence ladder and posted
// last, after both branches have finished moving the store — so the after-picture the
// decision reads is the settled one, and so a sentence is never spoken about a state that a
// line below it then changed.
facts.lockAfter = readOnlyLock
facts.breakageAfter = reloadFailure
announce(BoardAnnouncer.speech(for: facts))
}
/// Installs the recovery `BoardAnnouncer` chose for a focus that vanished under a foreign
/// reload — the storage half of the rule, with every decision already made.
///
/// The board container case is spelled rather than skipped: `resolve(against:)` has already
/// emptied the selection by the time this runs, so `clearSelection()` is a no-op on membership —
/// but it also drops the anchor and the head, which is the difference between "nothing is
/// selected" and "nothing is selected and the next ⇧-arrow ranges from a ghost".
///
/// `noteActiveLane` rides along on the lane case for `lastActiveLaneID`'s own reason: the
/// resolution just cleared that memory along with the lane it named, and a ⌘N after a foreign
/// delete should file the card where the user has been left, not at the far left of the board.
private func recoverFocus(_ recovery: BoardAnnouncer.FocusRecovery?) {
switch recovery {
case nil:
break
case let .lane(id):
transient.select([id], in: .board)
transient.noteActiveLane(id)
case .boardContainer:
transient.clearSelection()
}
}
/// **The cursor**, as 10-accessibility.md's announcements mean it: the navigation head when it
/// is still in the selection, else a sole selected item, else nothing.
///
/// Nothing for a multi-item selection with no head deliberately — a vanishing-focus sentence
/// names *one* item ("Card 'Fix login' was deleted externally"), and picking one out of a
/// five-card selection by set order would name whichever the hash table happened to yield. That
/// case falls through to the digest, which describes all five honestly.
private var focusedItem: ItemID? {
let ids = transient.selection.ids
if let head = transient.selectionHead, ids.contains(head) { return head }
return ids.count == 1 ? ids.first : nil
}
private func startPendingReload() {
guard !reloadInFlight, let origin = pendingReload else { return }
pendingReload = nil
startReload(origin)
}
// MARK: - The lock's clearing rules
/// Clears the read-only lock if this successful reload actually disproved its cause.
///
/// **Reason-specific, because the causes are not alike** (02-architecture.md § Write-failure
/// surfacing):
///
/// - `.bracketedReloadFailed` and `.vanishedRoot` are *disproved by the success itself*. The
/// first says "the tree could not be re-read after a wholesale change" and the second says
/// "the root is gone" — a completed tree walk at the root contradicts both, whatever origin
/// asked for it, so any success clears them.
/// - `.unwritableLocation` is not. A board on a read-only DMG reloads flawlessly forever;
/// loading proves nothing about writing. It clears only when a **reconciling** reload — wake,
/// activation, a stream re-creation — re-probes writability and finds it changed ("Writability
/// re-probes on every reconciling reload, so a fixed permission or rewritable remount clears
/// the lock without ceremony").
///
/// `FileManager.isWritableFile(atPath:)` is `access(2)` on the root directory: a real-uid
/// permission question asked of the filesystem, which is what makes it answer correctly for
/// both halves of the case — a read-only *mount* and a permission-denied *folder*.
///
/// Deliberately **one-way**: a reconciling reload that finds the root unwritable does not
/// *raise* the lock. Arming it is the open flow's job (`enterUnwritableLock()`), and inferring
/// a lock from a probe here would be a policy decision this milestone was not asked to make.
private func clearLockIfDisproved(by origin: WatchOrigin) {
switch readOnlyLock {
case nil:
break
case .bracketedReloadFailed, .vanishedRoot:
readOnlyLock = nil
case .unwritableLocation:
guard origin == .reconciling, FileManager.default.isWritableFile(atPath: rootURL.path) else { return }
Self.logger.debug("writability re-probe passed — the unwritable-location lock clears")
readOnlyLock = nil
}
}
// MARK: - Root identity and explicit locks
/// Points this store at the board's new location, absorbing a rename or a move.
///
/// Called by the registry when a `.rootChanged` re-resolved the board's bookmark somewhere else
/// (02-architecture.md § Write-failure surfacing, "A renamed or moved board root follows its
/// file identity"): the board the app has open is the *file*, not the path string, so this is
/// bookkeeping rather than an event — **no banner, no lock, nothing was ever wrong**.
///
/// It deliberately starts **no reload**. The caller follows this with the watcher's
/// `reattach(to:)`, whose reconciling reload is the one that rebuilds the snapshot at the new
/// root; a reload fired from here would be a second walk racing that one for no gain. Until it
/// lands, `rootURL` is the new location and `snapshot.rootURL` is still the old — see
/// `rootURL`'s note.
public func relocate(to newRoot: URL) {
guard newRoot != rootURL else { return }
Self.logger.debug("board root relocated; Writer URLs now derive from the new location")
rootURL = newRoot
}
/// Raises the vanished-root read-only lock — the registry's call, after bookmark re-resolution
/// found nothing and the last-known path is not there either.
///
/// Overwrites whatever lock was standing: a root that is gone is the most current and most
/// specific truth about why writes are refused, and its clearing rule (a successful reload,
/// which can only happen if the root came back) is strictly the safer one to be holding.
public func enterVanishedRootLock() {
Self.logger.error("board root vanished — entering the read-only lock")
let before = readOnlyLock
readOnlyLock = .vanishedRoot
announceLockChange(from: before)
}
/// Raises the unwritable-location read-only lock — the open flow's call, after probing the
/// root's writability (02 § "An unwritable board location enters the read-only lock at open").
/// Public now so the vocabulary and its clearing rule ship together; m4's open flow is the
/// producer.
///
/// Does **not** overwrite a standing lock: a board that is already locked for a vanished root
/// or a failed bracketed reload has a cause that outranks "and it is also read-only", and both
/// of those clear on a success that would then re-probe anyway.
public func enterUnwritableLock() {
guard readOnlyLock == nil else { return }
Self.logger.error("board location is not writable — entering the read-only lock")
readOnlyLock = .unwritableLocation
announceLockChange(from: nil)
}
/// Speaks a lock raised **outside** the reload path — the registry's vanished-root call and the
/// open flow's writability probe, neither of which is a reload and neither of which therefore
/// competes with anything for the debounce's one sentence.
///
/// 10-accessibility.md makes the live-reload-resilience banner an announced element, and these
/// are the two ways it can appear without a reload landing. Routed through the same
/// `BoardAnnouncer.ReloadFacts` ladder rather than posting directly so the sentence is composed
/// exactly once, in one place, from the banner's own headline: a lock the user hears described
/// one way and reads another is two locks as far as they can tell.
private func announceLockChange(from before: ReadOnlyLockReason?) {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
facts.lockBefore = before
facts.lockAfter = readOnlyLock
announce(BoardAnnouncer.speech(for: facts))
}
// MARK: - Write gate
/// Runs a synchronous Writer operation inside the watcher bracket, so the churn it produces
/// rounds back as one app-mediated reload rather than a scatter of foreign ones.
///
/// The store does **not** touch its snapshot here, before or after. `operation` puts bytes on
/// disk; the watcher notices; the reload applies. That indirection is the one-way flow, and it is
/// why this method's only jobs are the gate and the bracket.
///
/// **A failure posts to the banner before it is rethrown** (02-architecture.md § Write-failure
/// surfacing): the strip is how the one-way flow keeps its honesty — the action visibly did not
/// happen, and the banner is the only thing that says why — so no call site is trusted to
/// remember, and a `try?` at some future call site cannot make a failure silent. The refusal
/// below is deliberately *not* posted: the lock row is already standing, and a second row per
/// refused gesture would bury it under echoes of itself.
///
/// - Throws: `BoardStoreWriteRefusal.readOnlyLocked` if the board is locked read-only — checked
/// *before* the bracket opens, so a refusal costs no suspended watcher and no owed reload.
/// Otherwise rethrows whatever `operation` threw, which is a `BoardWriteError`. The untyped
/// `throws` is the price of those being two different error types today; see
/// `BoardStoreWriteRefusal` for why they are, and why they will not stay that way.
///
/// One ergonomic wart, recorded so it is not rediscovered: when `operation` returns a value,
/// Swift cannot infer `T` and the closure's thrown type at the same time — the thrown type
/// widens to `any Error` and the call fails to compile. Such a call site spells the closure out
/// (`{ () throws(BoardWriteError) -> ItemID in … }`). `Void`-returning operations, which are
/// most of them, infer cleanly.
@discardableResult
public func performWrite<T>(_ operation: () throws(BoardWriteError) -> T) throws -> T {
if let readOnlyLock {
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
}
watcherBrackets?.begin()
// `defer`, not a trailing call: a Writer operation that fails partway has still touched disk,
// and an unbalanced bracket would leave the watcher suspended for the rest of the session.
defer { watcherBrackets?.end() }
// `do throws(BoardWriteError)`: without the annotation the `catch` widens to `any Error` and
// the Writer's typed error is lost on the way to the banner.
do throws(BoardWriteError) {
return try operation()
} catch {
banners.post(error)
throw error
}
}
/// Runs an operation that rewrites the tree **wholesale** — pull-rebase, branch switch, undo
/// restore (06-history-undo.md, 07-sync-collab.md) — under the bracket, and arms the rule that
/// its closing reload must succeed.
///
/// Two things distinguish this from `performWrite`:
///
/// - The bracket is load-bearing rather than tidy: a reload landing mid-operation would render a
/// half-checked-out tree.
/// - Failure of the closing reload **locks the board** (`ReadOnlyLockReason.bracketedReloadFailed`).
/// After a wholesale change the last-good snapshot may describe a different branch entirely, so
/// writes derived from it would land nonsense — unlike ordinary per-file breakage, where the
/// snapshot still describes the tree.
///
/// The expectation is armed on **every** exit path, a thrown error included: an operation that
/// died partway is precisely the case where the tree's state is unknown and the next reload had
/// better be the authority on it.
///
/// - Parameter completion: what to announce when the closing reload lands
/// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at
/// completion" — "Pulled 3 commits", "Switched to branch 'redesign'"). `nil`, the default, is
/// an operation whose completion is not worth speech, which is **every base-edition bracket
/// today**: base has no git operations, and the two app-initiated writes that do reach disk on
/// their own — the loose-file relocation and the legacy-tombstone migration — are ordinary
/// `performWrite` calls that already say what they did on the banner strip. The parameter is
/// the seam pro-m1 fills; see `wholesaleCompletion`.
///
/// - Throws: `BoardStoreWriteRefusal.readOnlyLocked` if the board is already locked — a locked
/// board refuses to *start* wholesale work, not just ordinary writes. Otherwise rethrows
/// `operation`'s error. (Spelled `throws` rather than `rethrows` because of that refusal: a
/// `rethrows` function may only throw errors its closure threw.)
public func performWholesale(announcing completion: String? = nil, _ operation: () throws -> Void) throws {
if let readOnlyLock {
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
}
watcherBrackets?.begin()
defer {
// Ordered: arm first, then close the bracket. `endBracket()` is what schedules the
// post-bracket reload, and with a `nil` watcher a consumer may signal by hand the instant
// this returns — either way the floor has to be in place before any walk can start. The
// completion phrase is armed with it, for the same reason and on the same exit paths: an
// operation that died partway still owes its closing reload, and 10 gives that reload one
// sentence whichever way it goes.
wholesaleReloadFloor = reloadGeneration + 1
wholesaleCompletion = completion
watcherBrackets?.end()
}
do {
try operation()
} catch let error as BoardWriteError {
// Same honesty rule as `performWrite`, applied to the one error type the banner has
// phrasing for. A wholesale operation is usually git's (m7), whose own failure
// vocabulary is not `BoardWriteError` and whose surfacing — the suspended-history
// condition, the in-progress row swapping for an error — is the committer's to drive;
// but a `BoardWriteError` escaping here is an ordinary failed write and may no more
// bypass the strip than one from `performWrite`.
banners.post(error)
throw error
}
}
// MARK: - Lane width
/// Writes a lane's width — the one commit point both width mechanisms share (03-board-ui.md §
/// Lane): the right-edge drag's release and the stepper's ⌥⌘→/⌥⌘← both land here, and they differ
/// only in what they did to the *window* on the way (the drag grew it, the stepper did not).
///
/// **The value written is an integer, replacing whatever was there.** `width` is a lenient field
/// on the read side — missing, malformed, zero and negative all render as one unit
/// (`LaneLayoutMath.displayUnits`) with the author's bytes left alone — but an explicit width
/// change is the user overwriting that value, so the Writer puts a plain integer in its place
/// (01-storage-format.md § Frontmatter).
///
/// Three ways this does nothing, all deliberate: a count below 1 clamps to 1 (a lane spans at
/// least one unit), an id that is not in the snapshot is ignored (the lane vanished under the
/// gesture — the reload that removed it is the authority), and a count already equal to what the
/// lane displays writes nothing (a drag that ends where it started must not stamp `modified` or
/// mint a git commit).
///
/// Failures are already the banner's: `performWrite` posts every `BoardWriteError` before it
/// rethrows, so the rethrow is swallowed here rather than propagated to a gesture that has no
/// second thing to do about it. The lane stays at its old width, which is the truth — nothing was
/// written.
public func setLaneWidth(_ id: ItemID, units: Int) {
writeLaneWidths([(id, max(1, units))])
}
/// Steps every lane in `ids` one unit — the Increase/Decrease Lane Width menu items' batch
/// (03-board-ui.md § Lane, settled: "they batch over a multi-lane selection — each selected
/// lane steps one unit, one gesture, one commit"; the context-menu stepper stays single-lane
/// by nature and keeps calling `setLaneWidth`).
///
/// Lanes already at the one-unit floor simply hold there on a decrease — the batch is not
/// refused because one member has nowhere to go, matching the style batch's silent-skip shape.
public func stepLaneWidths(_ ids: Set<ItemID>, by delta: Int) {
let changes: [(ItemID, Int)] = snapshot.lanes
.filter { ids.contains($0.id) }
.map { ($0.id, max(1, LaneLayoutMath.displayUnits(of: $0) + delta)) }
writeLaneWidths(changes)
}
/// The one commit point every width mechanism shares — the edge drag, the context-menu stepper,
/// and the menu items' batch. One `performWrite` bracket whatever the count: one gesture, one
/// app-mediated reload, one commit on git boards (the style batch's rule).
///
/// **A width landing on 1 removes the `width` key** (03-board-ui.md § Lane, settled — the
/// remove-at-default family beside the empty rename's `title` and the None well's
/// `background`): a default lane's frontmatter stays clean whichever mechanism wrote it. A
/// hand-written `width: 1` is legal and preserved until the app itself next edits width — the
/// unchanged-units guard below skips it, so only a real change reaches the remove.
private func writeLaneWidths(_ changes: [(id: ItemID, units: Int)]) {
let writes: [(folder: URL, units: Int, prior: FieldValue<Int>, title: String?)] = changes.compactMap { change in
guard let lane = snapshot.lanes.first(where: { $0.id == change.id }),
LaneLayoutMath.displayUnits(of: lane) != change.units
else { return nil }
return (rootURL.appendingPathComponent(change.id.rawValue), change.units, lane.width, lane.title.value)
}
guard !writes.isEmpty else { return }
// The closure's signature is spelled out because of `try?`: with the error discarded at the
// call site Swift stops inferring the typed `throws(BoardWriteError)` and widens it to `any
// Error`, which `performWrite` will not take. Same wart as the value-returning call sites
// `performWrite`'s doc comment records, arriving from the other direction.
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for write in writes {
try Self.setWidth(write.units, at: write.folder)
}
}
guard landed != nil else { return }
// resize → prior width (13-native-undo.md ▸ Rules). One step whatever the batch's size — the
// menu items step every selected lane in one gesture, and one gesture is one step.
//
// The validated field is `width`, read as the app reads it: a lane landing on one unit has
// **no key at all** (the remove-at-default rule above), which is a real after-value and the
// one `nil` here means. The redo's expectation is the prior as the inverse restores it —
// `prior.value`, which is `nil` for a missing *and* for a malformed prior, exactly matching
// `restoreWidth`'s own reading.
registerStep(
HistoryPhrase.name(.resize, kind: .lane, count: writes.count),
subject: writes.count == 1 ? writes[0].title : nil,
undoExpects: writes.map { .present($0.folder, .width($0.units == 1 ? nil : $0.units)) },
redoExpects: writes.map { .present($0.folder, .width($0.prior.value)) }
) { _ in
for write in writes {
try BoardWriter.updateIndex(inItemFolder: write.folder, operation: .resize(title: nil)) { document in
Self.restoreWidth(write.prior, in: &document)
}
}
} redo: { _ in
for write in writes {
try Self.setWidth(write.units, at: write.folder)
}
}
}
/// The width write itself, spelled once so the gesture and its redo cannot drift apart on the
/// remove-at-default rule.
private static func setWidth(_ units: Int, at folder: URL) throws(BoardWriteError) {
try BoardWriter.updateIndex(inItemFolder: folder, operation: .resize(title: nil)) { document in
if units == 1 {
document.remove(FrontmatterKeys.width)
} else {
document.set(FrontmatterKeys.width, to: .int(units))
}
}
}
// MARK: - Styling
/// One item a style gesture is about to act on: where its `index.md` is, and what the two styled
/// keys currently say there.
///
/// The editor reads these for its per-dimension current-value display (`StyleFieldState.resolve`)
/// and `applyStyle` reads the *same* values to decide what is a no-op, so the display and the
/// write can never disagree about what is already on disk. `id` is `nil` for the board root,
/// which has no `ItemID` by design (see `ItemID`'s doc comment).
public struct StyleSubject: Sendable, Equatable {
public let id: ItemID?
public let folder: URL
public let background: FieldValue<String>
public let icon: FieldValue<String>
}
/// The live items `target` names, in display order — lanes left to right, each lane's cards top
/// to bottom.
///
/// **Vanished targets are simply absent**, ancestor walk included: a tombstoned card, a card
/// under a tombstoned lane, and an id that names nothing all contribute no subject, which is the
/// same silent skip `commitRename` gives a vanished rename target — "nothing is ever written into
/// a vanished folder". A style editor whose set has emptied dismisses (`StyleEditorSession`), so
/// an empty result is a frame's worth of nothing to show rather than a state to handle.
public func styleSubjects(of target: StyleTarget) -> [StyleSubject] {
switch target {
case .board:
return [StyleSubject(
id: nil,
folder: rootURL,
background: snapshot.background,
icon: snapshot.icon
)]
case let .items(ids):
var subjects: [StyleSubject] = []
for lane in snapshot.lanes {
let laneFolder = rootURL.appendingPathComponent(lane.id.rawValue)
if ids.contains(lane.id) {
subjects.append(StyleSubject(
id: lane.id,
folder: laneFolder,
background: lane.background,
icon: lane.icon
))
}
for card in lane.cards where ids.contains(card.id) {
subjects.append(StyleSubject(
id: card.id,
folder: laneFolder.appendingPathComponent(card.id.rawValue),
background: card.background,
icon: card.icon
))
}
}
return subjects
}
}
/// Which level `target` sits at — the editor's symbol grid needs it for its leading well, "the
/// level's default symbol" (03-board-ui.md § Styling ▸ Controls).
///
/// A set naming any card is a card set: 04-interactions.md's cards-XOR-lanes rule means a live
/// selection never mixes the two, so the branch below is a total answer rather than a policy —
/// and if a mixed set ever reached here, `doc.text` is the level whose default would actually be
/// removed by the leading well.
public func styleLevel(of target: StyleTarget) -> StyleLevel {
switch target {
case .board:
return .board
case let .items(ids):
let namesACard = snapshot.lanes.contains { lane in
lane.cards.contains { ids.contains($0.id) }
}
return namesACard ? .card : .lane
}
}
/// Writes a style gesture — the **one** commit point every anchor shares (03-board-ui.md §
/// Styling ▸ Controls: "One component, one behavior, three anchors"), and the quick-style recents
/// row with them.
///
/// **One bracket, whatever the target set's size.** "Choosing a well applies to the whole
/// selection — one gesture, one commit on git boards" (§ Controls), so every target's `index.md`
/// is rewritten inside a single `performWrite`: the churn rounds back as one app-mediated reload,
/// and the auto-committer (m7) sees one operation rather than N.
///
/// **No-ops are skipped per dimension and per target** — `setLaneWidth`'s rule, for its reason: a
/// well clicked twice, or a batch where half the cards are already that colour, must not stamp
/// `modified` or mint a commit on the items that were already right. A dimension whose value is
/// already what the gesture asks contributes nothing; a target both of whose dimensions are
/// no-ops is dropped entirely; and a gesture that changes nothing anywhere never opens the
/// bracket at all.
///
/// **`iconColor` is not a parameter, and that is the design**: it is "resolved — schema yes,
/// control no" (§ Capabilities). The field renders when hand-written and the app offers no
/// control for it, so there is nothing here to pass.
///
/// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here
/// like every other gesture with no second thing to do. A batch that fails partway leaves the
/// targets written before it written — the Writer is "atomic per filesystem operation, not per
/// gesture" — and the reload shows the true state, which is the honest one.
public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) {
let edits: [(
id: ItemID?,
folder: URL,
background: StyleChange,
icon: StyleChange,
priorBackground: FieldValue<String>,
priorIcon: FieldValue<String>
)] = styleSubjects(of: target)
.compactMap { subject in
let background = Self.effective(background, against: subject.background)
let icon = Self.effective(icon, against: subject.icon)
guard background != .keep || icon != .keep else { return nil }
return (
id: subject.id,
folder: subject.folder,
background: background,
icon: icon,
priorBackground: subject.background,
priorIcon: subject.icon
)
}
guard !edits.isEmpty else { return }
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for edit in edits {
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a
// failure names the item by the title it still has (see `WriteOperation.style`).
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
}
}
}
guard landed != nil else { return }
// restyle → prior style (13-native-undo.md ▸ Rules). **One step for the batch**, which is the
// same sentence as this method's one bracket: "choosing a well applies to the whole selection
// — one gesture, one commit", substrate swapped.
let kind: HistoryPhrase.Kind = switch styleLevel(of: target) {
case .board: .board
case .lane: .lane
case .card: .card
}
// **Per dimension, not per item**: a gesture that set only `background` validates only
// `background`, so a foreign `icon:` edit on the very same card leaves the step alone. That is
// the field-level predicate read at its narrowest, and it is free — `effective(_:against:)`
// has already narrowed each dimension to what this write actually changed.
let subject = edits.count == 1
? edits[0].id.flatMap { Self.boardItem($0, in: snapshot)?.title }
: nil
registerStep(
HistoryPhrase.name(.restyle, kind: kind, count: edits.count),
subject: subject,
undoExpects: edits.map {
.present($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon))
},
redoExpects: edits.map {
.present($0.folder, fields: Self.restoredStyleFields(
background: $0.background,
priorBackground: $0.priorBackground,
icon: $0.icon,
priorIcon: $0.priorIcon
))
}
) { _ in
for edit in edits {
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
}
}
} redo: { _ in
for edit in edits {
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
}
}
}
}
/// `change` narrowed against what is already on disk: `.keep` when it would write what is
/// already there.
///
/// The comparison is against the **valid** reading, not the written text: a malformed value —
/// `background: [a, b]`, a sequence where a scalar belongs — is never equal to a palette name, so
/// choosing a well always replaces it, which is what "choosing any well replaces it" (§ Controls)
/// promises about a value the app could not read.
nonisolated static func effective(_ change: StyleChange, against field: FieldValue<String>) -> StyleChange {
switch change {
case .keep: .keep
case let .set(value): field.value == value ? .keep : .set(value)
case .remove: field.isMissing ? .keep : .remove
}
}
private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) {
switch change {
case .keep: break
case let .set(value): document.set(key, to: .string(value))
case .remove: document.remove(key)
}
}
// MARK: - Creation
/// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md).
///
/// **Untitled, and deliberately with no inline editor.** 03-board-ui.md gives lane titles one
/// editing surface — "Inline rename on the header" — and 04-interactions.md gives that surface
/// one entry point, Board ▸ Rename, "since Return on a lane creates a card". Nothing in either
/// doc opens an editor *at creation*, so a new lane appears with the untitled placeholder and
/// the user renames it if they want a name. Titles are optional at every level; a lane with no
/// `title` key is a legitimate resting state, not a half-finished one.
///
/// Like `setLaneWidth`, the rethrow is swallowed: `performWrite` has already posted the banner,
/// and a menu item has no second thing to do about a failure.
public func createLane() {
let root = rootURL
let created = try? performWrite { () throws(BoardWriteError) -> ItemID in
try BoardWriter.createLane(inBoard: root, title: nil)
}
guard let created else { return }
// create → remove the created folder (13-native-undo.md ▸ Rules). The bytes are read back
// here, while the folder still exists, because the inverse destroys it — see `CreatedItem`.
let folder = root.appendingPathComponent(created.rawValue, isDirectory: true)
guard let item = createdItem(at: folder, kind: .lane) else { return }
registerCreation([item], kind: .lane)
}
// MARK: - The new-card placeholder's commit
/// Turns the open placeholder into a real card — the write half of 02-architecture.md §
/// Layering's one named exception to the one-way flow.
///
/// The five outcomes, all settled:
///
/// - **No placeholder, or one already committed** — nothing to do. (Idempotence matters: Return
/// commits, and the field's focus-loss handler fires immediately afterwards.)
/// - **An empty title discards it** — "creating-then-abandoning never leaves an empty card
/// behind" (04-interactions.md ▸ Grammar). Whitespace counts as empty: a title of three
/// spaces is a slip, not a deliberate untitled card.
/// - **A vanished lane discards it** — the anchor is gone, so there is nowhere to file the
/// card; the reload that removed the lane is the authority.
/// - **A failed create discards it too** (settled, 02 § Layering): "the overlay never waits for
/// a card that cannot arrive". The failure is already the banner's.
/// - **A successful create hands off**: the overlay flips to `.awaitingArrival` and stands until
/// the watcher round-trips the real card, so the user never sees a hole where they just typed.
///
/// - Returns: the created card's id, or `nil` on any of the discard paths — which is what the
/// ⌘↩ call site needs to know whether it has a card window to open.
@discardableResult
public func commitPlaceholder() -> ItemID? {
guard let placeholder = transient.newCardPlaceholder, placeholder.phase == .editing else { return nil }
let title = placeholder.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty,
let lane = snapshot.lanes.first(where: { $0.id == placeholder.laneID })
else {
transient.discardPlaceholder()
return nil
}
let laneFolder = rootURL.appendingPathComponent(placeholder.laneID.rawValue)
let visible = lane.cards
// `nil` means "append", which is `createCard`'s own default — so the anchored case is the
// only one that needs a rank at all.
let position = Self.insertionIndex(after: placeholder.anchorCardID, among: visible)
let created = try? performWrite { () throws(BoardWriteError) -> ItemID in
let id = try BoardWriter.createCard(inLane: laneFolder, title: title)
guard let position else { return id }
// The rank is computed here rather than passed to `createCard` because the create's
// contract is "append after the visible siblings" and widening it would give every
// caller a position to think about. The reposition rides the Writer's own same-parent
// degenerate reorder — "a move whose destination is the item's current parent degrades
// to a plain reorder" — inside the *same* `performWrite`, so the pair rounds back as
// one app-mediated reload rather than showing the card at the bottom for a frame.
var rank = Ranks.insertionRank(amongVisible: visible.map(\.order), at: position)
if rank == nil {
// Midpoint precision exhausted between the anchor and its neighbour
// (01-storage-format.md § Ordering). Compact, then place against the fresh ranks:
// the new card is not among the renumbered siblings — it was appended past them —
// so the compacted ladder lines up one-for-one with `visible`.
try BoardWriter.renumberVisibleChildren(of: laneFolder)
rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: visible.count), at: position)
}
guard let rank else { return id }
_ = try BoardWriter.moveItem(
at: laneFolder.appendingPathComponent(id.rawValue),
toParent: laneFolder,
sourceBoardRoot: rootURL,
destinationBoardRoot: rootURL,
order: rank
)
return id
}
guard let created else {
transient.discardPlaceholder()
return nil
}
transient.commitPlaceholder(expecting: created)
// create → remove the created folder (13-native-undo.md ▸ Rules). The rank the pair above
// may have written is inside the captured bytes, so a redo puts the card back where the
// gesture put it, not merely at the bottom of the lane.
if let item = createdItem(at: laneFolder.appendingPathComponent(created.rawValue, isDirectory: true), kind: .card) {
registerCreation([item], kind: .card, subject: title)
}
return created
}
/// The display position a new card takes, or `nil` for "append at the bottom".
///
/// An anchor that is not among `visible` degrades to `nil` rather than failing: the card the
/// ⌘N target rule named was deleted or moved away mid-typing, and the lane — the anchor that
/// actually matters — is still there. Appending is the honest fallback; refusing to create
/// would punish the user for someone else's edit.
nonisolated static func insertionIndex(after anchor: ItemID?, among visible: [Card]) -> Int? {
guard let anchor, let index = visible.firstIndex(where: { $0.id == anchor }) else { return nil }
// Already last: "immediately after it" and "at the bottom" are the same position, and
// append needs no rank of its own.
return index + 1 < visible.count ? index + 1 : nil
}
// MARK: - Inline rename
/// Writes the open rename editor's draft — the third inline editor's commit
/// (04-interactions.md ▸ Grammar), reached by Return **and** by focus loss ("a rename commits
/// … the deliberate exception being the placeholder, because nothing exists on disk yet").
///
/// Four rules, all from 04 and 03:
///
/// - **The editor closes first, unconditionally.** Every path below ends with it gone, and
/// retiring it up front is what makes this idempotent — Return commits and the field's
/// focus-loss handler fires an instant later against no editor at all.
/// - **A vanished target writes nothing, silently.** "A target that is tombstoned, deleted, or
/// gone at commit time discards the editor and its keystrokes silently … nothing is ever
/// written into a vanished folder, and no partial `index.md` can resurrect deleted data."
/// Entering the trash is a vanish from the board, and so is losing the lane you were in.
/// - **An empty commit removes the `title` key** (03-board-ui.md § Card face; 04 ▸ Selection:
/// "Committing an empty rename on an existing item removes its `title` key"), rather than
/// writing `title: ""` — titles are optional, and the face shows the untitled placeholder.
/// - **An unchanged title writes nothing.** `setLaneWidth`'s rule, for the same reason: an
/// editor opened and dismissed with Return must not stamp `modified` or mint a commit.
///
/// The folder is re-derived from the *current* snapshot, which is what makes a foreign move
/// mid-rename invisible: the editor follows the UUID, and the write lands wherever the item is
/// now.
public func commitRename() {
guard let editor = transient.renameEditor else { return }
transient.discardRename()
guard let target = Self.boardItem(editor.targetID, in: snapshot) else { return }
let typed = editor.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let newTitle: String? = typed.isEmpty ? nil : typed
guard newTitle != target.title else { return }
var folder = rootURL.appendingPathComponent(target.laneID.rawValue)
if let cardID = target.cardID {
folder.append(component: cardID.rawValue)
}
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so the
// banner names the item by the title it still has rather than the one that failed to
// land (see `WriteOperation.rename`).
try Self.setTitle(newTitle, at: folder)
}
guard landed != nil else { return }
// rename → restore title (13-native-undo.md ▸ Rules). The prior title is the *typed* value,
// `nil` for an untitled item — so undoing a rename that gave an untitled card a name takes
// the `title` key away again rather than writing `title: ""`.
//
// The validated field is `title` and nothing else: an agent that restyles this very card
// between the rename and the ⌘Z has not touched what this step wrote, so the undo applies —
// "a foreign change to an unrelated item must not skip anything", read one level finer.
let priorTitle = target.title
registerStep(
HistoryPhrase.name(.rename, kind: target.cardID == nil ? .lane : .card),
subject: newTitle ?? priorTitle,
undoExpects: [.present(folder, .title(newTitle))],
redoExpects: [.present(folder, .title(priorTitle))]
) { _ in
try Self.setTitle(priorTitle, at: folder)
} redo: { _ in
try Self.setTitle(newTitle, at: folder)
}
}
/// The title write every rename shares — the item-level one and the board's — spelled once so
/// the empty-title rule (a missing key, never `title: ""`) cannot differ between a gesture and
/// its own undo.
private static func setTitle(_ title: String?, at folder: URL) throws(BoardWriteError) {
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let title {
document.set(FrontmatterKeys.title, to: .string(title))
} else {
document.remove(FrontmatterKeys.title)
}
}
}
/// Where a **board** item lives and what it is currently called, or `nil` when the id names
/// nothing on the board.
///
/// **The board container, and only it.** A card that has been deleted is in `.trash/`, where it
/// does not open, cannot be renamed, takes no attachments and has no task boxes to tick
/// (03-board-ui.md § Trash's no-editing rule) — so every caller of this wants exactly the board
/// side, and a trash card answering `nil` is the vanished-target guard those gestures already
/// make. The tombstone era's ancestor walk is gone with the tombstones: presence in the lanes is
/// the whole question.
///
/// The path is returned as its two identity components rather than as a URL so the caller builds
/// it off the store's *current* `rootURL`, which a mid-session folder rename may have moved.
nonisolated static func boardItem(
_ id: ItemID,
in snapshot: BoardModel
) -> (laneID: ItemID, cardID: ItemID?, title: String?)? {
for lane in snapshot.lanes {
if lane.id == id {
return (laneID: lane.id, cardID: nil, title: lane.title.value)
}
if let card = lane.cards.first(where: { $0.id == id }) {
return (laneID: lane.id, cardID: card.id, title: card.title.value)
}
}
return nil
}
// MARK: - Task checkboxes
/// Ticks or unticks a Preview task-list checkbox — **the app's one write into a card's body**
/// (05-card-window.md ▸ Preview), and otherwise an entirely ordinary one: the same
/// `performWrite` bracket, the same banner on failure, the same one-way flow back through the
/// watcher. "A toggle is an ordinary user edit — the standard atomic write, auto-committed and
/// undoable on git boards."
///
/// `bodyOffset` is the UTF-8 byte offset the parse handed the renderer (`BodyTask
/// .markerOffset`) and `checked` is the state the user was looking at; both travel to
/// `BoardWriter.toggleTaskMarker`, which re-verifies them against the file it reads and refuses
/// rather than write blind. Nothing here inspects the body: the store never re-parses to
/// second-guess the click, because its own snapshot is exactly as stale as the render was.
///
/// **It registers no undo step.** 13-native-undo.md ▸ Rules' inventory names the body write it
/// makes undoable precisely — "Edit-session body save → restore prior body bytes" — and a Preview
/// checkbox is not one: it belongs to no session, has no flip to coalesce at, and 05 files it
/// under what is "undoable on git boards", which is the *other* substrate's answer. Registering it
/// here would be extending 13's inventory rather than implementing it.
///
/// **A checkbox in a card that has gone writes nothing** — the vanished-target guard every
/// gesture in this file makes, ancestor-walked through `boardItem`: the card window would be
/// dismissing itself in the same breath, and the reload that removed the card is the authority.
/// The read-only lock is `performWrite`'s refusal, which is also why the controls disable in
/// place on the Preview side rather than failing here (02-architecture.md § the lock's scope).
public func toggleTaskMarker(inCard cardID: ItemID, bodyOffset: Int, checked: Bool) {
guard let target = Self.boardItem(cardID, in: snapshot), let card = target.cardID else { return }
let folder = rootURL
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.toggleTaskMarker(inItemFolder: folder, bodyOffset: bodyOffset, checked: checked)
}
}
// MARK: - Card body
/// Saves a card window's Edit buffer — the debounced tick, the flush that leaves Edit, and the
/// flush that closes the window (05-card-window.md ▸ Edit).
///
/// An ordinary store write in every mechanical respect: one `performWrite` bracket, so the churn
/// rounds back as a single app-mediated reload (and, on git boards, sits inside the session's
/// one commit — see `CardBodyEditSession` for that seam); the banner posts itself on failure;
/// the snapshot is never touched here, because the watcher's reload is what brings the text
/// back.
///
/// **It reports rather than swallows**, which is the one way it differs from every other write
/// in this file. `toggleTaskMarker` and its neighbours are one-shot gestures whose failure the
/// banner fully describes, so they `try?` and move on. This one has a *buffer* behind it: the
/// caller has to know whether the text landed, because on success it may stop holding it dirty
/// and on failure it must keep holding it — the whole of "nothing is lost while the window stays
/// open" (02-architecture.md § Write-failure surfacing). Hence an outcome, not a `Void`.
///
/// **A trashed card is writable here, deliberately.** The folder is resolved by
/// `cardBodyTarget(_:in:)` — a walk that spans **both containers** — because 05-card-window.md ▸
/// Deletion & lifecycle requires exactly that: "a dirty Edit buffer flushes into the card's
/// folder at its new `.trash/` location before the window dismisses — a surgical body write, so
/// the keystrokes survive a later restore". The write replaces the body span and nothing else,
/// so the card is not otherwise disturbed on its way into the trash.
public func writeCardBody(inCard cardID: ItemID, body: String) -> CardBodyWriteOutcome {
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return .vanished }
let folder = target.folder(under: rootURL)
do {
// The closure's signature is spelled out because it returns a value — the inference wart
// `performWrite`'s doc comment records.
let wrote = try performWrite { () throws(BoardWriteError) -> Bool in
try BoardWriter.writeBody(inItemFolder: folder, body: body)
}
return wrote ? .written : .unchanged
} catch let refusal as BoardStoreWriteRefusal {
guard case let .readOnlyLocked(reason) = refusal else { return .unchanged }
return .suspended(reason)
} catch let error as BoardWriteError {
return .failed(error)
} catch {
// `performWrite`'s `throws` is untyped only because its two error types have not been
// unified yet (`BoardStoreWriteRefusal`); there is no third thing it can throw.
Self.logger.error("unexpected error saving a card body: \(String(describing: error), privacy: .public)")
return .unchanged
}
}
/// Registers **one Edit session** as one undo step — 13-native-undo.md ▸ Rules' coalescing
/// sentence, stated where the session ends rather than where the bytes land.
///
/// ### Why this is not registered in `writeCardBody`
///
/// Because a session is not a save. "An Edit session is one step, registered at the Edit→Preview
/// flip (the effective Save — 05-card-window.md)", and a session contains any number of debounced
/// saves: registering per write would put a step on the stack every ~700 ms of typing, and ⌘Z
/// would walk backwards through the user's keystrokes in seven-hundred-millisecond slices rather
/// than undoing the edit they made. So `CardBodyEditSession` remembers the bytes disk held when
/// the session's first save landed, and calls this once at the flip with that pair — the same
/// boundary pro-m1's auto-committer coalesces on, for the same reason.
///
/// ### The bytes are the whole state
///
/// `BoardWriter.writeBody` replaces the body span and nothing else, so a step built from two body
/// strings restores the prior body **byte for byte** — unknown keys, comments and key order above
/// the delimiter were never this write's to change. That is the one inverse in the app whose
/// fidelity is byte-level rather than field-level.
///
/// ### Its staleness predicate is the bytes, at the path the session wrote to
///
/// "Body steps compare bytes" (13 ▸ Rules), so the expectation is the whole body span as this
/// session left it — a foreign editor that changed one character of it skips the step rather than
/// throwing that character away. The **container rides in the path** (`HistoryStaleness`): a
/// session that ended because its card was moved to the trash registers against the trash folder
/// it actually flushed into, and a later restore moves the card out from under the step, which
/// the ordinary existence check then reads as the collision it is.
public func registerBodyEdit(inCard cardID: ItemID, priorBody: String, newBody: String) {
guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
let folder = target.folder(under: rootURL)
let title = Self.cardTitle(at: target, in: snapshot)
registerStep(
HistoryPhrase.name(.edit, kind: .card),
subject: title,
undoExpects: [.present(folder, .body(newBody))],
redoExpects: [.present(folder, .body(priorBody))]
) { _ in
_ = try BoardWriter.writeBody(inItemFolder: folder, body: priorBody)
} redo: { _ in
_ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
}
}
/// Which folder a card's body write lands in — **the one card walk that spans both containers**.
///
/// Every other resolution in this file goes through `boardItem`, whose board-side-only answer is
/// what keeps gestures off cards that have left the working set. This one deliberately does not:
/// the card window's dismissal flush has to reach a card that was moved into the trash *out from
/// under the buffer* (05 ▸ Deletion & lifecycle), and to `boardItem` that card is already gone. A
/// card whose folder is genuinely no longer in the tree — purged, or moved to another board —
/// still resolves to `nil`, which is the case 05 answers with "nowhere left to write".
nonisolated static func cardBodyTarget(_ id: ItemID, in snapshot: BoardModel) -> ItemPath? {
for lane in snapshot.lanes {
if lane.cards.contains(where: { $0.id == id }) { return .card(lane: lane.id, id: id) }
}
return snapshot.trash.contains { $0.id == id } ? .trashCard(id) : nil
}
/// A card's title at a resolved path, in either container — the skip banner's quoted subject.
nonisolated static func cardTitle(at path: ItemPath, in snapshot: BoardModel) -> String? {
switch path {
case let .card(lane, id):
snapshot.lanes.first { $0.id == lane }?.cards.first { $0.id == id }?.title.value
case let .trashCard(id):
snapshot.trash.first { $0.id == id }?.title.value
case let .lane(id):
snapshot.lanes.first { $0.id == id }?.title.value
}
}
// MARK: - Raw source
/// Reads a card's `index.md` as literal text, for the raw-source outlet's entry
/// (05-card-window.md ▸ Raw source outlet: "Entering source mode flushes any pending title/body
/// edits first, then reads the file fresh from disk").
///
/// **Never from the snapshot**, which is what the design's "fresh" means and what the store is
/// least able to offer: a `BoardModel` holds a parsed `FrontmatterDocument`, and re-emitting it
/// would be a rendering of the file rather than the file. It also lags the reload, so a pull or
/// an agent write that landed a moment ago would be invisible to the one surface that promises to
/// show what is actually there.
///
/// **Ancestor-walked liveness** (`boardItem`), unlike `writeCardBody`'s deliberately liveness-blind
/// walk: there is nothing to *rescue* here — a tombstoned card's window is dismissing itself, and
/// opening its whole `index.md` in an editor whose Apply would undelete it is exactly what 05 ▸
/// Deletion & lifecycle forbids ("An open raw-source buffer discards instead: its Apply writes
/// the *whole* pre-tombstone `index.md` and would silently undelete the card").
///
/// No `performWrite` bracket and no banner: this is a read, and its one failure — a file that is
/// not UTF-8, or is gone between the snapshot and the read — is the card window's alert to raise,
/// where it can say "so source mode did not open" rather than joining a strip of write failures.
public func readCardSource(inCard cardID: ItemID) -> RawSourceReadOutcome {
guard let target = Self.boardItem(cardID, in: snapshot), let card = target.cardID else { return .vanished }
let folder = rootURL
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
do {
return .read(try BoardWriter.readRawSource(ofCard: folder))
} catch {
return .failed(error)
}
}
/// Applies a raw-source buffer: **validate, then write the bytes verbatim** (05-card-window.md ▸
/// Raw source outlet).
///
/// ### Validation runs before the bracket, on purpose
///
/// A proposal that would not load is not a failed write — it is a write that never started. Doing
/// it here means an invalid Apply opens no watcher bracket, posts no banner, and touches nothing;
/// the typed `BoardLoadError` travels back so the window's alert can show the loader's own detail
/// ("detailed alert on error, stays in source mode"). `BoardWriter.writeRawSource` validates the
/// same bytes through the same function again as its own guarantee — the two are one call to
/// `BoardLoader.validateCardIndex`, not two rules that could drift.
///
/// ### Everything else is an ordinary store write
///
/// One `performWrite` bracket, so the echo comes back as a single app-mediated reload that
/// refreshes every window on the board; the banner posts itself on a real failure; the snapshot is
/// never touched here. The read-only lock refuses it like any other write — Apply is a mutation,
/// however literal — and the buffer's owner reads `.suspended` as "hold the text", the standing
/// lock row being the message.
///
/// A pull landing mid-session is not consulted at all: "Apply stays last-writer-wins" (05, citing
/// 07-sync-collab.md), the same posture the Edit buffer takes.
///
/// **It registers no undo step**, `toggleTaskMarker`'s reason: 13-native-undo.md ▸ Rules makes the
/// *Edit session's* body save undoable, and Apply is not one — it is a whole-file replacement of
/// bytes the user typed themselves, with the raw buffer still on screen as its own record of what
/// they were.
public func applyCardSource(inCard cardID: ItemID, text: String) -> RawSourceApplyOutcome {
guard let target = Self.boardItem(cardID, in: snapshot), let card = target.cardID else { return .vanished }
let folder = rootURL
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
do throws(BoardLoadError) {
_ = try BoardLoader.validateCardIndex(Data(text.utf8), path: BoardLoader.indexFileName)
} catch {
return .invalid(error)
}
do {
// The closure's signature is spelled out because it returns a value — the inference wart
// `performWrite`'s doc comment records.
let wrote = try performWrite { () throws(BoardWriteError) -> Bool in
try BoardWriter.writeRawSource(inCard: folder, text: text)
}
return wrote ? .applied : .unchanged
} catch let refusal as BoardStoreWriteRefusal {
guard case let .readOnlyLocked(reason) = refusal else { return .unchanged }
return .suspended(reason)
} catch let error as BoardWriteError {
return .failed(error)
} catch {
Self.logger.error("unexpected error applying raw source: \(String(describing: error), privacy: .public)")
return .unchanged
}
}
// MARK: - Board rename
/// Writes the board's own `title` — the board popover's rename field (03-board-ui.md § Board
/// popover), and the one rename in the app with no item to aim at.
///
/// **It edits frontmatter, never the folder**: "Rename edits the board's frontmatter `title`
/// only — the folder is never renamed by the app; the Finder document name is Finder's to
/// change" (§ Board popover, 01-storage-format.md § Board naming). The app's display name and
/// the Finder document name may therefore diverge, which is accepted rather than reconciled.
///
/// The three commit rules are `commitRename`'s, deliberately identical — one rename vocabulary
/// whatever level it is aimed at:
///
/// - **Trimmed**, so a title of three spaces is a slip rather than a name.
/// - **An empty commit removes the key.** A board with no `title` falls back to its *folder
/// name* (§ Board naming) — never the "Untitled" placeholder cards and lanes show, and never
/// `title: ""`, which would be a real if blank title with nothing to fall back to.
/// - **An unchanged title writes nothing**, so a popover opened and dismissed with Return
/// neither stamps `modified` nor mints a commit.
///
/// There is no vanished-target guard, because a board cannot tombstone itself out of its own
/// window (01-storage-format.md § Deletion): the only way this target goes away is the root
/// itself vanishing, which is the read-only lock's story, and `performWrite` refuses under it
/// before anything touches disk.
public func renameBoard(_ title: String?) {
let typed = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let newTitle: String? = typed.isEmpty ? nil : typed
guard newTitle != snapshot.title.value else { return }
let folder = rootURL
let priorTitle = snapshot.title.value
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so a
// refusal names the board by the title it still has (see `WriteOperation.rename`).
try Self.setTitle(newTitle, at: folder)
}
guard landed != nil else { return }
// rename → restore title, at the one level with no item to aim at. The board root is never
// tombstoned however its frontmatter reads (a board-level `deleted:` is a tolerated load
// warning), so `.live` here means exactly "the root is still readable".
registerStep(
HistoryPhrase.name(.rename, kind: .board),
subject: newTitle ?? priorTitle,
undoExpects: [.present(folder, .title(newTitle))],
redoExpects: [.present(folder, .title(priorTitle))]
) { _ in
try Self.setTitle(priorTitle, at: folder)
} redo: { _ in
try Self.setTitle(newTitle, at: folder)
}
}
// MARK: - Lane reorder
/// Commits a lane drag: `id` lands at display position `index` among the board's live lanes,
/// counted **with the dragged lane itself removed** — which is the index
/// `DropSlotMath.laneSlot` produces.
///
/// Within-board only. A cross-board lane drag is the locality model's (04-interactions.md ▸
/// Drag and drop) and belongs to m5's drag card; here source and destination board roots are
/// the same URL, so `moveItem` takes its same-parent degenerate-reorder path and rewrites
/// exactly one file — the moved lane's `order`.
///
/// **A drag that ends where it started writes nothing**: `index == from` re-inserts the lane in
/// its own slot, and a no-op must not stamp `modified` or mint a commit — the resize drag's
/// rule, and for the same reason.
public func moveLane(_ id: ItemID, toIndex index: Int) {
let lanes = snapshot.lanes
guard let from = lanes.firstIndex(where: { $0.id == id }) else { return }
var remaining = lanes
remaining.remove(at: from)
let target = min(max(0, index), remaining.count)
guard target != from else { return }
let root = rootURL
let folder = root.appendingPathComponent(id.rawValue)
// The rank the lane held before the write and the one it lands on — both read out of the
// bracket below, because a renumber that fires inside it moves the *prior* value too: the
// dragged lane is among the renumbered children, so its pre-gesture `order` would no longer
// place it where it was. What an inverse must restore is the rank the file held immediately
// before its own rewrite, which is exactly what this captures either way.
var priorOrder = lanes[from].order
var newOrder: Double?
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target)
if rank == nil {
// Compact and place again. Unlike the card case the dragged lane *is* among the
// renumbered children — it is a real folder on disk — so its fresh rank is dropped
// from the ladder before the neighbours are consulted.
try BoardWriter.renumberVisibleChildren(of: root)
let renumbered = Ranks.renumbered(count: lanes.count)
priorOrder = renumbered[from]
var compacted = renumbered
compacted.remove(at: from)
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
}
guard let rank else { return }
newOrder = rank
_ = try BoardWriter.moveItem(
at: folder,
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
guard landed != nil, let newOrder else { return }
// reorder → restore original `order` (13-native-undo.md ▸ Rules). A lane drag never changes
// parent — the board root is the only one there is — so 06's vocabulary word for it is
// Reorder, not Move.
let restored = priorOrder
registerStep(
HistoryPhrase.name(.reorder, kind: .lane),
subject: lanes[from].title.value,
undoExpects: [.present(folder, .order(newOrder))],
redoExpects: [.present(folder, .order(restored))]
) { _ in
try Self.setOrder(restored, at: folder)
} redo: { _ in
try Self.setOrder(newOrder, at: folder)
}
}
/// The bare rank rewrite an inverse reorder performs — `moveItem`'s same-parent degenerate path
/// with the URL arithmetic taken out, since an inverse always names the folder directly.
private static func setOrder(_ order: Double, at folder: URL) throws(BoardWriteError) {
try BoardWriter.updateIndex(inItemFolder: folder, operation: .reorder(title: nil)) { document in
document.set(FrontmatterKeys.order, to: .double(order))
}
}
/// The within-board **lane drag**, multi-drag included: `ids` land contiguously at display
/// position `index` among the board's live lanes, counted with the dragged run removed — the
/// index `DropSlotMath.laneSlot` produces.
///
/// `moveLane`'s plural, and it exists rather than a loop over it because "one `performWrite`
/// bracket per gesture whatever the set's size" is load-bearing (DRAG-REORDER.md § The drop
/// commits): one app-mediated reload, and on git boards one commit rather than N.
///
/// The run keeps **board order**, which is the lane level's flatten order — a multi-lane drag has
/// no other relative order to preserve.
///
/// A drag that changes nothing writes nothing, stated as the arrangement rather than as a special
/// case: if the strip would render exactly what it renders now, no rank is rewritten and no
/// commit is minted.
public func moveLanes(_ ids: Set<ItemID>, toIndex index: Int) {
let lanes = snapshot.lanes
let members = lanes.filter { ids.contains($0.id) }
guard !members.isEmpty else { return }
let remaining = lanes.filter { !ids.contains($0.id) }
let target = min(max(0, index), remaining.count)
guard DropSlotMath.applied(lanes.map(\.id), moving: members.map(\.id), to: target) != lanes.map(\.id)
else { return }
let root = rootURL
// `moveLane`'s capture, per member — see its note on why the prior rank is read out of the
// bracket rather than off the snapshot.
var priorOrders = members.map(\.order)
var rewrites: [(folder: URL, order: Double)] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
if ranks == nil {
// Compact and place again. The dragged lanes *are* among the renumbered children —
// they are real folders on disk — so their fresh rungs are dropped from the ladder
// before the neighbours are consulted, exactly as `moveLane` drops its one.
try BoardWriter.renumberVisibleChildren(of: root)
let renumbered = Array(zip(lanes, Ranks.renumbered(count: lanes.count)))
priorOrders = renumbered.filter { ids.contains($0.0.id) }.map(\.1)
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
}
guard let ranks else { return }
for (member, rank) in zip(members, ranks) {
let folder = root.appendingPathComponent(member.id.rawValue, isDirectory: true)
rewrites.append((folder: folder, order: rank))
_ = try BoardWriter.moveItem(
at: folder,
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
guard landed != nil, !rewrites.isEmpty else { return }
// reorder → restore original `order`, one step for the whole run: "one `performWrite` bracket
// per gesture whatever the set's size" is the same sentence as one gesture, one undo step.
let inverse = Array(zip(rewrites.map(\.folder), priorOrders))
let forward = rewrites
registerStep(
HistoryPhrase.name(.reorder, kind: .lane, count: forward.count),
subject: members.count == 1 ? members[0].title.value : nil,
undoExpects: forward.map { .present($0.folder, .order($0.order)) },
redoExpects: inverse.map { .present($0.0, .order($0.1)) }
) { _ in
for (folder, order) in inverse {
try Self.setOrder(order, at: folder)
}
} redo: { _ in
for write in forward {
try Self.setOrder(write.order, at: write.folder)
}
}
}
// MARK: - Drag & drop commits
// The writes a released drag performs (04-interactions.md ▸ Drag and drop; the geometry that
// produces their `index` is DRAG-REORDER.md's, implemented in `DropSlotMath`).
//
// **One `performWrite` bracket per gesture**, whatever the set's size — the style batch's and
// the tombstone batch's rule, for their reason: one gesture, one app-mediated reload, one commit
// on git boards.
//
// **`index` always means the same thing**: a position among the destination's *rendered* items
// counted with the dragged run already removed — the resting layout's own convention, so the
// number the geometry produced is the number these methods consume, unrewritten. Every one of
// them clamps it rather than trusting it: a proposal computed against a snapshot one reload old
// must not trap.
//
// **Ranks are inserted, never permuted.** A drop rewrites only the dragged items' `order`, so
// the siblings' files — and `modified`, and a git commit — stay honest about what actually
// moved. That is the one place these differ from `sortSelection`, which permutes because its
// gesture is a permutation. `Ranks.insertionRanks` answering `nil` is the renumber trigger, and
// the fallback is `moveLane`'s: compact the destination, then place against the fresh ladder.
//
// **Silent no-ops throughout**, all of them the reload being the authority rather than the
// gesture: a destination lane that is gone or tombstoned (04's "a card is never filed under a
// `deleted:` parent"), a dragged set emptied by a foreign reload, and a drop that lands exactly
// where everything already is (a drag that ends where it started must not stamp `modified` or
// mint a commit — the resize drag's rule).
/// One member of a dragged card set, resolved against the snapshot: **where it is now** — a lane,
/// or the board's trash.
private struct DraggedCard {
let id: ItemID
/// The card's current home, which is also where an undo puts it back.
let path: ItemPath
let order: Double
/// What it is called, for the skip banner a stale step would raise — read here because the
/// snapshot this resolves against is the pre-write one, which is where a title still is.
let title: String?
/// The parent folder an inverse move returns it to.
func parent(under root: URL) -> URL {
switch path {
case let .card(lane, _): ItemPath.lane(lane).folder(under: root)
case .trashCard: BoardWriter.trashFolder(inBoard: root)
case .lane: root
}
}
}
/// `ids` narrowed to cards the snapshot holds and sorted into **flatten order** — "lane `order`
/// first, then card `order`" (`SelectionGrammar.boardCards`), which is what "drop inserts
/// contiguously in preserved relative order" means and the only order a `Set` cannot supply.
///
/// **Both containers, because a drag out of the trash is an ordinary move** (03-board-ui.md §
/// Trash, resettled 2026-07-28: "Restoring is an ordinary move out … there is no restore-specific
/// machinery and no Put Back"). A drag membership set is homogeneous by container, so exactly one
/// of the two branches below ever contributes; asking both is what lets `moveCards` and
/// `copyCards` serve the restore without a second code path able to disagree with them.
///
/// Members that vanished since the drag began are simply absent: drag membership is a UUID set
/// that vanished items leave silently (02-architecture.md), and "partial vanishing drops the
/// survivors" is the design's own wording.
private func draggedCards(_ ids: Set<ItemID>) -> [DraggedCard] {
var members: [DraggedCard] = []
for lane in snapshot.lanes {
for card in lane.cards where ids.contains(card.id) {
members.append(DraggedCard(
id: card.id,
path: .card(lane: lane.id, id: card.id),
order: card.order,
title: card.title.value
))
}
}
guard members.isEmpty else { return members }
for card in snapshot.trash where ids.contains(card.id) {
members.append(DraggedCard(
id: card.id,
path: .trashCard(card.id),
order: card.order,
title: card.title.value
))
}
return members
}
/// The within-board card drop: `ids` land contiguously at logical position `index` among
/// `laneID`'s rendered cards, in flatten order.
///
/// **Uniformly `moveItem`, cross-lane members and same-lane ones alike.** A member already in
/// the destination takes the writer's same-parent degenerate path, which rewrites exactly one
/// file — its `order` — and never touches the filesystem; a member arriving from another lane
/// moves its folder and carries the same explicit rank. That is `moveItem`'s own promise ("a
/// drop that lands back in its own lane is the same gesture as one that lands elsewhere"), and
/// leaning on it is what keeps this method from growing two branches that could disagree about
/// ordering.
///
/// The selection is deliberately untouched: every id survives the move, and the cards the user
/// is dragging should stay the cards the user is dragging.
public func moveCards(_ ids: Set<ItemID>, toLane laneID: ItemID, at index: Int) {
guard let destination = snapshot.lanes.first(where: { $0.id == laneID }) else { return }
let members = draggedCards(ids)
guard !members.isEmpty else { return }
let rendered = destination.cards
let memberIDs = members.map(\.id)
let remaining = rendered.filter { !ids.contains($0.id) }
let target = min(max(0, index), remaining.count)
// The no-op guard, stated as the arrangement rather than as a special case: if the lane
// would render exactly what it renders now, nothing moved. A member sitting in another lane
// — or in the trash — makes the two lists differ by construction, so this covers the
// cross-container case too.
guard DropSlotMath.applied(rendered.map(\.id), moving: memberIDs, to: target) != rendered.map(\.id)
else { return }
let root = rootURL
let laneFolder = ItemPath.lane(laneID).folder(under: root)
// The pre-write home of every member, per 13's "move → move back (original lane, original
// `order`)" — and, for a card coming out of the trash, back into `.trash/` at the rank it
// was filed under. A renumber inside the bracket rewrites the destination lane's own cards,
// so a member that was already there has its captured rank refreshed — `moveLane`'s note.
var origins = Dictionary(uniqueKeysWithValues: members.map { ($0.id, (path: $0.path, order: $0.order)) })
var arrivals: [(id: ItemID, order: Double)] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
if ranks == nil {
// Compact and place again. The renumber assigns in display order over the lane's
// cards, so the compacted ladder lines up one-for-one with `rendered`; the members
// already in this lane are dropped from it before the neighbours are consulted,
// exactly as `moveLane` drops the dragged lane's own rung.
try BoardWriter.renumberVisibleChildren(of: laneFolder)
let renumbered = Array(zip(rendered, Ranks.renumbered(count: rendered.count)))
for (card, rank) in renumbered where ids.contains(card.id) {
origins[card.id] = (path: .card(lane: laneID, id: card.id), order: rank)
}
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
}
guard let ranks else { return }
for (member, rank) in zip(members, ranks) {
arrivals.append((id: member.id, order: rank))
_ = try BoardWriter.moveItem(
at: member.path.folder(under: root),
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
guard landed != nil, !arrivals.isEmpty else { return }
// move → move back (original container, original `order`); a drop that never left its lane is
// 06's Reorder rather than Move, which is the same distinction the commit vocabulary draws —
// and a card arriving from the trash always counts as a Move, because it crossed containers.
let inverse: [(from: URL, toParent: URL, order: Double)] = arrivals.compactMap { arrival in
guard let origin = origins[arrival.id] else { return nil }
return (
from: laneFolder.appendingPathComponent(arrival.id.rawValue, isDirectory: true),
toParent: Self.parentFolder(of: origin.path, under: root),
order: origin.order
)
}
let forward: [(from: URL, order: Double)] = arrivals.compactMap { arrival in
guard let origin = origins[arrival.id] else { return nil }
return (from: origin.path.folder(under: root), order: arrival.order)
}
let crossed = members.contains { member in
if case let .card(lane, _) = member.path { return lane != laneID }
return true
}
// The two lists are index-aligned mirror images — `inverse[i].from` is where the card is now
// and `forward[i].from` is where it was — so the expectations read as one swap: **the undo
// wants the card at its destination holding the rank the drop gave it; the redo wants it back
// at its origin holding the rank it left.** The destination *path* is both the lane check and
// the container check: a card a foreign writer moved elsewhere — into the trash included —
// leaves nothing there to validate (`HistoryStaleness`).
registerStep(
HistoryPhrase.name(crossed ? .move : .reorder, kind: .card, count: arrivals.count),
subject: members.count == 1 ? members[0].title : nil,
undoExpects: zip(inverse, forward).map { .present($0.from, .order($1.order)) },
redoExpects: zip(inverse, forward).map { .present($1.from, .order($0.order)) }
) { _ in
for step in inverse {
_ = try BoardWriter.moveItem(
at: step.from,
toParent: step.toParent,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.order
)
}
} redo: { _ in
for step in forward {
_ = try BoardWriter.moveItem(
at: step.from,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.order
)
}
}
}
/// The parent folder an item at `path` sits in — the destination an inverse move returns it to.
nonisolated static func parentFolder(of path: ItemPath, under root: URL) -> URL {
switch path {
case let .card(lane, _): ItemPath.lane(lane).folder(under: root)
case .trashCard: BoardWriter.trashFolder(inBoard: root)
case .lane: root
}
}
/// The within-board ⌥-drag: fresh-GUID duplicates of `ids` land contiguously at `index` among
/// `laneID`'s rendered cards, **originals untouched** (04-interactions.md ▸ Drag and drop:
/// "originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop").
/// `created` survives because a copy is a fork — `CopyStamps.fork`, the same stamps paste uses.
///
/// **The ranks are placed among the lane's *full* rendered set**, not among the set with the
/// dragged members removed — the one place a copy's arithmetic differs from a move's. The
/// originals are lifted out of the layout for the duration of the drag whatever the effective
/// operation is (⌥ can be pressed and released mid-drag; a layout that re-admitted them on every
/// flip would flap the whole board), but they are still *on disk* holding their ranks, and a
/// rank chosen in the gap they appear to have vacated would collide with them the instant they
/// reappear. So the drop's index is mapped through to the neighbour it names — the card the run
/// lands in front of — and the rank is taken there.
public func copyCards(_ ids: Set<ItemID>, toLane laneID: ItemID, at index: Int) {
guard let destination = snapshot.lanes.first(where: { $0.id == laneID }) else { return }
let members = draggedCards(ids)
guard !members.isEmpty else { return }
let rendered = destination.cards
let remaining = rendered.filter { !ids.contains($0.id) }
let target = min(max(0, index), remaining.count)
// The resting-layout index, re-read against the layout the originals are still part of.
let placement = target < remaining.count
? (rendered.firstIndex { $0.id == remaining[target].id } ?? rendered.count)
: rendered.count
let root = rootURL
let laneFolder = ItemPath.lane(laneID).folder(under: root)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: placement, count: members.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: placement,
count: members.count
)
}
guard let ranks else { return }
for (member, rank) in zip(members, ranks) {
_ = try BoardWriter.copyItem(
at: member.path.folder(under: root),
toParent: laneFolder,
order: rank,
stamps: .fork
)
}
}
}
// MARK: - Cross-board arrivals
//
// Executed by the **destination** store, inside *its* bracket, because the destination is where
// the write's effects have to round-trip. A move mutates the source board's tree outside that
// board's own bracket, which is correct and needs no coordination: the source store's watcher
// sees a foreign change and reloads, which is exactly what a foreign change is.
//
// **None of these register an undo step, and the reason is 13's own two sentences.** Its inverse
// inventory names nine operations and an arrival is not among them; and "undo is board-local" —
// one stack per board — while a cross-board move's inverse would have to write into the *source*
// board, whose stack knows nothing about it and whose window may not even be open. The clipboard's
// half is the same shape one remove further: a paste's inverse needs the staged tree to still be
// there, which is exactly the staging lifecycle 13 defers with the attachment operations. Within a
// board, `copyCards`' ⌥-drag is left out with them: its Writer operation is `.copy`, not a create,
// and the three arrival paths are one gesture family that should gain undo together or not at all.
//
// `sources` are the items' folder URLs in the source board — both boards are open in this app,
// so both roots are already security-scoped and the payload can carry plain URLs. The source
// board root is read back off the path rather than passed alongside: 01-storage-format.md's
// fractal layout fixes the depth (`<root>/<lane>` and `<root>/<lane>/<card>`), so the URL
// already carries it and a second parameter could only ever disagree with the first.
/// The board root a lane folder sits directly under.
nonisolated static func boardRoot(ofLaneFolder folder: URL) -> URL {
folder.deletingLastPathComponent()
}
/// The board root a card folder sits two levels under.
nonisolated static func boardRoot(ofCardFolder folder: URL) -> URL {
folder.deletingLastPathComponent().deletingLastPathComponent()
}
/// Where one arriving item's bytes come from.
///
/// **Two producers, one arrival path.** A drag names folders in the source board; a paste names
/// folders in the clipboard's staging directory — and, when that snapshot is missing or
/// unreadable, the manifest's embedded `index.md` instead (04-interactions.md ▸ Clipboard's
/// staging-less fallback). Modelling the fallback as a second kind of *source* rather than as a
/// second arrival method is what keeps the rank insertion, the tombstone stripping and the
/// `deleted:` clearing stated once: everything downstream of "where do the bytes come from" is
/// identical, and a paste that half-falls-back mixes the two cases inside one bracket.
public enum ItemSource: Sendable, Equatable {
/// A folder on disk — the source board's own, or a staged snapshot of it.
case folder(URL)
/// The manifest's embedded text: the item's `index.md`, and (for a lane) its cards'.
/// Materialized by `BoardWriter.materializeItem`, byte-faithfully.
case text(index: String, cards: [String])
}
/// A cross-board card drop, landing contiguously at `index` among `laneID`'s rendered cards.
///
/// - `.copy` (the default between boards) — `copyItem` per folder: fresh GUIDs throughout,
/// `created` kept, originals untouched. Copies mint by construction, so the import boundary's
/// collision question never arises.
/// - `.move` (⌘-drag) — `moveItem` per folder: identity travels, and the import boundary remints
/// **only** the folders whose UUID the destination board already holds, per folder at the
/// finest grain (01-storage-format.md's per-folder degradation, which is `moveItem`'s own
/// behaviour rather than something this method arranges).
public func receiveCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
receive(
sources.map(ItemSource.folder),
operation: operation,
toLane: laneID,
at: index,
normalizingLooseFiles: false
)
}
/// **The clipboard's card arrival** — `receiveCards` with the one axis a paste varies
/// independently (04-interactions.md ▸ Clipboard).
///
/// It is the same commit as a drop's, deliberately: `.copy` materializes from the staged snapshot
/// (or, per entry, from the embedded `index.md`) and `.move` is the armed cut's — "the ⌘-drag
/// move path — identity travels", which is also the keyboard restore when the cut was made in the
/// trash (▸ The trash: "cut in the trash, paste into a lane is the keyboard-native restore, an
/// ordinary folder move").
///
/// **The trash's old copy-out rule is gone with the tombstone it stripped** (resettled
/// 2026-07-28): a trashed card carries no `deleted:` key, so a card copied out of the trash is
/// an ordinary copy of an ordinary card and there is nothing to clear at materialization.
///
/// `normalizingLooseFiles` is the remaining axis: **"a paste is an import boundary, so
/// normalization applies"** (04-interactions.md ▸ Clipboard, settled 2026-07-28 —
/// 01-storage-format.md's loose-file rule). Loose files the staged snapshot carries beside a
/// card's `index.md` land in the pasted card's `attachments/`, Finder-renamed on collision, so
/// "nothing the snapshot preserved is dropped on arrival" *and* nothing arrives out of place.
///
/// It has **no default**, here and on `receiveLanes`, so every arrival path states which side of
/// the import boundary it is on rather than inheriting an answer. The clipboard passes `true`
/// (both operations: 04 says "a paste is an import boundary" unqualified, and an armed cut's
/// move is a paste); the drag passes `false` and leaves its arrivals to the destination board's
/// own carve-out, which relocates on the next reload with the notice a user-initiated paste has
/// no need of.
public func receiveCards(
_ sources: [ItemSource],
operation: TransferOperation,
toLane laneID: ItemID,
at index: Int,
normalizingLooseFiles: Bool
) {
receive(
sources,
operation: operation,
toLane: laneID,
at: index,
normalizingLooseFiles: normalizingLooseFiles
)
}
private func receive(
_ sources: [ItemSource],
operation: TransferOperation,
toLane laneID: ItemID,
at index: Int,
normalizingLooseFiles: Bool
) {
guard !sources.isEmpty,
let destination = snapshot.lanes.first(where: { $0.id == laneID })
else { return }
let rendered = destination.cards
let target = min(max(0, index), rendered.count)
let root = rootURL
let laneFolder = ItemPath.lane(laneID).folder(under: root)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: target,
count: sources.count
)
}
guard let ranks else { return }
for (source, rank) in zip(sources, ranks) {
guard let arrived = try Self.materialize(
source,
operation: operation,
intoParent: laneFolder,
destinationBoardRoot: root,
sourceBoardRoot: Self.boardRoot(ofCardFolder:),
order: rank
) else { continue }
// Inside the same bracket, so the card lands normalized in one round trip rather
// than appearing loose for a reload and being tidied afterwards.
guard normalizingLooseFiles else { continue }
try BoardWriter.normalizeLooseFiles(
inCard: laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true)
)
}
}
}
/// One arrival's materialization — the two `ItemSource` kinds crossed with the two operations,
/// in the one place both the card path and the lane path can share.
///
/// **`.move` of a `.text` source is unreachable and answers `nil`.** A move needs a folder whose
/// identity travels, and the only producer of text sources is the clipboard's fallback, which is
/// a *copy* by construction (04-interactions.md ▸ Clipboard: an armed cut moves the surviving
/// originals, and a cut that cannot find them is void). Skipping is the standing posture for an
/// arrival that names nothing — the same silent no-op every other drop commit gives a source that
/// has gone.
private static func materialize(
_ source: ItemSource,
operation: TransferOperation,
intoParent parent: URL,
destinationBoardRoot: URL,
sourceBoardRoot: (URL) -> URL,
order: Double
) throws(BoardWriteError) -> ItemID? {
switch (source, operation) {
case let (.folder(folder), .copy):
return try BoardWriter.copyItem(at: folder, toParent: parent, order: order, stamps: .fork)
case let (.folder(folder), .move):
return try BoardWriter.moveItem(
at: folder,
toParent: parent,
sourceBoardRoot: sourceBoardRoot(folder),
destinationBoardRoot: destinationBoardRoot,
order: order
).id
case let (.text(index, cards), .copy):
return try BoardWriter.materializeItem(
inParent: parent,
indexText: index,
children: cards,
order: order
)
case (.text, .move):
return nil
}
}
/// A cross-board lane drop, landing contiguously at `stripIndex` among this board's lanes.
///
/// **The two operations no longer differ** (04-interactions.md ▸ Drag and drop, resettled
/// 2026-07-28): "A lane carries exactly its cards — the trash is board-level (`.trash/`), so
/// there is nothing lane-nested to strip or carry: copy and ⌘-drag move alike transfer the lane's
/// folder as it is; the old tombstone-stripping rule is retired with the tombstone model." A
/// copy therefore mints fresh GUIDs and a move carries the identity, and that is the whole of the
/// difference.
///
/// Within-board lane reorders are `moveLane(_:toIndex:)`, and a within-board lane *copy* does
/// not exist by drag at all (⌥ is ignored on lane drags; the clipboard is that operation's one
/// home), so this method is cross-board by construction.
public func receiveLanes(_ sources: [URL], operation: TransferOperation, at stripIndex: Int) {
receiveLanes(
sources.map(ItemSource.folder),
operation: operation,
at: stripIndex,
normalizingLooseFiles: false
)
}
/// **The clipboard's lane arrival** — `receiveLanes` with the staging-less fallback folded in
/// (04-interactions.md ▸ Clipboard).
///
/// The two operations keep their drag semantics exactly, because 04 says they are the same
/// semantics: "a pasted *copy* takes fresh GUIDs throughout; a cut-paste is the ⌘-drag move —
/// the folder moves whole (nothing lane-nested to strip or carry — the trash is board-level)".
///
/// `normalizingLooseFiles` is the import boundary's, exactly as on `receiveCards` and with the
/// same no-default rule; at lane level it reaches each arriving lane's **cards**, which is the
/// only level the carve-out has (a lane's own loose files keep the verbatim posture).
public func receiveLanes(
_ sources: [ItemSource],
operation: TransferOperation,
at stripIndex: Int,
normalizingLooseFiles: Bool
) {
guard !sources.isEmpty else { return }
let root = rootURL
let rendered = snapshot.lanes
let target = min(max(0, stripIndex), rendered.count)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: root)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: target,
count: sources.count
)
}
guard let ranks else { return }
for (source, rank) in zip(sources, ranks) {
guard let arrived = try Self.materialize(
source,
operation: operation,
intoParent: root,
destinationBoardRoot: root,
sourceBoardRoot: Self.boardRoot(ofLaneFolder:),
order: rank
) else { continue }
guard normalizingLooseFiles else { continue }
try BoardWriter.normalizeLooseFiles(
inLane: root.appendingPathComponent(arrived.rawValue, isDirectory: true)
)
}
}
}
// MARK: - Finder file drops
// **The attachment half registers no undo step** (13-native-undo.md ▸ Out of scope, ratified
// 2026-07-27): "attachment add/remove registers **no undo step** in v1", because remove →
// re-add needs the removed file to survive somewhere and that staging area is a design pass of
// its own. Add → remove would be a clean inverse on its own, but half a pair is worse than none:
// ⌘Z would undo attaching and refuse to undo detaching, which is not a rule anyone could learn.
// The *card-creating* half below is an ordinary create and does register one.
//
// The writes an external Finder file drag performs (04-interactions.md ▸ Drag and drop, "Files
// from Finder"): onto a card the files join its `attachments/`, onto lane empty space they become
// one card each. The gesture's half — which card, which slot — is `BoardDropContext`'s; these are
// ordinary store writes, with the drop commits' own rules above (one `performWrite` bracket per
// gesture; a vanished or tombstoned destination is a silent no-op, the reload being the
// authority; failures are the banner's).
//
// **Folders never arrive here from a drop.** "Folders are refused at hover" (04-interactions.md):
// the gesture refuses a folders-only drag outright and `FinderDrop.land` partitions a mixed one
// before it calls either of these, naming the skipped folders in a loss row. Both functions stay
// honest about a directory anyway — `BoardWriter.importAttachments` refuses one by design — since
// nothing about their contract says a drop is the only caller.
/// Copies `urls` into `cardID`'s `attachments/` — the drop-on-a-card half.
///
/// **The board container, and only it** (`boardItem`): a card that has been deleted is in
/// `.trash/`, and a drop on a target that vanished under the gesture writes nothing at all. That
/// is also the whole of "Finder file drops on trash cards are inert" (04-interactions.md ▸ The
/// trash) on the write side — the gesture refuses to propose one in the first place, and this
/// refuses to serve one that slipped through a reload.
///
/// A lane id is refused for the same reason a lane folder is: attachments belong to cards.
/// Multi-file, any type, and a name already taken is renamed Finder-style rather than
/// overwritten — all `BoardWriter.importAttachments`', including its failure shape: the first
/// failing file stops the batch and banners naming it, and everything already copied stays.
public func importAttachments(_ urls: [URL], toCard cardID: ItemID) {
guard !urls.isEmpty,
let item = Self.boardItem(cardID, in: snapshot),
let card = item.cardID
else { return }
let folder = rootURL
.appendingPathComponent(item.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.importAttachments(urls, intoCard: folder)
}
}
// MARK: - Removing an attachment
/// Moves one of a card's attachments to the **system** Trash — the card window attachment row's
/// Remove, its ⌫ twin, and nothing else (05-card-window.md ▸ Attachments).
///
/// **An ordinary bracketed write, which is the whole point of it being here** rather than a
/// `FileManager` call in the view: it mutates the card's folder, so the churn has to round back
/// as one *app-mediated* reload (the echo the watcher would otherwise read as a foreign edit),
/// it has to refuse under the read-only lock like every other mutation (`performWrite`'s gate),
/// and its failures have to reach the banner strip like every other write's. On git boards it
/// is also one commit, for free, for the same reason.
///
/// The guards are `importAttachments`' exactly, and its inverse in every way: **the board
/// container and only it** (`boardItem`), so a trashed card is as unreachable as a deleted one
/// and its attachments are not removable from a window that is dismissing itself in the same
/// breath; a lane id is refused because attachments belong to cards. Which *file* may go is
/// `BoardWriter.removeAttachment`'s listing check, and a name that is no longer there is a
/// silent no-op rather than a failure — the reload is the authority on what the card has.
public func removeAttachment(named name: String, fromCard cardID: ItemID) {
guard !name.isEmpty,
let item = Self.boardItem(cardID, in: snapshot),
let card = item.cardID
else { return }
let folder = rootURL
.appendingPathComponent(item.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.removeAttachment(named: name, fromCard: folder)
}
}
// MARK: - The loose-file carve-out
/// Moves every loose file the last applied snapshot found beside a card's `index.md` into that
/// card's `attachments/`, and posts one notice naming what moved — the **act** half of
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28,
/// "Lanework-owns-the-board"; the loader's `looseCardFiles` is the notice half).
///
/// **It registers no undo step**, and unlike its neighbours that is not a deferral: nobody asked
/// for it. The relocation is the app tidying its own house on a reload, not a gesture — there is
/// no ⌘Z that should follow it, and putting one on the stack would let the next ⌘Z undo something
/// the user never did. (It is `renumberVisibleChildren`'s posture: bookkeeping composes no event,
/// 06-history-undo.md ▸ Commit messages.)
///
/// **It is an ordinary app write and nothing more.** One `performWrite` bracket over the whole
/// board's worth of relocation, so the churn rounds back as a single app-mediated reload and (on
/// git boards) a single commit — the style batch's rule, applied to a batch the app started
/// itself. The snapshot is not touched here any more than it is anywhere else: the files move,
/// the watcher notices, the reload lands.
///
/// ### The read-only lock defers it, it does not cancel it
///
/// "The relocation … waits out any read-only lock — strays stay tolerated until it clears." A
/// locked board returns here having written nothing **and having remembered nothing**, so the
/// next attempt is a fresh one. The arming seam is `land(_:generation:origin:)`: every lock
/// clears on a successful reload and nowhere else, and this runs at the end of every successful
/// reload, after `clearLockIfDisproved` — so the reload that lifts the lock is the reload that
/// performs the relocation, with no timer, no queue, and no second state to keep in step.
///
/// ### It cannot hot-loop
///
/// The relocation's own reload re-walks the tree, which is the loop the guard exists for. After
/// a success the walk finds nothing loose, `looseCardFiles` empties, and the memo below is
/// cleared — the ordinary resting state. After a *failure* the walk finds the same files again,
/// and an unguarded call would fail again, forever, at the speed of a directory walk. So an
/// attempt is made only when the loose-file set **differs from the last one attempted**: one
/// failure, one banner row, then silence until the picture on disk actually changes (a file
/// added, removed, or partially moved by the failed attempt itself — each of which is a
/// different set and so a fresh attempt).
///
/// The failure is the banner's already: `performWrite` posts every `BoardWriteError` before it
/// rethrows, and the rethrow is swallowed here like every other gesture with nothing else to do
/// about it. Files moved before the failure stay moved, and the notice names exactly those.
public func relocateLooseCardFiles() {
let work = looseCardFiles
guard !work.isEmpty else {
// The resting state, and the memo's reset: a board with nothing loose has nothing to
// remember having tried.
attemptedRelocation = []
return
}
// Deferred, not abandoned — and deliberately *before* the memo is written, so the attempt
// this lock refused is not the attempt the guard below remembers.
guard readOnlyLock == nil else {
Self.logger.debug("loose-file relocation deferred — the board is read-only")
return
}
let signature = Self.relocationSignature(of: work)
guard signature != attemptedRelocation else { return }
attemptedRelocation = signature
let root = rootURL
var relocated: [BannerCenter.Relocation] = []
try? performWrite { () throws(BoardWriteError) -> Void in
for card in work {
let folder = root
.appendingPathComponent(card.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.cardID.rawValue, isDirectory: true)
let moved = try BoardWriter.relocateLooseFiles(card.fileNames, inCard: folder)
// A card whose files all vanished under the write contributes no line: the Writer
// skipped them because they are gone, and nothing was moved to report.
guard !moved.isEmpty else { continue }
relocated.append(BannerCenter.Relocation(
title: card.title,
fileNames: moved.map { $0.sourceURL.lastPathComponent }
))
}
}
banners.postRelocatedLooseFiles(relocated)
}
/// The loose-file picture as a comparable value: one entry per file, keyed by where it sits.
///
/// A `Set` rather than the array itself because the *identity* of the work is what matters, not
/// the order the walk happened to meet it in — and because two loads of an unchanged tree must
/// compare equal even if a lane's folder-name ordering shifted underneath them.
nonisolated static func relocationSignature(of work: [LooseCardFiles]) -> Set<String> {
var signature: Set<String> = []
for card in work {
for name in card.fileNames {
signature.insert("\(card.laneID.rawValue)/\(card.cardID.rawValue)/\(name)")
}
}
return signature
}
/// Creates one card per file at `index` in `laneID`, each titled with its filename minus the
/// extension and carrying that file as its attachment — the drop-into-a-lane half.
///
/// **`index` is the drop position, not the lane's end** (04-interactions.md ▸ Drag and drop,
/// settled 2026-07-28): "created cards land at the drop position — resolved through the same
/// card-grid zones an ordinary card drag uses … drops are positional everywhere, and
/// append-at-bottom stays the creation *trio*'s rule, not the drop's". The gesture resolves it
/// through `FileDropZones.landing`; the rank arithmetic below is `moveCards`', so a run landing
/// between two siblings takes exactly the ranks a card drop there would have produced.
///
/// **Ordinary store writes, with no drop-only path**: a fresh GUID and an inserted rank per card
/// (`Ranks.insertionRanks`, compacting and placing again when midpoint precision is exhausted,
/// exactly as `moveCards` does), then the m2 import machinery for the file. The whole batch is one
/// `performWrite` bracket, so a five-file drop rounds back as one reload and one commit.
///
/// **The title follows the empty-title rules**: a name that trims to nothing — a dotfile whose
/// stem is blank, a file called `" .png"` — writes no `title` key at all rather than an empty
/// string, since a missing key is the untitled state and `""` would be a real, blank title
/// (01-storage-format.md § Frontmatter).
///
/// **A Finder file drop is a user-initiated creation, so it clears the search**
/// (04-interactions.md § Search, stated by mechanism: "⌘N, Return-creation, the header button,
/// empty-space double-click, paste, and Finder file drops alike"). Cleared at the gesture, in
/// front of the write, exactly as the placeholder's begin clears it in front of the typing —
/// `TransientBoardState.noteUserCreation()` is the rule's one home, and the *attach* half of the
/// same gesture (`importAttachments`) deliberately does not call it, because a drop on a card
/// creates nothing that could be born invisible.
///
/// **Partial failure is honest, and leaves no half-made card.** The batch stops at the first file
/// that cannot be imported — an unreadable source, a vanished one, a disk with no room left —
/// which banners naming it; the cards already made keep their files, matching `importAttachments`'
/// own "everything already imported stays landed". The card whose import failed is removed again
/// before the throw: it was minted moments earlier in this same bracket and holds nothing but
/// what this call put there, and "creating-then-abandoning never leaves an empty card behind"
/// (04-interactions.md ▸ Grammar) is the rule it would otherwise break.
public func createCards(fromFiles urls: [URL], inLane laneID: ItemID, at index: Int) {
guard !urls.isEmpty,
let lane = snapshot.lanes.first(where: { $0.id == laneID })
else { return }
transient.noteUserCreation()
let rendered = lane.cards
let target = min(max(0, index), rendered.count)
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
var created: [(folder: URL, source: URL)] = []
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(
amongVisible: rendered.map(\.order), at: target, count: urls.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count), at: target, count: urls.count)
}
guard let ranks else { return }
for (url, rank) in zip(urls, ranks) {
// Create then place, `commitPlaceholder`'s pair: the Writer's create appends after the
// visible siblings by contract, and the rank rides its same-parent degenerate reorder
// inside this same bracket rather than widening the create's signature.
let id = try BoardWriter.createCard(inLane: laneFolder, title: Self.cardTitle(forFile: url))
let folder = laneFolder.appendingPathComponent(id.rawValue, isDirectory: true)
do throws(BoardWriteError) {
_ = try BoardWriter.moveItem(
at: folder,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
_ = try BoardWriter.importAttachments([url], intoCard: folder)
} catch {
try? FileManager.default.removeItem(at: folder)
throw error
}
created.append((folder: folder, source: url))
}
}
// create → remove the created folder (13-native-undo.md ▸ Rules), one step for the drop
// whatever its file count. The redo re-imports from the same source URLs the gesture used —
// the one create in the app whose replay needs more than the card's own bytes. Collected
// from what actually landed rather than from `urls`, so a batch that failed halfway still
// hands ⌘Z exactly the cards it left behind.
let items = created.compactMap { createdItem(at: $0.folder, kind: .card, attachments: [$0.source]) }
registerCreation(
items,
kind: .card,
subject: created.count == 1 ? Self.cardTitle(forFile: created[0].source) : nil
)
}
/// The title a dropped file's card takes: **the filename without its extension**
/// (04-interactions.md ▸ Drag and drop), or `nil` — no `title` key — when that trims to nothing.
///
/// The split is `URL`'s own, which is also Finder's: an extension-less name keeps all of itself,
/// and a multi-dot name loses only the last component (`archive.tar.gz` → `archive.tar`), matching
/// the collision-rename rule the same file's attachment goes through.
nonisolated static func cardTitle(forFile url: URL) -> String? {
let stem = url.deletingPathExtension().lastPathComponent
.trimmingCharacters(in: .whitespacesAndNewlines)
return stem.isEmpty ? nil : stem
}
// MARK: - Within-lane sort
/// The lane and the new card ordering one ⌥⌘↑/⌥⌘↓ press would produce, or `nil` when the press
/// is not available — **the menu items' `disabled` condition and the write's guard, as one
/// answer** (`LaneWidthCommands`' rule).
///
/// `nil` covers every refusal the design names in one expression: an empty or trash-side
/// selection ("⌥⌘↑/⌥⌘↓ are inert on trash cards — the trash's order is its arrival order, not a
/// workspace to arrange"), a lane selection ("with a lane
/// selected … ⌥⌘↑/⌥⌘↓ are inert"), a card selection that **spans lanes** ("cards never change
/// lanes by ⌘-arrow … so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes"), and a block
/// already at the end of its lane.
func sortPlan(_ direction: SortMath.Direction) -> (lane: Lane, ordering: [ItemID])? {
let selection = transient.selection
guard selection.container == .board,
SelectionGrammar.kind(of: selection, in: snapshot) == .card,
// `nil` here *is* the spans-lanes case: the helper answers only when one lane holds
// the whole set.
let laneID = Self.lane(holding: selection.ids, in: snapshot),
let lane = snapshot.lanes.first(where: { $0.id == laneID })
else { return nil }
let rendered = lane.cards.map(\.id)
guard let ordering = SortMath.reordered(rendered, moving: selection.ids, direction) else { return nil }
return (lane, ordering)
}
/// Board ▸ Move Up / Move Down (⌥⌘↑/⌥⌘↓) — the within-lane sort (04-interactions.md ▸ The map).
///
/// **One `performWrite` bracket**, like every other batch here: one gesture, one app-mediated
/// reload, one commit on git boards.
///
/// **The ranks are permuted, not invented.** The lane's existing `order` values, read in display
/// order, are already a sorted ladder of exactly the right length — so the new ordering takes
/// them rung for rung and only the cards whose *position* changed are rewritten. A block stepping
/// past one sibling therefore touches the block plus that sibling and nothing else, which is what
/// keeps `modified` (and, later, a git commit) honest about what actually moved.
///
/// The one case that ladder cannot serve is **duplicate `order` values**, where display order is
/// decided by the folder-name tie-break (`Ranks.isOrderedForDisplay`) rather than by the rank —
/// permuting equal ranks would write the file and leave the board looking identical. That is the
/// renumber trigger, exactly as an exhausted midpoint is elsewhere: compact the lane, then place
/// against the fresh ladder (`commitPlaceholder`'s and `moveLane`'s pattern).
///
/// The selection, the anchor and the head are deliberately untouched: every id survives, and the
/// cards the user is moving should stay the cards the user is moving.
public func sortSelection(_ direction: SortMath.Direction) {
guard let plan = sortPlan(direction) else { return }
let rendered = plan.lane.cards
let laneFolder = rootURL.appendingPathComponent(plan.lane.id.rawValue, isDirectory: true)
let orders = rendered.map(\.order)
let positions = Dictionary(uniqueKeysWithValues: rendered.enumerated().map { ($1.id, $0) })
// Every rank this gesture rewrites, with the value it replaced — the permutation's own
// inverse. It is read out of the bracket because the ladder may be the *renumbered* one:
// after a compaction the card at display position `origin` holds `ladder[origin]`, which is
// what its own rewrite overwrites and therefore what an undo has to put back.
var rewrites: [(folder: URL, from: Double, to: Double)] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var ladder = orders
if !Self.isStrictlyAscending(orders) {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
// The renumber assigns in display order, so the compacted ladder lines up one-for-one
// with `rendered` — the same alignment `commitPlaceholder` relies on.
ladder = Ranks.renumbered(count: rendered.count)
}
for (destination, id) in plan.ordering.enumerated() {
guard let origin = positions[id], origin != destination else { continue }
let rank = ladder[destination]
let folder = laneFolder.appendingPathComponent(id.rawValue, isDirectory: true)
rewrites.append((folder: folder, from: ladder[origin], to: rank))
try BoardWriter.updateIndex(
inItemFolder: folder,
// `.reorder(title: nil)`: `updateIndex` enriches it off the document it reads, so
// a failure names the card by its own title.
operation: .reorder(title: nil)
) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
}
}
}
guard landed != nil, !rewrites.isEmpty else { return }
// reorder → restore original `order` (13-native-undo.md ▸ Rules). The step is named for the
// *gesture's* subject — the cards the user was moving — not for every sibling the permutation
// displaced, which is the same rule 06 applies to a commit subject.
//
// Every rewritten rank is validated, the displaced siblings' included: they are what this
// permutation wrote, so they are what it must find unchanged — the step is *named* for the
// gesture's subject and *validated* over its whole write.
let steps = rewrites
let moved = selection.ids
registerStep(
HistoryPhrase.name(.reorder, kind: .card, count: moved.count),
subject: moved.count == 1 ? moved.first.flatMap { Self.boardItem($0, in: snapshot)?.title } : nil,
undoExpects: steps.map { .present($0.folder, .order($0.to)) },
redoExpects: steps.map { .present($0.folder, .order($0.from)) }
) { _ in
for step in steps {
try Self.setOrder(step.from, at: step.folder)
}
} redo: { _ in
for step in steps {
try Self.setOrder(step.to, at: step.folder)
}
}
}
/// Whether a lane's ranks separate its cards on their own — the condition under which they can
/// be permuted rather than replaced. Ties fall to the folder-name tie-break, which a permutation
/// cannot reach past.
nonisolated static func isStrictlyAscending(_ orders: [Double]) -> Bool {
zip(orders, orders.dropFirst()).allSatisfy { $0 < $1 }
}
// MARK: - The trash
/// Whether physically removing a card on this board destroys the only copy of it — and
/// therefore whether a permanent delete stands an alert between one keystroke and unrecoverable
/// deletion (03-board-ui.md § Trash, "Both confirm exactly where the loss is real").
///
/// **Every board is `true` today**, because every board is history mode *none*: nothing in the
/// app keeps a second copy, so a purge is final everywhere.
///
// m7-git: git boards answer `false` here — "on git boards they act immediately (delete-never-
// forgets)" (06-history-undo.md). Repo-nested boards stay `true` alongside mode none: the app
// manages no history for them either. The named predicate exists now so the committer card
// changes one expression rather than hunting the confirmation logic out of two menu items and an
// alert.
public var purgeIsUnrecoverable: Bool { true }
/// **File ▸ Delete ⌘⌫ and its plain-⌫ grammar twin — staged by place** (04-interactions.md ▸
/// The map, resettled 2026-07-28: "one Delete vocabulary, staged by place").
///
/// The selection's container is the whole of the staging, and it is asked exactly once, here:
/// a board selection moves into `.trash/` (or, for lanes, deletes physically), a trash selection
/// deletes **permanently**. That is why Put Back's ⌘⌫ twin could retire — there is one Delete
/// item and one predicate, and which write it performs is a fact about where the user was
/// working, not about which of two menu rows AppKit happened to enable.
///
/// **The confirmation is not here.** Whether the permanent branch's loss is real is
/// `purgeIsUnrecoverable`'s question and the alert is the window's (`TrashConfirmations`); a
/// store method that put up its own dialog could not be driven from a test.
public func deleteSelection() {
switch selection.container {
case .board: delete(selection.ids)
case .trash: deleteTrashCards(selection.ids)
}
}
/// Deletes every **board** item in `ids` — cards into `.trash/`, lanes outright — in one bracket.
///
/// **Two writes, because they are two acts** (03-board-ui.md § Trash): "deleting a card moves its
/// folder into `<board-root>/.trash/`", while "Cards only. Lanes are never trashed — deleting a
/// lane deletes it, folder and contents, physically. The net is undo, not the trash."
///
/// **A set naming both is not a gesture this app can produce** — the selection is cards XOR lanes
/// (04-interactions.md § Selection) — so the partition below never actually splits, and when a
/// caller hands one anyway the lanes win: a lane delete takes its cards with it, and running the
/// trash move as well would put a second step on the undo stack for one keystroke.
///
/// **Ids that name nothing are silently skipped**, not refused: the paths are resolved against
/// the snapshot, so a selection the next reload will drop writes nothing. An empty resolution
/// never opens a bracket at all.
///
/// **The selection moves to the successor sibling** — 04-interactions.md ▸ The map's Finder-style
/// rule ("next card in the lane, next lane on the board; the last sibling's predecessor
/// otherwise; empty container = nothing selected"), whose whole point is that "repeated ⌫ walks
/// down a lane".
///
/// Two things make that hold. The successor is computed from the **pre-write** snapshot, which is
/// the last one that still knows where the doomed items sat; and it is selected **immediately**,
/// rather than waiting for the reload the delete will echo back — a second ⌫ pressed before the
/// watcher rounds the first one back must already have somewhere to land.
///
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
/// reload-survival rule).
public func delete(_ ids: Set<ItemID>) {
let paths = ItemPath.resolve(ids, in: .board, snapshot: snapshot)
guard !paths.isEmpty else { return }
// The successor is drawn from what the container is *showing*, so a delete under an active
// search walks the filtered lane rather than selecting a card the query has hidden.
let successor = SelectionGrammar.successor(
afterDeleting: ids,
in: .board,
snapshot: snapshot,
filter: searchFilter
)
let lanes = paths.compactMap { path -> ItemID? in
guard case let .lane(id) = path else { return nil }
return id
}
let landed = lanes.isEmpty
? moveToTrash(paths.compactMap(Self.cardMove(of:)))
: removeLanes(lanes)
guard landed else { return }
if let successor {
select([successor], in: .board, anchor: successor, head: successor)
} else {
clearSelection()
}
}
/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash): "the drag is the pointer's delete
/// gesture — release moves the dragged card(s) into `.trash/`".
///
/// *Exactly* the ⌫ delete is a claim about the disk, and `moveToTrash(_:)` is what makes it
/// structural rather than a matter of two call sites staying in step: one write op, one bracket,
/// one set of ranks and stamps, so a card deleted by drop and a card deleted by keystroke are
/// byte-indistinguishable afterwards.
///
/// ### The one thing it does not share is the successor
///
/// ⌫ moves the selection to the deleted card's successor sibling because *the selection* lost its
/// cards and "repeated ⌫ walks down a lane" — the rule exists to keep a keyboard gesture
/// repeatable. A drag has no such continuation, and its run is **not necessarily the selection at
/// all**: dragging a card outside the selection drags that card alone and leaves the selection
/// exactly where it was (`LaneView.startCardDrag`), so picking a successor for it would re-point a
/// selection that never lost anything.
///
/// So this writes and says nothing about the selection, and the ordinary reload does the rest: a
/// board-side set ejects members that cross into the trash, as the vanish it is
/// (02-architecture.md's reload-survival rule).
public func deleteByDrag(cardIDs: [ItemID]) {
_ = moveToTrash(ItemPath.resolve(Set(cardIDs), in: .board, snapshot: snapshot).compactMap(Self.cardMove(of:)))
}
/// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — moves the card
/// to the trash … the window then dismisses itself").
///
/// The write is `moveToTrash(_:)`, so a card deleted from its own window is byte-indistinguishable
/// from one deleted with ⌫ on the board or dropped on the trash column. What differs is the same
/// thing that differs for the drag, and for its reason: **it says nothing about the selection.**
///
/// **It does not dismiss the window either**, and must not: the window's dismissal is a *fate*
/// re-derived from every snapshot (`CardWindowHost.cardWindowFate`), so the move this writes
/// comes back through the watcher and the fate walk takes the window down — the same path an
/// agent's or another window's delete takes. A second dismissal from here would be a second rule
/// able to disagree with the first.
public func deleteCard(_ id: ItemID) {
_ = moveToTrash(ItemPath.resolve([id], in: .board, snapshot: snapshot).compactMap(Self.cardMove(of:)))
}
/// One card about to be moved into the trash: where it is now, and what an undo has to put back.
private struct TrashMove {
let id: ItemID
let laneID: ItemID
/// The rank it holds in its lane — the position an undo returns it to (13's "move → move
/// back (original lane, original `order`)").
let order: Double
let title: String?
}
/// A resolved board path as a card move, or `nil` for a lane — the one place the partition is
/// spelled, so no caller re-derives it.
private static func cardMove(of path: ItemPath) -> (lane: ItemID, card: ItemID)? {
guard case let .card(lane, id) = path else { return nil }
return (lane, id)
}
/// **The delete write itself: a physical move into `<root>/.trash/`, at a freshly minted top
/// rank** — one `performWrite` bracket whatever the set's size and whichever gesture asked.
///
/// Spelled once so ⌫, drop-on-trash and the card window's button cannot drift apart on disk;
/// everything that differs between them is about the *selection*, and lives in the callers.
///
/// **The rank is the store's to mint** (03-board-ui.md § Trash: "every arrival lands at the
/// trash's topmost position, minting an `order` rank above the current top"). That is a question
/// about the snapshot, which the stateless Writer does not have — so `Ranks.insertAtHead` runs
/// here over `snapshot.trash`, and a multi-card delete threads the minted rank back through the
/// running list so each card in the run lands above the one before it. Newest-first therefore
/// falls out of ordinary ranks, with no timestamp sort anywhere.
///
/// - Returns: whether the write landed, so a caller can decide what to do with the selection.
@discardableResult
private func moveToTrash(_ cards: [(lane: ItemID, card: ItemID)]) -> Bool {
let moves: [TrashMove] = cards.compactMap { entry in
guard let lane = snapshot.lanes.first(where: { $0.id == entry.lane }),
let card = lane.cards.first(where: { $0.id == entry.card })
else { return nil }
return TrashMove(id: card.id, laneID: lane.id, order: card.order, title: card.title.value)
}
guard !moves.isEmpty else { return false }
let root = rootURL
// The ranks, minted against the trash as it stands and threaded forward: each arrival is
// above the previous one, so a three-card ⌫ reads newest-first in the column exactly as three
// separate deletes would.
var ladder = snapshot.trash.map(\.order)
var ranks: [Double] = []
for _ in moves {
let rank = Ranks.insertAtHead(ofVisible: ladder)
ranks.append(rank)
ladder.insert(rank, at: 0)
}
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for (move, rank) in zip(moves, ranks) {
try BoardWriter.deleteCardToTrash(
at: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root),
inBoard: root,
order: rank
)
}
}
guard landed != nil else { return false }
// delete → **move back out of `.trash/`** (13-native-undo.md ▸ Rules, ▸ Interaction with the
// trash: "a card delete is a move into `.trash/`, so its undo is the ordinary inverse move,
// returning the card to its source lane and rank").
//
// The redo replays the *forward* write with its own captured rank, exactly as every other
// redo in this file replays the values its gesture wrote — so a redone delete lands the card
// back where the undo took it from, rather than at whatever the top of the trash has become
// in the meantime.
//
// **The expectations are one swap, and the container rides in the path** (`HistoryStaleness`):
// the undo wants the card in the trash holding the rank the delete gave it; the redo wants it
// back in its lane holding the rank it left. A foreign restore empties the trash path and the
// undo skips; a foreign re-delete empties the lane path and the redo skips.
let steps = zip(moves, ranks).map { move, rank in
(
trashed: ItemPath.trashCard(move.id).folder(under: root),
origin: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root),
laneFolder: ItemPath.lane(move.laneID).folder(under: root),
priorOrder: move.order,
trashRank: rank
)
}
registerStep(
HistoryPhrase.name(.delete, kind: .card, count: steps.count),
subject: moves.count == 1 ? moves[0].title : nil,
undoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) },
redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) }
) { _ in
for step in steps {
_ = try BoardWriter.moveItem(
at: step.trashed,
toParent: step.laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.priorOrder
)
}
} redo: { _ in
for step in steps {
try BoardWriter.deleteCardToTrash(at: step.origin, inBoard: root, order: step.trashRank)
}
}
return true
}
/// **Deleting a lane is physical** — the folder and its contents go (03-board-ui.md § Trash:
/// "Cards only. Lanes are never trashed … The net is undo, not the trash").
///
/// **Capture before you remove.** The undo replays the lane's bytes, which only works if the step
/// is holding them: `captureSubtree` reads the whole tree — nested cards, their `attachments/`,
/// every stray, symlinks as links, POSIX modes — inside the same bracket as the removal, so
/// nothing can change between the two. The capture is the reason this is a *destructive* delete
/// with a real inverse rather than an unrecoverable one.
///
/// **In-session only, and that is the accepted net** (13-native-undo.md ▸ Rules ▸ session-only
/// persistence): the bytes live on the stack, so closing the board loses them. Git boards keep
/// the lane reachable forever (06-history-undo.md's delete-never-forgets) — a Pro difference,
/// stated honestly.
///
/// A capture that fails takes the whole bracket down and nothing is removed: better a delete that
/// visibly did not happen than one whose undo could not.
@discardableResult
private func removeLanes(_ ids: [ItemID]) -> Bool {
let lanes = ids.compactMap { id in snapshot.lanes.first { $0.id == id } }
guard !lanes.isEmpty else { return false }
let root = rootURL
let folders = lanes.map { ItemPath.lane($0.id).folder(under: root) }
var captures: [SubtreeSnapshot] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
captures.append(try BoardWriter.captureSubtree(at: folder, operation: .delete(title: nil)))
try BoardWriter.removeLane(at: folder)
}
}
guard landed != nil, captures.count == folders.count else { return false }
// lane delete → **recreate the folder from the registered inverse** (13 ▸ Rules). Values, not
// references: the capture is a `SubtreeSnapshot` of bytes taken before the removal, so the
// step means the same thing any number of reloads later.
//
// Its predicate is existence and nothing else — the undo wants the paths still empty (a
// recreate refuses to clobber, so a lane somebody re-made at that id is not this step's to
// overwrite), the redo wants them back.
let steps = Array(zip(folders, captures))
registerStep(
HistoryPhrase.name(.delete, kind: .lane, count: steps.count),
subject: lanes.count == 1 ? lanes[0].title.value : nil,
undoExpects: steps.map { .absent($0.0) },
redoExpects: steps.map { .present($0.0) }
) { _ in
for (folder, capture) in steps {
try BoardWriter.recreateSubtree(at: folder, from: capture, operation: .createLane)
}
} redo: { _ in
// Reversed, so a multi-lane delete unwinds in the mirror of the order it was made in.
for (folder, _) in steps.reversed() {
try BoardWriter.removeLane(at: folder)
}
}
return true
}
/// **The trash's own Delete — permanent** (03-board-ui.md § Trash: "on a trash card, Delete
/// (⌫/⌘⌫) is permanent; in the trash it removes the folder").
///
/// Its own method rather than a flag on `delete(_:)` because it is a different act with a
/// different safety story: it registers **no undo step**, and `purgeIsUnrecoverable` stays `true`
/// — 13-native-undo.md ▸ Rules settles this by name ("Permanently delete (Delete Immediately,
/// Empty Trash) … the confirm *is* the safety"). A stack entry here would be a promise the
/// filesystem cannot keep.
///
/// **The confirmation is the window's** (`TrashConfirmations`), for `deleteSelection`'s reason —
/// and it is why this seam is explicit: the alert has to be able to name what this will purge
/// before it runs.
///
/// The selection moves to the successor sibling **within the trash**: the permanent delete is as
/// deliberate a gesture as the move-to-trash, so repeated ⌫ walks down the column exactly as it
/// walks down a lane (04-interactions.md ▸ The map).
public func deleteTrashCards(_ ids: Set<ItemID>) {
let paths = ItemPath.resolve(ids, in: .trash, snapshot: snapshot)
guard !paths.isEmpty else { return }
let successor = SelectionGrammar.successor(
afterDeleting: ids,
in: .trash,
snapshot: snapshot,
filter: searchFilter
)
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
for path in paths {
try BoardWriter.purgeTrashCard(at: path.folder(under: root), inBoard: root)
}
}
if let successor {
select([successor], in: .trash, anchor: successor, head: successor)
} else {
clearSelection()
}
}
/// **Delete Immediately ⌥⌘⌫ — skips the trash from anywhere** (03-board-ui.md § Trash;
/// 11-command-nexus.md: "Board window, card selection — skips the trash from anywhere").
///
/// The one method whose targets can be in either container, and the reason is the command's own
/// wording: from a lane it bypasses the trash the ordinary delete would have used, and from the
/// trash it is the permanent delete the card is already one keystroke from. Cards only — a lane's
/// delete is physical already, so there is nothing for "skip the trash" to mean on one.
///
/// **It registers no undo step**, `deleteTrashCards`' ruling and its wording.
///
/// The selection is cleared rather than walked to a successor: unlike ⌫, this is the command a
/// confirmation stands in front of, and what follows it is reading the board rather than pressing
/// the key again.
public func deleteImmediately(_ ids: Set<ItemID>) {
let container = selection.container
let paths = ItemPath.resolve(ids, in: container, snapshot: snapshot).filter { !$0.isLane }
guard !paths.isEmpty else { return }
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
for path in paths {
switch path {
case .trashCard:
try BoardWriter.purgeTrashCard(at: path.folder(under: root), inBoard: root)
default:
try BoardWriter.purgeItem(at: path.folder(under: root))
}
}
}
clearSelection()
}
/// **Empty Trash… ⇧⌘⌫** — purges every card in `<root>/.trash/`, in one bracket.
///
/// Not undoable, `deleteTrashCards`' ruling — this is the other half of 13's "Permanently delete".
///
/// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the writer walks
/// the folder itself, never a filtered view — "a bulk command about the trash itself never
/// silently narrows to the visible subset". The filter does not reach this method at all, which
/// is the strongest form of that guarantee, and strays a hand-editor left in the container are
/// preserved verbatim rather than swept up with the cards (`BoardWriter.emptyTrash`).
public func emptyTrash() {
guard !snapshot.trash.isEmpty else { return }
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.emptyTrash(inBoard: root)
}
if selection.container == .trash {
clearSelection()
}
}
// MARK: - The legacy tombstone migration
/// Migrates every legacy `deleted:` key the last applied snapshot found, and posts one notice —
/// the **act** half of 01-storage-format.md § Deletion's migration rule ("Legacy `deleted:` keys
/// migrate on load-and-write, never destroy"; the loader's `legacyTombstones` is the notice half).
///
/// **`relocateLooseCardFiles()`'s twin in every mechanical respect**, deliberately: same tail hook
/// on a successful reload, same lock deferral, same attempted-set loop guard, same one bracket
/// for the whole board, same warning-tone loss row. Two migrations arriving in one release with
/// two different schedulings would be two things to keep honest.
///
/// ### The two acts
///
/// - **A card relocates into `.trash/`** with the key removed (`BoardWriter.migrateTombstonedCard`).
/// - **A lane returns live** with the key stripped and nothing moved
/// (`BoardWriter.migrateTombstonedLane`) — "resurrection is the safe direction, nothing is
/// destroyed by migration". A tombstoned lane's own cards come back with it; any of them
/// carrying their own key migrate on their own account, as ordinary tombstoned cards, in the
/// same pass.
///
/// ### The order among migrating cards is `deleted:`-ascending, deliberately
///
/// Every arrival mints a rank above the current top, so the *last* card migrated ends up topmost.
/// Migrating oldest-first therefore reproduces the newest-first column the tombstone model's
/// timestamp sort used to render — the same board, read the same way, with ordinary ranks doing
/// the work. A card whose stamp is missing or unparseable sorts as **oldest** (the retired sort's
/// own rule: "a corrupt stamp must not outrank fresh deletions"), and ties fall to the loader's
/// walk order — lane `order`, then card `order` — which is the deterministic tie-break the whole
/// corpus already uses. `legacyTombstones` carries no timestamp of its own, so the stamps are
/// read out of the snapshot the same walk produced.
///
/// ### The read-only lock defers it, it does not cancel it
///
/// Exactly the relocation's posture, and stated there: a locked board returns having written
/// nothing **and having remembered nothing**, so the reload that lifts the lock is the reload
/// that performs the migration.
///
/// ### It cannot hot-loop
///
/// The migration's own write triggers a reload, which re-walks the tree — the loop the guard
/// exists for. After a success the walk finds no keys, `legacyTombstones` empties, and the memo
/// clears. After a *failure* it finds the same keys again, and an unguarded call would fail
/// forever at the speed of a directory walk; so an attempt is made only when the tombstone set
/// **differs from the last one attempted**.
public func migrateLegacyTombstones() {
let work = legacyTombstones
guard !work.isEmpty else {
attemptedTombstoneMigration = []
return
}
guard readOnlyLock == nil else {
Self.logger.debug("legacy tombstone migration deferred — the board is read-only")
return
}
let signature = Self.migrationSignature(of: work)
guard signature != attemptedTombstoneMigration else { return }
attemptedTombstoneMigration = signature
let root = rootURL
let cards = Self.migrationOrder(of: work, in: snapshot)
let lanes = work.filter { $0.kind == .lane }
var movedCards: [String?] = []
var returnedLanes: [String?] = []
// The ranks are minted exactly as a delete's are — head of the trash, threaded forward — so a
// migrated card is indistinguishable on disk from one the user deletes today.
var ladder = snapshot.trash.map(\.order)
try? performWrite { () throws(BoardWriteError) -> Void in
for card in cards {
guard let cardID = card.cardID else { continue }
let rank = Ranks.insertAtHead(ofVisible: ladder)
try BoardWriter.migrateTombstonedCard(
at: ItemPath.card(lane: card.laneID, id: cardID).folder(under: root),
inBoard: root,
order: rank
)
ladder.insert(rank, at: 0)
movedCards.append(card.title)
}
for lane in lanes {
try BoardWriter.migrateTombstonedLane(at: ItemPath.lane(lane.laneID).folder(under: root))
returnedLanes.append(lane.title)
}
}
banners.postMigratedTombstones(cards: movedCards, lanes: returnedLanes)
}
/// The tombstone picture as a comparable value — `relocationSignature`'s shape, for its reason:
/// the *identity* of the work is what matters, not the order the walk happened to meet it in.
nonisolated static func migrationSignature(of work: [LegacyTombstone]) -> Set<String> {
Set(work.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "")" })
}
/// The `.card` tombstones in the order they should be filed into the trash — oldest `deleted:`
/// first, so the newest ends up on top (see `migrateLegacyTombstones`).
///
/// `sorted(by:)` is not stable in the standard library, so the walk position is folded into the
/// key rather than relied on: an unparseable or missing stamp takes `Date.distantPast` and ties
/// break on the index the loader met the card at.
nonisolated static func migrationOrder(
of work: [LegacyTombstone],
in snapshot: BoardModel
) -> [LegacyTombstone] {
var stamps: [ItemID: Date] = [:]
for lane in snapshot.lanes {
for card in lane.cards {
if let deleted = card.deleted.value { stamps[card.id] = deleted }
}
}
return work
.enumerated()
.filter { $0.element.kind == .card }
.sorted { lhs, rhs in
let left = lhs.element.cardID.flatMap { stamps[$0] } ?? .distantPast
let right = rhs.element.cardID.flatMap { stamps[$0] } ?? .distantPast
return left == right ? lhs.offset < rhs.offset : left < right
}
.map(\.element)
}
// MARK: - The agent guide
/// Brings the board root's `CLAUDE.md` up to the current guide version, or leaves it exactly as
/// it is — the whole of 08-agent-integration.md ▸ The agent guide's scheduling. The rule itself
/// is `AgentGuide.decide(_:)`, a pure function; this method is the I/O and the policy around it.
///
/// **Run on every successful reload**, beside the relocation and the tombstone migration, and
/// once more at open (`BoardStoreRegistry.acquire`, which fires it after the watcher and the
/// brackets exist, for the reason stated there). That makes the guide *self-healing* rather than
/// merely written-once: a foreign deletion, a downgrade to an older guide, a board restored from
/// a template carrying a stale one — each heals on the next reload, without a single new signal.
/// It also pre-wires the Pro-era bounce 06-history-undo.md acknowledges by name, where undoing
/// an "Update agent guide (vN)" commit restores an older guide that the app immediately
/// re-upgrades.
///
/// **The steady state is a read and a comparison** — one `lstat`, one small file read, one
/// first-line parse — and no write at all. Nothing here touches the snapshot: the bytes land, the
/// watcher notices, the reload applies, exactly like every other app write.
///
/// ### The read-only board is skipped, never banner-ed
///
/// Two gates, because two different things can be true. `performWrite` would refuse under the
/// read-only lock on its own, but that refusal is a thrown error and this is not a gesture — so
/// the lock is checked first, the relocation's own deferral idiom. The writability probe beside
/// it covers the case the lock does not: 02-architecture.md's open-time unwritable-root lock is a
/// separate card, and until it lands a board on a read-only volume would reach the Writer, fail,
/// and post a banner about a file the user never asked for. 02 settles that exact case the other
/// way — "the open-time agent-guide write is skipped-with-log, the `CLAUDE.user.md`-taken
/// precedent" — so it is skipped with a log.
///
/// ### It cannot hot-loop
///
/// `performWrite`'s bracket schedules a reload whether or not the write succeeded, and this runs
/// on every reload — so a *failing* guide write would retry forever at the speed of the debounce,
/// posting a banner row each time. The guard is the relocation's exactly: an attempt is made only
/// when the board root's picture **differs from the one last acted on**. One failure, one row,
/// then silence until something on disk actually changes. The same memo is what keeps the two
/// skip cases from repeating their log line on every reload of an unchanged board.
public func refreshAgentGuide() {
let state = AgentGuide.inspect(atBoardRoot: rootURL)
let decision = AgentGuide.decide(state)
guard decision != .leaveAlone else {
// The resting state, and the memo's reset: a board whose guide is current has nothing to
// remember having tried.
attemptedGuideRefresh = nil
return
}
// Deferred, not abandoned — and deliberately *before* the memo is written, so the refresh a
// lock refused is not the one the guard below remembers.
guard readOnlyLock == nil else {
Self.logger.debug("agent-guide refresh deferred — the board is read-only")
return
}
guard FileManager.default.isWritableFile(atPath: rootURL.path) else {
Self.logger.debug("agent-guide refresh skipped — the board's location is not writable")
return
}
guard state != attemptedGuideRefresh else { return }
// Armed before anything is attempted, so a write that throws leaves it set — the memo's
// whole job is to remember pictures this store has already failed or refused to act on.
attemptedGuideRefresh = state
switch decision {
case .leaveAlone:
break // Ruled out above; the switch stays exhaustive so a new decision is a compile error.
case .skipUntouchable:
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not an ordinary file")
case .skipUserFilenameTaken:
// The ruling's own outcome (08 ▸ Ownership): a user-authored CLAUDE.md that cannot be
// rescued keeps its name, and the guide simply does not exist on this board.
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not the app's and CLAUDE.user.md is taken")
case .write, .displaceThenWrite:
let root = rootURL
let displace = decision == .displaceThenWrite
do {
// One bracket over the rescue move *and* the write: two files change, one
// app-mediated reload lands, and (in the Pro edition) one honestly-attributed commit
// records it.
try performWrite { () throws(BoardWriteError) -> Void in
try AgentGuide.install(atBoardRoot: root, displacingUserContent: displace)
}
// **Cleared on success, and this is load-bearing rather than tidy**: the memo keys on
// the picture that provoked the write, and a foreign deletion restores that exact
// picture ("missing"). A memo left standing would make the deletion the one thing the
// self-heal could not heal.
attemptedGuideRefresh = nil
} catch {
// Already the banner's — `performWrite` posts every `BoardWriteError` before it
// rethrows — and there is nothing else a courtesy write can do about a failure. The
// memo, left armed above, is what keeps it from being posted again every reload.
Self.logger.error("agent-guide write failed: \(error.localizedDescription, privacy: .public)")
}
}
}
// MARK: - Selection (delegated)
// The 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 editors 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.
//
// `isEditingInline` earns one for the selection's reason and no other: **every** board-mutating
// menu item validates against it (04-interactions.md's focused-editor rule), and a rule read
// that often should read as one word.
/// The board's selection, re-resolved against every snapshot this store applies —
/// `TransientBoardState.selection` under a shorter name.
public var selection: ItemReferenceSet { transient.selection }
/// Whether an inline title editor is open — `TransientBoardState.isEditingInline`, which owns
/// what it means and why every mutating command reads it.
///
/// **The search field is not one of these**, and that is 04-interactions.md § Search's settled
/// dispatch rule as one absence: "the field is a *control*, not a content editor — the
/// focused-editor lockdown does not apply", so board menu commands stay enabled and act on the
/// selection while the user types a query. The narrow exception — the caret chords — is the
/// menu items' own (`caretChordsYield`), not this flag's.
public var isEditingInline: Bool { transient.isEditingInline }
// MARK: - The live search filter
/// The search field's text (04-interactions.md § Search), and **the one funnel every change to
/// it goes through**.
///
/// The setter is where the filter's one consequence lives: narrowing the query narrows what the
/// board shows, and "hidden cards leave the selection" — so every write re-applies
/// `TransientBoardState.constrainToSearch(in:)` against the current snapshot. Putting it here
/// rather than at the field's binding is what makes it true for Escape's clear and for any later
/// caller equally, without either having to remember.
///
/// **The equality guard is not an optimisation.** `NSSearchField` reports its text on events
/// that did not change it, and a re-entrant assignment during a live keystroke would re-run the
/// constraint (harmlessly) and re-fire observation (not harmlessly — the strip's animated
/// transaction is keyed on this value).
public var searchQuery: String {
get { transient.searchQuery }
set {
guard newValue != transient.searchQuery else { return }
transient.searchQuery = newValue
transient.constrainToSearch(in: snapshot)
}
}
/// The query as the predicate, for the selection grammar's order lists — read wherever the board
/// asks "what is on the board, in what order" (`SelectionGrammar.order`).
public var searchFilter: SearchFilter { SearchFilter(query: transient.searchQuery) }
/// Clears the search — **Escape's middle step** (04 § Search's staged Escape: "with *board*
/// focus and an active search, one press clears the search and the full board returns"), and the
/// search field's own Escape in a non-empty field.
///
/// Widening, so it constrains nothing; it goes through the setter anyway so there is exactly one
/// place the query is written on the store.
public func clearSearch() {
searchQuery = ""
}
/// Replaces the selection, and **records the lane it lands in** as the last-active one.
///
/// The lane bookkeeping lives here rather than in `TransientBoardState` for one reason: it
/// takes a snapshot to answer "which lane is that". 04-interactions.md's ⌘N target rule calls
/// for "the lane that most recently held selection or a creation", and a *card* selection is
/// its lane holding selection just as much as the lane's own header click is — so both are
/// noted here, and creation notes itself in `beginPlaceholder`.
public func select(_ ids: Set<ItemID>, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil) {
transient.select(ids, in: container, anchor: anchor, head: head)
transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
}
/// **Every pointer click on a selectable surface goes through here** — card face, lane header,
/// lane empty space, trash row — so 04-interactions.md § Selection's grammar is stated once
/// (`SelectionGrammar`) rather than four times with three of them subtly different.
///
/// The store's whole contribution is supplying the three inputs the grammar cannot see (the
/// snapshot, the selection, the anchor) and storing the outcome. An emptied outcome clears
/// rather than storing an empty set on a side, because that is what "nothing selected" is
/// everywhere else in the app.
///
/// - Parameter togglesOnRepeat: the lane's click-again-to-unselect — see `SelectionGrammar`.
public func click(_ target: SelectionTarget, modifier: ClickModifier, togglesOnRepeat: Bool = false) {
let outcome = SelectionGrammar.click(
target,
modifier: modifier,
selection: selection,
anchor: transient.selectionAnchor,
snapshot: snapshot,
togglesOnRepeat: togglesOnRepeat,
// A ⇧-range walks the *filtered* board (04 § Search); the other two branches ignore it.
filter: searchFilter
)
guard !outcome.selection.isEmpty else {
clearSelection()
return
}
// Both cursors are passed through explicitly: `select`'s default would otherwise re-anchor a
// ⇧-range's single-member edge case on the target, and the grammar's answer is the one that
// knows whether this click was an origin or an extension. The head is the clicked item in
// every branch — see `SelectionGrammar.Outcome`.
select(
outcome.selection.ids,
in: outcome.selection.container,
anchor: outcome.anchor,
head: outcome.head
)
}
/// **Select All** — "all visible cards on the board" (04-interactions.md ▸ The map), with the
/// trash's own reading of the same command when the trash side is the one in play.
///
/// Two branches, and the trash's is the narrow one: it fires only when the column is **shown**,
/// the selection is in the trash, and it still names a card — the exact conditions under which
/// "all" could mean anything but the board (04 ▸ The map, resettled 2026-07-28: "with the trash
/// visible and a non-empty trash selection, Select All selects all visible trash cards; in every
/// other state, all visible live cards — the container boundary decides which 'all' is meant").
/// A trash selection naming nothing (a foreign restore, a purge) falls through to the board
/// rather than selecting the trash wholesale on a guess. There is no kind clause any more:
/// lanes are never trashed, so every trash row is a card.
///
/// The anchor — and the navigation head with it — **survives if it is still in the set** and is
/// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor,
/// but it has no business discarding ones that are still standing inside what it selected.
///
/// **"All visible cards" means the filter's survivors** — "filter-respecting, like every
/// surface" (04 ▸ The map). The universe is `SelectionGrammar`'s order lists, which is where the
/// filter threads in, so this command and every ⇧-range narrow together by construction.
public func selectAll() {
let filter = searchFilter
if transient.isTrashVisible, selection.container == .trash, !selection.isEmpty,
SelectionGrammar.kind(of: selection, in: snapshot) != nil {
apply(Set(SelectionGrammar.trashCards(in: snapshot, filter: filter)), in: .trash)
return
}
apply(Set(SelectionGrammar.boardCards(in: snapshot, filter: filter)), in: .board)
}
/// Select All's storage half: an empty universe clears rather than storing an empty set, and the
/// anchor and head are kept only while they are still inside what was selected.
private func apply(_ ids: Set<ItemID>, in container: ItemContainer) {
guard !ids.isEmpty else {
clearSelection()
return
}
let anchor = transient.selectionAnchor.flatMap { ids.contains($0) ? $0 : nil }
let head = transient.selectionHead.flatMap { ids.contains($0) ? $0 : nil }
select(ids, in: container, anchor: anchor, head: head)
}
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar).
///
/// The last-active lane deliberately survives: it is a high-water mark of where the user has
/// been working, and ⌘N after a deselect is exactly the case it exists to answer.
public func clearSelection() {
transient.clearSelection()
}
/// The lane a selection sits in, or `nil` when it names no single one — a lane selects itself;
/// cards select their lane, but only when they all share one (a cross-lane selection has no
/// single home to remember). A trash selection names no lane at all, which is exactly 04's "a
/// trash selection never anchors creation".
nonisolated static func lane(holding ids: Set<ItemID>, in snapshot: BoardModel) -> ItemID? {
guard !ids.isEmpty else { return nil }
var found: ItemID?
for lane in snapshot.lanes {
let names = ids.contains(lane.id) || lane.cards.contains { ids.contains($0.id) }
guard names else { continue }
guard found == nil else { return nil }
found = lane.id
}
return found
}
// MARK: - Quiescence
/// Suspends until no reload is running and none is owed.
///
/// The store is not a request/response object — a signal in does not produce a result out — so
/// this is how a consumer (and every test below) says "let the pipeline settle" without polling.
/// Returns immediately when the store is already quiet.
public func awaitQuiescence() async {
guard !isQuiescent else { return }
await withCheckedContinuation { continuation in
quiescenceWaiters.append(continuation)
}
}
private var isQuiescent: Bool { !reloadInFlight && pendingReload == nil }
private func resumeQuiescenceWaitersIfQuiet() {
guard isQuiescent, !quiescenceWaiters.isEmpty else { return }
let waiters = quiescenceWaiters
quiescenceWaiters.removeAll()
for waiter in waiters {
waiter.resume()
}
}
}