Files
lanework/Kanban/LiveStore/BoardStore.swift
T
rzen d076427ee0 Realign undo with the evening rulings — repo-nested and identity anchors
Repo-nested boards bind native undo in every tier (25d2513): the
no-undo case is gone, makeHistoryProvider answers git or native, and
the native path provably never touches the enclosing repository's
.git. Session undo steps anchor by card identity, never by path
(9119aa1): HistoryAnchor carries the card UUID (plus comment/draft
vocabulary) and apply-time validation resolves the current folder via
the same both-container walk writeCardBody uses — a board-side lane or
trash move no longer stales the coarse close step, while a genuine
field collision still skips it whole.

2448 tests in 423 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 21:19:42 -04:00

4331 lines
244 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 is 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.
///
/// **It carries which** (settled): "the probe distinguishes read-only volume from
/// permission-denied folder and the lock reason carries it … the fixes being different acts".
/// The payload is the *only* thing the two spellings of this lock differ in — same scope, same
/// clearing rule — so it is an associated value rather than two cases, and `BannerCenter` turns
/// it into the one line the user reads.
///
/// **Raised and cleared by the probe, not by the reload's success** — 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 changed, in *either*
/// direction: it clears a lock whose cause is gone and raises one whose cause has appeared
/// mid-session (§ "the probe is symmetric").
case unwritableLocation(UnwritableCause)
}
/// 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: HealHost {
// 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 pending work the load that produced `snapshot` found** — the typed defect stream
/// (`IntegrityRules.Defect`, settled 2026-07-29). Replaced with the snapshot, like
/// `loadWarnings`, so it always describes the tree currently on screen.
///
/// **Nothing renders it.** A defect is not content — it reaches no view, and the board draws
/// exactly as it would without it. Its one consumer is `runScheduledHeals()`, immediately below
/// the reload that produced it.
public private(set) var defects: [IntegrityRules.Defect]
/// The cards the last load found holding loose files — a view over `defects`, under the name it
/// has always had.
public var looseCardFiles: [LooseCardFiles] {
defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } }
}
/// The legacy `deleted:` keys the last load found — a view over `defects`.
public var legacyTombstones: [LegacyTombstone] {
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
}
/// The claimed board-root names the last load found squatted — a view over `defects`.
public var claimedNameSquatters: [ClaimedNameSquatter] {
defects.compactMap { if case let .claimedNameSquatted(work) = $0 { work } else { nil } }
}
/// The duplicate ids the last load **withheld** from `snapshot` — a view over `defects`.
///
/// The one defect whose subject is deliberately absent from the snapshot: the folders are on disk,
/// their content intact, and they are kept out of every snapshot so the one-item-per-id invariant
/// holds by construction (01-storage-format.md § Fractal layout ▸ Rules). `remintDuplicateIdentities()`
/// is what puts them back, under fresh ids.
public var duplicateIdentities: [DuplicateIdentity] {
defects.compactMap { if case let .duplicateIdentity(work) = $0 { work } else { nil } }
}
/// 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()
/// **This board's write-provenance ledger** (02-architecture.md ▸ Components ▸ EchoLedger):
/// what the app wrote, so a landing reload can tell its own echo from someone else's edit.
///
/// **In-memory, per-store, dies with the session** — a `let` beside `transient` and `banners`,
/// for the same reason all three are: closing the board is the reset, and "losing it costs
/// attribution and nothing else". Owned rather than injected because there is exactly one
/// answer to "which ledger is this board's", and a second one would be a second provenance.
///
/// Its consumers today are the announcer's digest and the vanishing-focus sentence, both
/// through `land`. Pro's auto-committer (06-history-undo.md) becomes the second one without
/// this line changing — which is why the type lives in `LiveStore/` beside `BoardDiff` rather
/// than inside the git provider.
@ObservationIgnored
public let echoes = EchoLedger()
/// **Board search's transient comment index** (04-interactions.md ▸ Search, re-ruled 2026-07-29)
/// — the sweep that lets a query reach comment bodies without the snapshot ever carrying one.
///
/// A `let` beside `echoes` and `heals`, and *not* `@ObservationIgnored`: `searchFilter` reads
/// `matchingCards`, so every surface that filters through the store re-renders when a sweep lands.
/// That refinement arriving a moment after the keystroke is the design's own accepted behaviour —
/// see `CommentSearchIndex` for the freshness rule and its interim.
public let commentIndex = CommentSearchIndex()
/// 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.
///
/// **It is the board's stack, and not every step's destination** (13 ▸ Rules ▸ two levels,
/// re-ruled 2026-07-31): a gesture issued in a card window registers on *that window's* stack
/// instead, which the write methods below take as a parameter (`CardWindowUndo`). This one carries
/// board-surface gestures and the coarse step a window's close folds its session into.
///
/// **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)?
/// **Where Pro's auto-committer meets the write and reload paths** (06-history-undo.md ▸ Rules
/// ▸ Auto-commit), or `nil` on every board there is no committer for — which is every free-tier
/// board and every Pro board without a repository at its root.
///
/// Injected like `watcherBrackets` and `history`, and for their reason: the committer belongs to
/// the *session* (`HistoryStore.committer`), and a store that reached for one would be a second
/// answer to which committer a board has. `nil` keeps every method below behaving exactly as it
/// did before this milestone — which is what makes the free tier's inert posture structural
/// rather than conditional.
@ObservationIgnored
public var commitSeam: HistoryCommitSeam?
// 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 free-tier bracket today.** The free tier 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 scheduled-heal engine** (02-architecture.md ▸ Components ▸ HealScheduler): the six-step
/// pattern the three healers below used to re-derive one by one, plus the memo each of them
/// used to keep on its own.
///
/// A `let` beside `transient`, `banners` and `echoes`, and for their reason: the memos are
/// per-open state, and closing the board is the reset.
@ObservationIgnored
let heals = HealScheduler()
/// 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) }
/// **Where git path history reaches the loader** (01-storage-format.md ▸ Fractal layout
/// ▸ Rules, the duplicate-id winner rule; `BoardLoader.IdentityHistoryRanker`) — `nil` on every
/// board the app manages no git for, which is every free-tier board and every Pro board without
/// a repo at its root.
///
/// A **provider** rather than a ranker, for two reasons that point the same way. Each load wants
/// its own ranker, so that a load never answers from a history that has moved since the last one
/// (the ranker caches internally, once, per load). And add-git flips a board into git mode
/// mid-session, which a closure asked at load time absorbs by construction while a value handed
/// over at composition never could.
///
/// `@MainActor` because it is called here, on the main actor, at the head of each reload; what
/// it returns is `Sendable` and does its git work off-main, inside the walk that consults it.
///
/// **The board's first load predates this** — `init` runs inside `BoardStoreRegistry.acquire`,
/// before a session exists to compose the git state that supplies it — so an opening board's
/// duplicate-id ladder falls through to birth date, and every reload after it consults history.
/// Deliberate, and the narrow cost of composing the git state where the design puts it
/// (`AppModel.beginSession`) rather than where the first walk happens to run.
@ObservationIgnored
var makeIdentityHistoryRanker: (@MainActor () -> BoardLoader.IdentityHistoryRanker?)?
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 defects included.** `defects` is recorded here and
/// acted on by whoever wired this store up — `BoardStoreRegistry.acquire` calls
/// `runScheduledHeals()` once the watcher and the brackets exist, so a heal 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) heals 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.defects = result.defects
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
// Asked once per load, on the main actor, and answered off it: what comes back is a lazy
// `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to
// break a tie for. `nil` everywhere the app manages no git.
let historyRanker = makeIdentityHistoryRanker?()
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, historyRanker: historyRanker))
} 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()
facts.endsBracketedOperation = endsWholesaleOperation
facts.completion = completion
facts.lockBefore = readOnlyLock
facts.breakageBefore = reloadFailure
// **Whether this reload revealed anything the app does not vouch for** — the one bit the
// auto-committer's flush-before-overwrite gate turns on (06 ▸ Rules ▸ Flush-before-overwrite).
// A failed reload counts as foreign, conservatively: a file the loader could not read is one
// the app certainly did not write, and the safe direction is to let the next app write commit
// what is there before overwriting it.
var sawForeignChange = false
switch outcome {
case let .success(result):
// **What changed, who changed it, and what it cost the cursor** — all three computed
// against the *outgoing* snapshot, so they have to be taken before the assignment below
// replaces it. All three are functions of two value types; nothing here reads disk and
// nothing here decides whether anyone is told.
//
// **Asked on every origin, reconciling included** (10-accessibility.md ▸ Live board
// announcements, ruled 2026-07-29): what buys silence is the *ledger*, not the reload's
// label. An app-mediated echo is silent because its files carry receipts that still
// match; a reconciling sweep over a blind window is not, because the files it reveals
// carry none — "the app never vouches for changes it didn't witness". The one exemption
// is the bracket, which 10 gives a single sentence at completion rather than a
// description of its churn, and which 02 keeps out of the ledger entirely.
let focus: BoardAnnouncer.FocusOutcome
if !endsWholesaleOperation {
// **The digest covers the trash only while the trash lane is shown**
// (10-accessibility.md ▸ Live board announcements, ruled 2026-07-29). This is the
// seam that reading takes: visibility is view state on the board's own transient
// container — one per store, shared by every window onto this board
// (`TransientBoardState.isTrashVisible`) — so the store asks it here and both the
// summarizer and the classifier stay pure functions of two snapshots plus one fact.
let shownTrash = transient.isTrashVisible
let diff = BoardDiff.between(snapshot, result.model, includingTrash: shownTrash)
let verdicts = echoes.verdicts(
from: snapshot,
to: result.model,
diff: diff,
includingTrash: shownTrash
)
facts.diff = verdicts.foreign
sawForeignChange = verdicts.foreign.boardChanged || !verdicts.foreignItems.isEmpty
// The vanishing-focus sentence takes the same gate, one rung up the ladder: it says
// "deleted *externally*", which 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. So a vanishing the ledger vouches for is no
// vanishing at all as far as speech and focus are concerned.
let outcome = BoardAnnouncer.focusOutcome(
old: snapshot,
new: result.model,
selection: transient.selection,
focused: focusedItem
)
focus = Self.vanishingIsForeign(
focused: focusedItem,
old: snapshot,
new: result.model,
foreignItems: verdicts.foreignItems
) ? outcome : .survived
} 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.
//
// The comment index' current answer rides along, because the filter half of the
// re-grounding is the *whole* filter (04 ▸ Search, re-ruled 2026-07-29) — a card
// matching only through its comments must not be evicted from the selection by a
// reload that asked a narrower question. The index is re-swept just below, outside
// the transaction, and its landing re-runs this constraint through `onRefine`.
transient.resolve(against: result.model, commentMatches: commentIndex.matchingCards)
// 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
defects = result.defects
// **The comment index' freshness signal, and its stated interim** (04-interactions.md ▸
// Search: "kept fresh by the same FSEvents stream while a query is active"). The store has
// no changed-path channel — the watcher reports only *that* the tree changed
// (02-architecture.md) — so what a landed reload can offer an index of window-scoped
// content is its generation, and a query still active re-sweeps on it. Coarser than the
// ruling asks for and bounded by the same debounce; a no-op with no query running, which
// is the overwhelmingly common reload.
refreshCommentIndex()
reconcileLock(after: 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?()
// **The reload tail** — one of the two seams the heal engine runs at (the other is
// `BoardStoreRegistry.acquire`), and after `reconcileLock` deliberately: this is where
// the deferred app-initiated writes are armed. A board that was locked read-only
// tolerated its defects for exactly as long as the lock stood, and the reload that
// clears the lock is the reload that heals them. The ordering cuts the other way too:
// a reconciling reload that *raises* the unwritable-location lock raises it before this
// runs, so no heal writes into a location the same reload just learned is read-only.
runScheduledHeals()
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
}
sawForeignChange = true
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))
// **The auto-commit debounce, armed by every landing** (06 ▸ Rules ▸ Auto-commit; ▸
// Interaction with external writers: "Agent and hand edits arrive through the watcher like
// any change and get auto-committed on the same debounce").
//
// Here rather than at the watcher, deliberately: a reload landing means the tree walk is
// over, so the committer never races the loader for the same files. **Unconditional on what
// changed**, equally deliberately — a reload lands whether or not the snapshot moved, and the
// committer's condition is the *tree*, not the snapshot diff, so a window that touched only
// strays or only `CLAUDE.md` still commits (06 ▸ Commit messages ▸ Non-snapshot files commit
// too). A landing that finds nothing to commit is the silent no-op, not a wasted trip.
commitSeam?.reloadDidLand(sawForeignChange)
}
/// 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()
}
}
/// **Whether the hole under the cursor was somebody else's doing** — the gate that used to be
/// `origin == .foreign`, now asked of the ledger per file (10-accessibility.md, ruled
/// 2026-07-29).
///
/// It mirrors `BoardAnnouncer.focusOutcome`'s own choice of subject rather than second-guessing
/// it, because the two must agree about *what vanished*: when the lane went, the lane is the
/// subject and the lane's classification is the one that decides — a card swept away with an
/// app-mediated lane delete carries no receipt of its own (the removal took the whole subtree's
/// receipts with it), and letting the child's verdict speak would announce the user's own
/// gesture back at them.
///
/// `false` for a focus that was never on the board's old side: there is nothing to classify, and
/// `focusOutcome` already answers `.survived` there.
nonisolated static func vanishingIsForeign(
focused: ItemID?,
old: BoardModel,
new: BoardModel,
foreignItems: Set<ItemID>
) -> Bool {
guard let focused else { return false }
if old.lanes.contains(where: { $0.id == focused }) {
return foreignItems.contains(focused)
}
guard let home = old.lanes.first(where: { lane in lane.cards.contains { $0.id == focused } })
else { return false }
if new.lanes.contains(where: { $0.id == home.id }) {
return foreignItems.contains(focused)
}
return foreignItems.contains(home.id)
}
/// **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 reconciliation rules
/// Brings the read-only lock into line with what this successful reload — and, on a reconciling
/// one, a fresh writability probe — actually proves.
///
/// **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. Only the probe can speak to it, and the probe runs on
/// **reconciling** reloads — wake, activation, a stream re-creation — because those are the
/// reloads that admit a blind window ("Writability re-probes on every reconciling reload").
///
/// ### The probe is symmetric (settled)
///
/// It clears *and* raises. "A rewritable remount or fixed permission clears the lock without
/// ceremony, and a volume gone read-only mid-session *raises* it at the next probe — banner up
/// front, not every gesture failing one at a time (the lock's own founding rationale)." Between
/// probes a write that hits the newly read-only volume fails as an ordinary one-shot; this is
/// the line that converts that condition into the standing lock.
///
/// A raise here is deliberately **not** routed through `enterUnwritableLock(_:)`: that method
/// speaks its own sentence, and this runs inside `land`, which posts exactly one announcement
/// per reload from the before/after pictures it already holds. Two voices for one lock is the
/// bug `announceLockChange` exists to avoid.
///
/// The sibling locks are settled *before* the probe, so a reconciling reload that clears a
/// vanished root on a volume that came back read-only ends with the honest lock rather than no
/// lock at all. And a standing `.unwritableLocation` whose cause *changed* — a permission-denied
/// folder whose volume was then remounted read-only — re-lands with the new cause, updating the
/// row's line rather than replacing the row (`BannerRow.id` is constant per condition).
private func reconcileLock(after origin: WatchOrigin) {
switch readOnlyLock {
case .bracketedReloadFailed, .vanishedRoot:
readOnlyLock = nil
case .unwritableLocation, nil:
break
}
guard origin == .reconciling else { return }
switch (readOnlyLock, WritabilityProbe.probe(rootURL)) {
case (.unwritableLocation, nil):
Self.logger.debug("writability re-probe passed — the unwritable-location lock clears")
readOnlyLock = nil
case let (.unwritableLocation, .some(cause)):
// Still unwritable. The assignment is not a no-op only when the *cause* moved.
readOnlyLock = .unwritableLocation(cause)
case let (nil, .some(cause)):
Self.logger.error("writability re-probe failed (\(cause.rawValue, privacy: .public)) — the read-only lock rises")
readOnlyLock = .unwritableLocation(cause)
case (nil, nil):
break
// Cleared above, so unreachable — spelled so a new lock reason is a compile error here
// rather than a silent fall-through past the probe.
case (.bracketedReloadFailed, _), (.vanishedRoot, _):
break
}
}
// 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)
}
/// **The open-time writability probe** (02 § "An unwritable board location enters the read-only
/// lock at open") — `BoardStoreRegistry.acquire`'s call, and the only place the lock is raised
/// outside a reconciling reload.
///
/// A no-op on a writable board, which is the overwhelming case, and one `access(2)` plus one
/// volume resource value when it is not — cheap enough to sit unconditionally on the open path.
///
/// **The open still succeeds.** Nothing here refuses the board or throws: the lock's read
/// affordances stay live as always, because "inspecting an archived board on a DMG is a
/// legitimate errand, and viewing-first is the point". All that changes is that every mutating
/// entry point now consults a predicate that is already `true` before the window can be acted
/// on — the lock is up *before* the user's first gesture, which is the whole of "fail loudly,
/// specifically, once".
public func probeWritabilityAtOpen() {
guard let cause = WritabilityProbe.probe(rootURL) else { return }
enterUnwritableLock(cause)
}
/// Raises the unwritable-location read-only lock with the cause the probe found.
///
/// Public because the probe is not the only conceivable producer and because tests arm it
/// directly; `probeWritabilityAtOpen()` is the app's own path to it.
///
/// 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 (`reconcileLock(after:)` settles
/// the siblings first, then probes, precisely so that reload lands on the right answer).
public func enterUnwritableLock(_ cause: UnwritableCause) {
guard readOnlyLock == nil else { return }
Self.logger.error("board location is not writable (\(cause.rawValue, privacy: .public)) — entering the read-only lock")
readOnlyLock = .unwritableLocation(cause)
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()
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)
}
// **Flush-before-overwrite** (06-history-undo.md ▸ Rules), before the bracket rather than
// inside it: what the committer may need to do here is *commit*, and a commit taken with the
// watcher suspended would be a commit whose own reload never arrives. It is a no-op unless
// the window holds a change the app does not vouch for — see `GitAutoCommitter.noteWillWrite`
// for the gate, and for the two costs it is recorded as carrying.
commitSeam?.willWrite()
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.
//
// **The receipt harvest rides the same defer**, and after `end()` deliberately: the committer
// copies the ledger's receipts here because the landing reload *consumes* them, and this is
// the last moment they still describe a completed write nothing has classified yet
// (`EchoLedger.outstandingEntries`). A partway failure harvests too — bytes that reached disk
// are bytes the next commit will carry, whoever they belong to.
defer {
watcherBrackets?.end()
commitSeam?.writeBracketDidClose()
}
// **The receipt seam** (02-architecture.md ▸ Components ▸ EchoLedger). Binding the ledger
// here rather than passing it down is what keeps `BoardWriter` the stateless enum of statics
// the same bullet requires: the Writer's disk primitives drop receipts into whichever
// board's ledger is bound, and the binding is exactly this bracket — which is exactly the
// span in which a write is the app's. A Writer call outside one (another board's tree, a
// test) finds no ledger and records nothing.
return try EchoLedger.$current.withValue(echoes) {
// `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 free-tier bracket
/// today**: no git operations run there, 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
}
}
/// The same bracket over work that **awaits** — the undo restore (06-history-undo.md) and, next,
/// the branch switch.
///
/// A sibling rather than a replacement, and the reason is a hard fact about the two callers: the
/// synchronous version above exists because `performWrite`-shaped work is synchronous, while a
/// git operation is a detached libgit2 task the main actor must not block on
/// (`GitRepository`'s isolation rule). Both keep the bracket, the reload floor and the completion
/// phrase in one place; the distinct argument label is what keeps overload resolution from having
/// to guess which one a trailing closure meant.
///
/// The refusal, the ordering and the arming are the synchronous version's, unchanged — see its
/// doc comment for all three.
public func performWholesale(
announcing completion: String? = nil,
awaiting operation: () async throws -> Void
) async throws {
if let readOnlyLock {
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
}
watcherBrackets?.begin()
defer {
wholesaleReloadFloor = reloadGeneration + 1
wholesaleCompletion = completion
watcherBrackets?.end()
}
do {
try await operation()
} catch let error as BoardWriteError {
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.
///
/// **The step's stack is the anchor's** (13-native-undo.md ▸ Rules ▸ two levels): the card
/// window's sidebar editor passes that window's own (`CardStyleSection`), so a colour chosen there
/// is one of the window's fine-grained gestures and joins board history only inside the coarse
/// close step. The board popover, the Style… popover and the quick-style rows pass nothing, which
/// is the board's stack — where a board-issued gesture belongs even when it names a card whose
/// window is open.
///
/// **And so is the step's *anchoring***, by exactly the same split (13 ▸ Rules, ruled
/// 2026-07-31). A window's restyle is a session gesture: it anchors to the card's identity, so the
/// lane move that would once have staled it — and with it the whole coarse step it folds into —
/// now resolves through. A board-issued restyle keeps its path anchors, because a board gesture's
/// subjects are a *selection*, spanning lanes and the board root, and "board-stack steps keep
/// their existing path-anchored expectations" is the scope the ruling drew.
public func applyStyle(
to target: StyleTarget,
background: StyleChange = .keep,
icon: StyleChange = .keep,
on window: CardWindowUndo? = nil
) {
// A session gesture anchors to the card, everything else to the folder it resolved (above).
// The level is asked once, off the target, because a window's editor names exactly one card
// and a board gesture's selection may name lanes and the root — neither of which a card walk
// could ever resolve.
let anchorsByIdentity = window != nil && styleLevel(of: target) == .card
let edits: [(
id: ItemID?,
folder: URL,
anchor: HistoryAnchor,
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 }
let anchor: HistoryAnchor = if anchorsByIdentity, let id = subject.id {
.card(id)
} else {
.path(subject.folder)
}
return (
id: subject.id,
folder: subject.folder,
anchor: anchor,
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`).
// `kind: .board` for the one subject with no identity — the board root, whose
// position nothing can infer (`BoardWriter.updateIndex`'s on-touch backfill).
try BoardWriter.updateIndex(
inItemFolder: edit.folder,
kind: edit.id == nil ? .board : nil,
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,
on: window,
undoExpects: edits.map {
.present($0.anchor, fields: Self.styledFields(background: $0.background, icon: $0.icon))
},
redoExpects: edits.map {
.present($0.anchor, fields: Self.restoredStyleFields(
background: $0.background,
priorBackground: $0.priorBackground,
icon: $0.icon,
priorIcon: $0.priorIcon
))
}
) { store in
for edit in edits {
try BoardWriter.updateIndex(
inItemFolder: try store.requiredFolder(for: edit.anchor, .style(title: nil)),
kind: edit.id == nil ? .board : nil,
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: { store in
for edit in edits {
try BoardWriter.updateIndex(
inItemFolder: try store.requiredFolder(for: edit.anchor, .style(title: nil)),
kind: edit.id == nil ? .board : nil,
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.
// Ask, and on exhausted midpoint precision (01-storage-format.md § Ordering) compact
// and ask again — the shared two-step. 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` and nothing captured needs refreshing here.
guard let placed = try HealScheduler.placingRanks(
amongVisible: visible.map(\.order),
compacting: laneFolder,
{ Ranks.insertionRank(amongVisible: $0, at: position) }
) else { return id }
let rank = placed.placement
_ = 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.
///
/// - Parameter kind: `.board` from the board rename, `nil` from an item's — the on-touch `kind`
/// backfill's one declared case, since a board root's folder name is a Finder document name
/// and position cannot answer for it (`BoardWriter.updateIndex`).
private static func setTitle(
_ title: String?,
at folder: URL,
kind: IntegrityRules.ObjectKind? = nil
) throws(BoardWriteError) {
try BoardWriter.updateIndex(inItemFolder: folder, kind: kind, 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 card 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 step is anchored to the card, never to its folder** (13 ▸ Rules, ruled 2026-07-31): it
/// carries the card's UUID and the bytes, and the folder is resolved at apply time by the walk
/// `writeCardBody` itself uses. A lane move, mid-session or long after, therefore leaves the step
/// alone, and so does the trash move a dismissing window flushes into — the two relocations this
/// step used to be staled by, though neither is a change to the bytes it is about.
///
/// ### Which stack it lands on is the caller's to say
///
/// An Edit session belongs to a *window*, so the card window passes its own
/// (`CardWindowHost.configureSession` → `CardWindowUndo`) and the step never reaches board
/// history until the window closes and folds it into the coarse session step (13 ▸ Rules ▸ two
/// levels). `nil` — the default, and what a test or any non-window caller passes — is the board's
/// stack, exactly as before.
public func registerBodyEdit(
inCard cardID: ItemID,
priorBody: String,
newBody: String,
on window: CardWindowUndo? = nil
) {
guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
let title = Self.cardTitle(at: target, in: snapshot)
let card = HistoryAnchor.card(cardID)
let operation = WriteOperation.editBody(title: title)
registerStep(
HistoryPhrase.name(.edit, kind: .card),
subject: title,
on: window,
undoExpects: [.present(card, .body(newBody))],
redoExpects: [.present(card, .body(priorBody))]
) { store in
_ = try BoardWriter.writeBody(
inItemFolder: try store.requiredFolder(for: card, operation),
body: priorBody
)
} redo: { store in
_ = try BoardWriter.writeBody(
inItemFolder: try store.requiredFolder(for: card, operation),
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
}
/// An item'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
case let .trashLane(id):
snapshot.trashedLanes.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, kind: .board)
}
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, kind: .board)
} redo: { _ in
try Self.setTitle(newTitle, at: folder, kind: .board)
}
}
// 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
// The shared two-step, over the *whole* strip: unlike the card-create case the dragged
// lane **is** among the renumbered children — it is a real folder on disk — so the ask
// drops its own rung before consulting the neighbours, and a compaction refreshes the
// prior rank the inverse has to restore.
guard let placed = try HealScheduler.placingRanks(
amongVisible: lanes.map(\.order),
compacting: root,
{ ladder in
var compacted = ladder
compacted.remove(at: from)
return Ranks.insertionRank(amongVisible: compacted, at: target)
}
) else { return }
if placed.renumbered { priorOrder = placed.ladder[from] }
let rank = placed.placement
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
// `moveLane`'s two-step, plural: the dragged lanes *are* among the renumbered children,
// so the ask drops their rungs before consulting the neighbours, and a compaction
// refreshes the prior ranks the inverse restores.
guard let placed = try HealScheduler.placingRanks(
amongVisible: lanes.map(\.order),
compacting: root,
{ ladder in
let rungs = Array(zip(lanes, ladder))
return Ranks.insertionRanks(
amongVisible: rungs.filter { !ids.contains($0.0.id) }.map(\.1),
at: target,
count: members.count
)
}
) else { return }
if placed.renumbered {
priorOrders = Array(zip(lanes, placed.ladder)).filter { ids.contains($0.0.id) }.map(\.1)
}
let ranks = placed.placement
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)
}
}
}
/// **The lane restore: `.trash/` out to the strip, at a drop position** (03-board-ui.md § Trash:
/// "Restoring is an ordinary move out … drag … a trashed lane row to a lane-strip slot";
/// 04-interactions.md ▸ The trash: "dropping … a trashed lane row onto its own board's strip — is
/// an ordinary move to the drop position").
///
/// **Its own method rather than a branch inside `moveLanes`**, because the two are different
/// arithmetic wearing one word: a reorder permutes the strip's own lanes and has to drop the
/// dragged lanes' rungs before consulting the neighbours, while a restore is an *arrival* — the
/// lane is not on the ladder at all — which is exactly `receiveLanes`' shape. What it does not
/// borrow from `receiveLanes` is that method's cross-board posture: this is a within-board move,
/// so the identity travels untouched (no import boundary, no remint) and the gesture earns an
/// undo step, which an arrival deliberately does not (13-native-undo.md's inverse inventory names
/// "restore-by-move → move back in").
///
/// The cards ride along inside the folder, unread and unwritten, exactly as they did on the way
/// in (`moveLanesToTrash`).
public func restoreLanes(_ ids: Set<ItemID>, toIndex index: Int) {
let members = snapshot.trashedLanes.filter { ids.contains($0.id) }
guard !members.isEmpty else { return }
let root = rootURL
let rendered = snapshot.lanes
let target = min(max(0, index), rendered.count)
// What an undo puts back: the `order` the row was **carrying** while trashed — its old strip
// rank, which the trash move never rewrote (01-storage-format.md § Deletion, re-ruled
// 2026-07-31) and which this restore is about to overwrite with a drop-position rank.
var arrivals: [(id: ItemID, order: Double, carriedOrder: Double, title: String?)] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
// The shared two-step; the arriving lanes are not among the renumbered children.
guard let placed = try HealScheduler.placingRanks(
amongVisible: rendered.map(\.order),
compacting: root,
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: members.count) }
) else { return }
let ranks = placed.placement
for (member, rank) in zip(members, ranks) {
_ = try BoardWriter.moveItem(
at: ItemPath.trashLane(member.id).folder(under: root),
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
arrivals.append((
id: member.id,
order: rank,
carriedOrder: member.order,
title: member.title.value
))
}
}
guard landed != nil, !arrivals.isEmpty else { return }
// restore-by-move → **move back in** (13-native-undo.md ▸ Interaction with the trash),
// carrying back the `order` the row had while it sat there. The restore is what overwrote it
// (a move writes a landing rank), so putting it back is what makes this a true inverse —
// there is no *trash* rank involved either way, since the trash's own sequence is `modified`.
let trashFolder = Self.parentFolder(of: .trashLane(arrivals[0].id), under: root)
let steps = arrivals.map { arrival in
(
restored: ItemPath.lane(arrival.id).folder(under: root),
trashed: ItemPath.trashLane(arrival.id).folder(under: root),
order: arrival.order,
carriedOrder: arrival.carriedOrder
)
}
registerStep(
HistoryPhrase.name(.move, kind: .lane, count: steps.count),
subject: arrivals.count == 1 ? arrivals[0].title : nil,
undoExpects: steps.map { .present($0.restored, .order($0.order)) },
redoExpects: steps.map { .present($0.trashed, .order($0.carriedOrder)) }
) { _ in
for step in steps {
_ = try BoardWriter.moveItem(
at: step.restored,
toParent: trashFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.carriedOrder
)
}
} redo: { _ in
for step in steps {
_ = try BoardWriter.moveItem(
at: step.trashed,
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.order
)
}
}
}
// 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 {
BoardStore.parentFolder(of: path, under: 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
// The shared two-step. The renumber assigns in display order over the lane's cards, so
// the compacted ladder lines up one-for-one with `rendered`; members already in this
// lane are dropped from it before the neighbours are consulted, exactly as `moveLane`
// drops the dragged lane's own rung — and a compaction refreshes their captured origins.
guard let placed = try HealScheduler.placingRanks(
amongVisible: rendered.map(\.order),
compacting: laneFolder,
{ ladder in
let rungs = Array(zip(rendered, ladder))
return Ranks.insertionRanks(
amongVisible: rungs.filter { !ids.contains($0.0.id) }.map(\.1),
at: target,
count: members.count
)
}
) else { return }
if placed.renumbered {
for (card, rank) in zip(rendered, placed.ladder) where ids.contains(card.id) {
origins[card.id] = (path: .card(lane: laneID, id: card.id), order: rank)
}
}
let ranks = placed.placement
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.
///
/// Both trash cases answer the container itself: `.trash/` is flat, so a trashed lane's parent is
/// the same folder a trashed card's is (01-storage-format.md § Deletion).
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, .trashLane: 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
// The shared two-step. The copies are not among the renumbered children — they do not
// exist yet — so the compacted ladder lines up one-for-one with `rendered` and nothing
// captured needs refreshing.
guard let placed = try HealScheduler.placingRanks(
amongVisible: rendered.map(\.order),
compacting: laneFolder,
{ Ranks.insertionRanks(amongVisible: $0, at: placement, count: members.count) }
) else { return }
let ranks = placed.placement
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 — **or two levels under, when the lane is in
/// a trash** (03-board-ui.md § Trash: `.trash/` is flat, so a trashed lane sits one level deeper
/// than a live one).
///
/// The depth is a fact about the path and the fractal layout fixes both shapes, which is why this
/// reads the parent's name rather than taking a second parameter that could disagree with the
/// first. It matters at exactly one place and matters a lot there: the import boundary turns on
/// source-root-versus-destination-root (`BoardWriter.moveItem`), so a trashed lane pasted back
/// into its **own** board must answer `<root>` — answering `<root>/.trash` would make the restore
/// an import, and the import would find the row's own identity in the destination (the trash
/// counts in `identityOccurrences`) and remint the lane it was restoring.
///
/// The card twin needs no such clause: `<root>/.trash/<card>` is already two levels down, which
/// is the depth a lane's card sits at.
nonisolated static func boardRoot(ofLaneFolder folder: URL) -> URL {
let parent = folder.deletingLastPathComponent()
guard parent.lastPathComponent == IntegrityRules.trashFolderName else { return parent }
return parent.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.
///
/// **One producer shape, because there is only one kind of source left.** A drag names folders in
/// the source board; a paste names folders in the clipboard's staging directory. There used to be a
/// second case — the manifest's embedded `index.md`, materialized when the staged snapshot was
/// missing or unreadable — and it is **retired with the degraded paste** (04-interactions.md ▸
/// Clipboard, re-ruled 2026-07-29: "A paste whose staged snapshot is missing or unreadable refuses
/// loudly — never degrades"). An item arrives **whole — index, attachments, loose files — or not at
/// all**, so a paste can no longer half-fall-back inside one bracket, and the loss-accounting
/// problem the second case created dissolves rather than being solved. The manifest still embeds
/// `index.md`, now purely as identification metadata: menu validation, the refusal's wording, and
/// the plain-text flavor.
///
/// It stays a single-case enum rather than collapsing to a bare `URL`: the arrival paths read as
/// "where do these bytes come from", the drag and the clipboard each say so at their own call
/// sites, and a future third producer (an import, a drop from another document type) has a place to
/// land that is not a rewrite of every signature in between.
public enum ItemSource: Sendable, Equatable {
/// A folder on disk — the source board's own, or a staged snapshot of it.
case folder(URL)
}
/// 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
/// 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"). A copy whose staged snapshot is
/// missing never reaches here at all: the clipboard refuses the whole paste in front of this call
/// (04 ▸ Clipboard, re-ruled 2026-07-29), so every source this sees is a folder that exists.
///
/// **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
// The shared two-step; the arrivals are not among the renumbered children.
guard let placed = try HealScheduler.placingRanks(
amongVisible: rendered.map(\.order),
compacting: laneFolder,
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: sources.count) }
) else { return }
let ranks = placed.placement
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 source crossed with the two operations, in the one place
/// both the card path and the lane path can share.
///
/// A copy is `copyItem` (fresh GUIDs throughout, the copy contract applied to every folder it
/// materializes) and a move is `moveItem` (identity travels, the import boundary reminting only
/// what collides). The `ItemID?` return survives the retired text case because the lane and card
/// arrival loops read it as "did this arrival land": a `nil` is the standing silent no-op for a
/// source that names nothing.
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
}
}
/// 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 paste's own axis 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
// The shared two-step; the arriving lanes are not among the renumbered children.
guard let placed = try HealScheduler.placingRanks(
amongVisible: rendered.map(\.order),
compacting: root,
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: sources.count) }
) else { return }
let ranks = placed.placement
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 loose-file defect is the notice half).
///
/// **Scheduling is the engine's** (`HealScheduler`): the resting-clear, the lock-and-writability
/// gate, the signature compare, the armed-before-attempt memo, the one bracket, the banner
/// posture and the clear-on-success are all its six steps, and this method is now only what is
/// genuinely this heal's — which Writer call the defect maps to, and what the notice names.
///
/// **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.)
///
/// **One 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 write half re-verifies against disk**: `BoardWriter.relocateLooseFiles` re-reads each
/// name at write time (`isRelocatable`) and skips what has gone, so a card whose loose files
/// vanished under the write contributes no line to the notice.
public func relocateLooseCardFiles() {
let work = looseCardFiles
let root = rootURL
var relocated: [BannerCenter.Relocation] = []
heals.run(
.looseCardFiles,
signature: Self.signature(of: work.map(IntegrityRules.Defect.looseCardFiles)),
on: self
) { () 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 }
))
}
} posting: {
.relocatedLooseFiles(relocated)
}
}
/// A defect list as the engine's comparable picture — every defect's own signature, flattened.
///
/// 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 signature(of defects: [IntegrityRules.Defect]) -> Set<String> {
Set(defects.flatMap(\.signatures))
}
/// 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
// The shared two-step; the created cards are not among the renumbered children.
guard let placed = try HealScheduler.placingRanks(
amongVisible: rendered.map(\.order),
compacting: laneFolder,
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: urls.count) }
) else { return }
let ranks = placed.placement
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
// The shared two-step, with the *ask* being "are these ranks usable at all?": a
// permutation can only rewrite ranks that already separate the cards, so a ladder with
// ties is exhausted in exactly the sense the helper means, and the compacted one — which
// is strictly ascending by construction — always answers. It lines up one-for-one with
// `rendered`, the same alignment `commitPlaceholder` relies on.
guard let placed = try HealScheduler.placingRanks(
amongVisible: orders,
compacting: laneFolder,
{ Self.isStrictlyAscending($0) ? $0 : nil }
) else { return }
let ladder = placed.placement
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: deleteTrashEntries(selection.ids)
}
}
/// Deletes every **board** item in `ids` — cards and lanes alike into `.trash/` — in one bracket.
///
/// **One act at two levels now** (03-board-ui.md § Trash, re-ruled 2026-07-29): "deleting a lane
/// moves its folder — subtree intact — into `.trash/`, exactly as a card moves". The two writes
/// below differ only in which Writer door they take (`deleteCardToTrash` / `deleteLaneToTrash`),
/// which is a `kind` question and not a semantic one — no dialog either way, because the move is
/// recoverable and "nothing needs confirming".
///
/// **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 takes its cards with it, and moving them
/// separately would file the same cards twice and put two steps on the 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:)))
: moveLanesToTrash(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:)))
}
/// **Drop-on-trash deletes, at the lane level** (04-interactions.md ▸ The trash, lanes extended
/// 2026-07-29: "a lane drag over the shown trash proposes the delete alongside its strip slots").
///
/// `deleteByDrag`'s twin exactly, and for its reasons: the write is `moveLanesToTrash`, so a lane
/// deleted by drop and one deleted by ⌫ are byte-indistinguishable afterwards, and it says
/// **nothing about the selection** — a drag has no keystroke to keep repeatable and its run is not
/// necessarily the selection at all.
public func deleteLanesByDrag(laneIDs: [ItemID]) {
let lanes = ItemPath.resolve(Set(laneIDs), in: .board, snapshot: snapshot).compactMap { path -> ItemID? in
guard case let .lane(id) = path else { return nil }
return id
}
_ = moveLanesToTrash(lanes)
}
/// **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/`** — 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.
///
/// **There is no rank to mint** (03-board-ui.md § Trash, re-ruled 2026-07-31: "newest-first with
/// no `order` rewrite, no rank minting, the item's `order` key riding along untouched for its
/// eventual restore"). The trash sorts by `modified` descending and the move stamps it, so the
/// position is the Writer's own doing and the store has no snapshot question left to answer —
/// the head-of-the-trash ladder this method used to thread through the run retired with the rule.
///
/// - 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
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for move in moves {
try BoardWriter.deleteCardToTrash(
at: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root),
inBoard: root
)
}
}
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, which now takes no values at all — a delete is a folder
// move plus a fresh stamp, and a redone delete lands where a first one would.
//
// **The expectations are one swap, and the container rides in the path** (`HistoryStaleness`):
// the undo wants the card **in the trash**, and that is the whole of it — 13's field-level
// predicate compares "what its write set", and this write sets no field an expectation can
// name (the `modified` stamp is a clock reading, not a value the step chose). Existence is
// the honest expectation, and it is the one that matters: a foreign restore empties the trash
// path and the undo skips. The redo's side is unchanged and still field-level, because the
// *undo* set it: the card back in its lane holding the rank it left. A foreign re-delete
// empties the lane path and the redo skips.
let steps = moves.map { move 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
)
}
registerStep(
HistoryPhrase.name(.delete, kind: .card, count: steps.count),
subject: moves.count == 1 ? moves[0].title : nil,
undoExpects: steps.map { .present($0.trashed) },
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)
}
}
return true
}
/// **Deleting a lane is a move into `.trash/`, subtree intact** (03-board-ui.md § Trash,
/// re-ruled 2026-07-29: "deleting a lane moves its folder — subtree intact — into `.trash/`,
/// exactly as a card moves … The no-dialog posture survives for a better reason: the move is
/// recoverable, so nothing needs confirming").
///
/// **`moveToTrash`'s twin, and deliberately its mirror image**: no rank, the same one bracket,
/// the same one step — because on disk it is
/// the same write one level up (`BoardWriter.deleteLaneToTrash`, which differs only in the guard
/// it passes and the `kind` it stamps). What is *not* here any more is the whole capture layer:
/// the lane's bytes never leave the disk, so nothing has to hold them (13-native-undo.md ▸
/// Interaction with the trash: "the recreate-from-capture inverse retires with the last
/// destructive delete").
///
/// **The cards ride along and are not the store's business**: they are inside the folder that
/// moved, so they are neither read nor written, and they leave the snapshot with their lane —
/// which is what dismisses their card windows and voids their pending cuts, through the ordinary
/// vanish rule and no clause of its own (02-architecture.md § Live-reload resilience).
@discardableResult
private func moveLanesToTrash(_ 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 landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for lane in lanes {
try BoardWriter.deleteLaneToTrash(
at: ItemPath.lane(lane.id).folder(under: root),
inBoard: root
)
}
}
guard landed != nil else { return false }
// lane delete → **the ordinary move back** (13-native-undo.md ▸ Interaction with the trash:
// "a lane [returns] to its strip position (subtree intact — it never left the folder)"). The
// redo replays the forward write, which takes no values — the card delete's shape exactly.
//
// The expectations are one swap, and the container rides in the path (`HistoryStaleness`):
// the undo wants the lane **in the trash** (existence alone — the delete sets no field an
// expectation can name, `moveToTrash`' note), the redo wants it back on the strip holding the
// rank the undo put back. A foreign restore empties the trash path and the undo skips; a
// foreign re-delete empties the strip path and the redo skips.
let steps = lanes.map { lane in
(
trashed: ItemPath.trashLane(lane.id).folder(under: root),
origin: ItemPath.lane(lane.id).folder(under: root),
priorOrder: lane.order
)
}
registerStep(
HistoryPhrase.name(.delete, kind: .lane, count: steps.count),
subject: lanes.count == 1 ? lanes[0].title.value : nil,
undoExpects: steps.map { .present($0.trashed) },
redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) }
) { _ in
for step in steps {
_ = try BoardWriter.moveItem(
at: step.trashed,
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.priorOrder
)
}
} redo: { _ in
for step in steps {
try BoardWriter.deleteLaneToTrash(at: step.origin, inBoard: root)
}
}
return true
}
/// **The trash's own Delete — permanent** (03-board-ui.md § Trash: "on a trash selection, 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 (the trash's Delete,
/// Empty Trash) … the confirm *is* the safety", "lanes and their freight included"). A stack
/// entry here would be a promise the filesystem cannot keep.
///
/// **Entries, not cards** (lanes rejoined the trash 2026-07-29): a trashed lane row purges whole,
/// its subtree with it, through the same Writer call — the walk is the recursive removal's, and
/// the *counting* the confirmation needs is `TrashModel.freight`'s, off the snapshot before this
/// runs.
///
/// **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 deleteTrashEntries(_ 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.purgeTrashEntry(at: path.folder(under: root), inBoard: root)
}
}
if let successor {
select([successor], in: .trash, anchor: successor, head: successor)
} else {
clearSelection()
}
}
/// **Empty Trash… ⇧⌘⌫** — purges every entry in `<root>/.trash/`, **lane subtrees walked**, in
/// one bracket (03-board-ui.md § Trash).
///
/// Not undoable, `deleteTrashEntries`' 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 || !snapshot.trashedLanes.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 legacy-tombstone defect is the notice
/// half).
///
/// **`relocateLooseCardFiles()`'s twin in every mechanical respect**, and since 2026-07-29 that
/// is true by construction rather than by two methods agreeing: both run on `HealScheduler`, so
/// the tail hook, the lock deferral, the writability gate, the memo and the clear-on-success are
/// one implementation. What is this heal's own is below.
///
/// ### One act, since 2026-07-29
///
/// **A card relocates into `.trash/`** with the key removed (`BoardWriter.migrateTombstonedCard`),
/// and that is the whole of it. The lane half is **retired wholesale** with the lane trash
/// (01-storage-format.md § Deletion: "cards migrate, lanes ignore" — a lane carrying `deleted:`
/// "simply loads live with the key ignored — no migration machinery, no key-strip write, no
/// notice"). Old tombstoned lanes reappearing is the accepted cost, stated in the ruling; a
/// tombstoned lane's own cards still migrate on their own account, as ordinary tombstoned cards.
///
/// ### The column order is the stamps', not the batch's
///
/// **Each migrated card takes its own `deleted:` timestamp as its `modified`** where it parses
/// (01-storage-format.md § Deletion, re-ruled 2026-07-31; `BoardWriter.migrateTombstonedCard`),
/// so the board's real deletion order survives into the trash's `modified`-descending sort no
/// matter what order the batch runs in — the sequencing that used to *be* the ordering is now
/// only a batch order. It is kept, `deleted:`-ascending, for determinism: the notice's card list
/// and the commit's path order read the same way twice. A card whose stamp is missing or
/// unparseable sorts as **oldest** here and takes migration time as its `modified`, landing it
/// among the freshest — the honest reading, since a stamp that cannot be read is no evidence of
/// when the card was deleted; ties fall to the loader's walk order.
///
/// ### The write half re-verifies against disk
///
/// Each card's `deleted:` key is re-read at write time (`stillTombstoned(at:)`) and a key that
/// has gone — an agent removed it, another window migrated first — skips silently: "losing the
/// race to a foreign fix is success, never an error" (§ Validation and healing, generalized from
/// the Repair-races-a-vanished-duplicate precedent). Without it a card whose key vanished under
/// the write would be moved into the trash for nothing.
public func migrateLegacyTombstones() {
let work = legacyTombstones
let root = rootURL
let cards = Self.migrationOrder(of: work, in: snapshot)
var movedCards: [String?] = []
heals.run(
.legacyTombstone,
signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)),
on: self
) { () throws(BoardWriteError) -> Void in
for card in cards {
let folder = ItemPath.card(lane: card.laneID, id: card.cardID).folder(under: root)
guard Self.stillTombstoned(at: folder) else { continue }
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root)
movedCards.append(card.title)
}
} posting: {
.migratedTombstones(cards: movedCards)
}
}
/// Whether the item at `folder` still carries a `deleted:` key — the migration's disk re-verify.
///
/// **Presence, not validity**, exactly as `Lane`/`Card.isDeleted` reads it: a malformed timestamp
/// still tombstones, and an explicit `deleted: null` is absence to both. A file that cannot be
/// read or parsed answers `false` — the conservative direction, since a migration is a *move* and
/// the one thing it must never do is move something on a guess.
nonisolated static func stillTombstoned(at folder: URL) -> Bool {
guard let data = try? Data(contentsOf: folder.appendingPathComponent(BoardLoader.indexFileName)),
let text = String(validating: data, as: UTF8.self),
let document = try? FrontmatterDocument.parse(text)
else {
return false
}
return !document.deleted.isMissing
}
/// The tombstoned cards in the order the batch files them — oldest `deleted:` first, a
/// deterministic batch order rather than the column's (see `migrateLegacyTombstones`: each card's
/// own stamp decides where it lands).
///
/// `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()
.sorted { lhs, rhs in
let left = stamps[lhs.element.cardID] ?? .distantPast
let right = stamps[rhs.element.cardID] ?? .distantPast
return left == right ? lhs.offset < rhs.offset : left < right
}
.map(\.element)
}
// MARK: - The claimed-name displacement
/// Moves squatters off the names the app claims, and posts the notice naming old and new — the
/// **act** half of the claimed-names ruling (01-storage-format.md § Fractal layout ▸ Rules, ruled
/// 2026-07-29: "Lanework owns the board, so an invalid artifact on a claimed name is a defect, not
/// a resident").
///
/// **Two levels, one heal** (extended 2026-07-29 — "the rule is level-uniform"): the board root's
/// `.trash`, and any card's `attachments`. Each is a file or symlink sitting on a name the app needs
/// — breaking deletion in the first case, and every import, Finder drop and sidebar listing for that
/// card in the second — which is exactly why the timing is *scheduled* rather than on-touch
/// (§ Validation and healing: "proactive when the defect is load-bearing now"). `CLAUDE.md`'s
/// squatter is displaced by the guide's own heal, which owns that file end to end; a wrong-kinded
/// `comments` is a tolerated stray until the feature consumes the name.
///
/// **One bracket over every displacement the load found**, whatever their levels: the batch is one
/// app-mediated reload and, on git boards, one heal commit — the loose-file relocation's rule, and
/// this heal's own memo is board-wide anyway.
///
/// **Displacement, never destruction**, and never a mint: the freed name is left empty and the next
/// gesture that needs it creates the real folder — the next delete mints `.trash/`, the next import
/// mints `attachments/` — exactly as on a board that never had one. The displaced file, now an
/// ordinary loose file beside the card's `index.md`, is picked up by the next load's loose-file
/// relocation and lands in the real `attachments/`: the heals compose, which is 01's own word for it.
public func displaceClaimedNames() {
let work = claimedNameSquatters
let root = rootURL
var displaced: [BannerCenter.Displacement] = []
heals.run(
.claimedNameSquatted,
signature: Self.signature(of: work.map(IntegrityRules.Defect.claimedNameSquatted)),
on: self
) { () throws(BoardWriteError) -> Void in
for squatter in work {
// The Writer re-verifies and answers `nil` when the name freed itself under us.
guard let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: root) else {
continue
}
displaced.append(BannerCenter.Displacement(name: squatter.name, movedTo: freed))
}
} posting: {
.displacedClaimedNames(displaced)
}
}
// MARK: - The duplicate-id remint
/// Gives every duplicate id the last load withheld the fresh identity a copy should have had, and
/// posts one notice naming what was repaired — the **act** half of the duplicate-id rule
/// (01-storage-format.md § Fractal layout ▸ Rules; re-ruled 2026-07-29: "a silent heal, superseding
/// the former user-gated Repair banner", because "Lanework owns the board and re-mints identity at
/// will").
///
/// ### It is a heal, not a command
///
/// This is the whole of the 2026-07-29 re-ruling in one method. What it replaced was a *condition
/// banner* offering a **Repair** button — a consent gate for a repair that is unambiguous and
/// content-lossless, which is exactly the class the ruling moved to app-initiated. So: no banner
/// row to raise or clear, no button, no Command Nexus row, nothing that waits. There is a notice
/// afterwards, because an identity changed and a heal that touches user content says so
/// (§ Validation and healing), and that is the only user-visible trace.
///
/// **The withheld window is one heal cycle, not a standing condition** (02-architecture.md ▸
/// Live-reload resilience): the load withholds, this remints, the reload that follows renders the
/// folder as an ordinary item under its new id.
///
/// ### Its scheduling is the engine's, its ordering is not
///
/// `HealScheduler` supplies the six steps — the resting-clear, the lock-and-writability gate (so a
/// read-only board **defers, never abandons**), the signature compare, the armed-before-attempt
/// memo, the one bracket, the notice and the clear-on-success. What is this heal's own is the Writer
/// call and the notice — and its place in `runScheduledHeals()`, which is deliberately *after* the
/// heals that write inside item folders: see that method.
///
/// **It registers no undo step**, and per 13-native-undo.md that is a ruling rather than a
/// deferral: heals are not gestures, so nothing enters the stack, and undoing a remint would
/// recreate the duplicate id it exists to remove.
///
/// **The write half re-verifies against disk**: `BoardWriter.remintDuplicateIdentity` checks both
/// that the folder is still there under the losing identity *and* that something else still carries
/// it, so a duplicate that vanished under the write — repaired on another device, hand-deleted —
/// contributes no rename and no line to the notice.
public func remintDuplicateIdentities() {
let work = duplicateIdentities
let root = rootURL
var reminted: [String?] = []
heals.run(
.duplicateIdentity,
signature: Self.signature(of: work.map(IntegrityRules.Defect.duplicateIdentity)),
on: self
) { () throws(BoardWriteError) -> Void in
for duplicate in work {
// The Writer answers `nil` when the duplicate resolved itself under us.
guard try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: root) != nil else {
continue
}
reminted.append(duplicate.title)
}
} posting: {
.remintedDuplicateIDs(titles: reminted)
}
}
// 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 other scheduled heals, and once more at open
/// (`BoardStoreRegistry.acquire`). 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 two skips, and the one displacement
///
/// `.skipUserFilenameTaken` is the standing exception (a rescue destination is not itself freed
/// by a second displacement); `.displaceSquatterThenWrite` is the 2026-07-29 upgrade of the old
/// untouchable-skip, and it *does* post — a node of the user's moved aside owes the same
/// warning-tone notice `.trash`'s displacement does. Both decisions are re-made inside the write
/// half, which is this heal's disk re-verify.
///
/// ### The signature is the board root's picture
///
/// One failure, one row, then silence until something on disk actually changes — and the memo
/// clears on success, which is what lets a foreign deletion of the guide be healed again
/// immediately (the picture "missing" is restored, and a standing memo would make that deletion
/// the one thing this could not heal).
public func refreshAgentGuide() {
let root = rootURL
let state = AgentGuide.inspect(atBoardRoot: root)
let decision = AgentGuide.decide(state)
// **A decision that writes nothing is no work at all**, and says so with an empty signature:
// the engine's resting-clear then costs no bracket, which matters because a bracket schedules
// a reload whether or not anything was written — a skip that opened one would tick forever.
let signature: Set<String>
switch decision {
case .leaveAlone:
signature = []
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")
signature = []
case .write, .displaceThenWrite, .displaceSquatterThenWrite:
signature = [state.signature]
}
var displaced: [BannerCenter.Displacement] = []
heals.run(
.staleAgentGuide,
signature: signature,
on: self
) { () throws(BoardWriteError) -> Void in
// One bracket over the displacement *and* the write: two files change, one app-mediated
// reload lands, and (under Pro) one honestly-attributed commit records it.
guard let moved = try AgentGuide.install(atBoardRoot: root) else { return }
// The `CLAUDE.user.md` rescue is the settled, silent ownership rule; a squatter's
// displacement is announced.
guard !moved.wasUserContent else { return }
displaced.append(BannerCenter.Displacement(name: moved.name, movedTo: moved.movedTo))
} posting: {
.displacedClaimedNames(displaced)
}
}
/// **Every scheduled heal, in order** — the engine's two seams call exactly this
/// (02-architecture.md ▸ Components ▸ HealScheduler: "fires uniformly at the reload tail and at
/// registry acquire, closing today's asymmetry where tombstone migration never fires at open").
///
/// **The claimed-name displacement goes first, and that ordering is load-bearing**: a card's
/// migration mints `<root>/.trash/`, which cannot be created while a file or symlink holds that
/// name — so a migration attempted ahead of the displacement fails *and arms its memo against an
/// unchanged tombstone picture*, which would leave the board unmigrated until something else on
/// disk changed. Running the displacement first closes the ruling's one-reload window inside a
/// single pass, because a heal's write lands synchronously even though its reload does not.
///
/// **The duplicate-id remint goes after the two heals that write inside item folders, and that
/// ordering is load-bearing too** — for the mirror-image reason. A remint *renames a folder*, so
/// every path the same load handed the other healers below it would go stale the moment it ran: a
/// loose-file relocation aimed at a folder that had just been renamed underneath it would fail
/// loudly and banner about work the user never asked for. Running it last means each of them acts
/// on the paths the snapshot actually described, and the remint's own write is the last thing to
/// change the tree in the pass.
///
/// It costs at most one extra reload in the rarest of overlaps (a duplicate that *also* carries a
/// legacy `deleted:` key): the migration moves it into `.trash/` first, the remint's path is stale,
/// the Writer's re-verify no-ops, and the next load finds the duplicate at its new path and heals
/// it. Self-healing beats a failure banner.
///
/// The rest of the order is immaterial: they touch disjoint files (a card's loose files, a
/// `deleted:` key inside an `index.md`, `CLAUDE.md`), each opens its own bracket, and each is
/// re-armed by the reload the others' writes produce, so none can see another's work half-done.
public func runScheduledHeals() {
displaceClaimedNames()
relocateLooseCardFiles()
migrateLegacyTombstones()
remintDuplicateIdentities()
refreshAgentGuide()
}
// 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
// **The comment index tracks the query, not the reload** (04 ▸ Search, re-ruled
// 2026-07-29): the first keystroke of a query kicks the sweep, every later one re-filters
// what it found, and clearing throws the whole thing away. Before the constraint, so a
// keystroke that *widens* the comment matches does not first evict a selection the very
// next line would have kept.
refreshCommentIndex()
transient.constrainToSearch(in: snapshot, commentMatches: commentIndex.matchingCards)
}
}
/// 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`).
///
/// **It carries the comment index' answer**, which is what makes "a card matches if any of its
/// meaningful content matches" true for comments on every surface at once — the masonry, the
/// order lists, the trash column, Select All — without any of them learning that comments exist.
public var searchFilter: SearchFilter {
SearchFilter(query: transient.searchQuery, commentMatches: commentIndex.matchingCards)
}
/// Points the comment index at the current query and snapshot — the one funnel, called from the
/// query's setter and from a landed reload (`land`).
///
/// The targets are computed **lazily**, inside the index' own decision: an already-fresh index
/// re-filters in memory and never asks, so an ordinary keystroke costs no board walk at all.
private func refreshCommentIndex() {
commentIndex.onRefine = { [weak self] in
guard let self else { return }
transient.constrainToSearch(in: snapshot, commentMatches: commentIndex.matchingCards)
}
commentIndex.update(
query: transient.searchQuery,
generation: snapshotGeneration,
targets: CommentSearchIndex.targets(in: snapshot)
)
}
/// 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 row — the exact conditions under which
/// "all" could mean anything but the board (04 ▸ The map, resettled 2026-07-28: "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.
///
/// **In the trash "all" is all *rows*, both kinds** (04 ▸ The trash and 11-command-nexus.md ▸
/// Select All, re-ruled 2026-07-31 with kind-blind trash selection: "Select All with a non-empty
/// trash selection selects **all visible trash rows**"). The board's own Select All stays
/// card-scoped, as everywhere.
///
/// 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.trashRows(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()
}
}
}