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 /// **A board root's identity**, as the one canonical spelling every locality comparison is made /// against (04-interactions.md ▸ Drag and drop — the Finder volume model). /// /// Two roots name the same board when they name the same place: symlinks are resolved first — a /// board reached through a pinned symlink is the same board as the one reached directly /// (01-storage-format.md's symlink pins) — and the standardized path is the comparison key, so /// `/Boards/./Work.kanban/` and `/Boards/Other/../Work.kanban` are one board. /// /// **Minting touches the filesystem; comparing does not.** `resolvingSymlinksInPath` stats every /// component of the path — the trap `EchoLedger.key` refuses outright for the same reason — and the /// drag's locality question is asked several times per `dropUpdated` and again per lane per body /// evaluation. Resolution therefore happens exactly once per board, where the root is /// (`BoardStore.rootKey`), and every reader downstream compares two strings. Holding one of these /// rather than a `URL` is what makes the re-derivation unwriteable rather than merely avoided. public struct BoardRootKey: Hashable, Sendable { /// The resolved, standardized path — kept for equality and for nothing else. The root's *user* /// spelling stays on `BoardStore.rootURL`, which is what the display-name fallback and every /// derived write path read: canonicalizing there would rename a board reached through a pin. public let path: String public init(_ url: URL) { path = url.resolvingSymlinksInPath().standardizedFileURL.path } } /// 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 the whole point /// of the counter is that it is an *event* the overlay can watch rather than a value it would have /// to diff. /// /// **A value-equal reload does not bump it** (02-architecture.md § Live-reload resilience, /// blessed 2026-07-31): "the store skips the assignment entirely when the fresh snapshot equals /// the current one … so 'costs nothing visible' becomes *costs nothing*". Bumping /// an observed counter is itself a render pass, so the skip has to cover this line too or it /// covers nothing. /// /// The hold tolerates that, and provably: a hold is only ever armed by a drop whose write actually /// rearranged something — `moveCards`/`moveLane` refuse a no-op arrangement *before* they open a /// bracket, so a drop that would land value-equal never writes, never reloads, and its hold is /// already the watchdog's to retire (`CommittedHold.timeout`) exactly as it was before the skip /// existed. Anything whose subject is the *walk* rather than the snapshot reads `landedReloads` /// instead. public private(set) var snapshotGeneration: Int = 0 /// How many tree walks this store has **landed with a snapshot in hand**, ever — bumped by every /// successful reload, whether or not the snapshot it produced was different enough to assign. /// /// The counter for everything whose subject is the *walk*, which is everything that lives outside /// the snapshot and therefore cannot be inferred from it: /// /// - **Comments.** They are not in `BoardModel` at all (01-storage-format.md § Enhanced schema), /// so a foreign comment arriving changes the tree, changes nothing in the snapshot, and must /// still refresh the card window's thread and board search's comment index. Keying either on /// `snapshotGeneration` would make the value-equal skip a freshness bug rather than an /// optimization. /// - **The auto-committer's covering gate** (`GitAutoCommitter.awaitCoveringSnapshot`): what /// covers a flush is a *walk* that started after its writes hit disk, and a completed walk /// covers them whether or not the tree turned out to differ. A gate waiting on the applied /// counter would spin out its whole deadline on any value-equal landing. /// /// A failed reload bumps neither counter: it produced no snapshot, so it covers nothing and /// refreshes nothing — the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly. public private(set) var landedReloads: 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 skips this open consented to** — the decision surface's Skip set, riding the *session* /// (01-storage-format.md § Malformed input, ruled 2026-07-31; the posture settled here rather /// than left to each reload). /// /// The ruling makes skips **per-open decisions, never persisted** — "the next open of a /// still-broken board presents the surface again" — and this is what "per open" means once the /// board is on screen: every reload of this session passes the same set, so the consent the user /// gave when the window opened holds for as long as that window does. The alternative — a reload /// that dropped the set — would blank the board on the first foreign filesystem event, because /// the defect the user tolerated is still on disk and would fail the walk again. /// /// Nothing writes it and nothing persists it: it arrives at `init` from the open that composed /// it, and dies with the store, which is the ruling's own "not a stored preference". /// /// Empty on every ordinary board — no surface, no skips — which is what makes the reload path /// below byte-identical to what it was for every board that opens cleanly. @ObservationIgnored public let skippedPaths: Set /// **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 failure from the last reload that failed, `nil` when the /// board is healthy. `BoardLoadFailure` carries every fail-fast defect that walk found — each one /// 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; the banner shows `primary` and counts the /// rest). 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: BoardLoadFailure? /// 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 /// This board's **identity**, minted once here because minting is the filesystem-touching step /// (`BoardRootKey`). Every locality comparison a drag makes — at hover, at release, and in every /// lane's resting layout — is an `==` against this value. /// /// **Follows the folder, exactly as `rootURL` does** — an absorbed rename or move re-mints it /// (`relocate(to:)`), at one filesystem touch per relocation and none on the hover path. A key /// frozen at open would outlive the place it names, and a *new* board opened at the vacated path /// would then mint the same one: two boards comparing as one, which is a cross-board drag /// silently behaving as a within-board move. The residue of following instead — a rename /// absorbed mid-drag re-reads that drag's locality — is the cosmetic failure of the pair, and /// is what shipped before the key existed. public private(set) var rootKey: BoardRootKey // 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, gitFailures: banners.gitFailures, repositoryUnreadable: banners.isRepositoryUnreadable ) } /// **The unreadable repository, raised or healed** (06-history-undo.md ▸ Rules, "A `.git` that /// isn't a valid repository still reads as git mode — and fails loudly", ruled 2026-07-31) — the /// session's one call for a condition that is the *git state's* truth rather than this store's /// (`HistoryStore.isRepositoryUnreadable`, wired in `AppModel.beginSession`). /// /// It does two things because the ruling asks for two: the row stands on the strip, and it is /// **announced** — "announced per 10-accessibility.md", which makes a standing banner "an /// accessibility element … announced when it appears and when it clears". This condition never /// arrives on a reload (it is detected at open and healed by the paused engine's own re-read), so /// it takes `announceLockChange(from:)`'s path exactly: through `BoardAnnouncer`'s ladder rather /// than posting directly, so the sentence a user hears and the sentence they read off the row are /// one string. /// /// Idempotent, and silent when nothing changed: the 15 s re-read that keeps confirming an /// unreadable repository must not say so every 15 s. public func noteRepositoryUnreadable(_ unreadable: Bool) { guard banners.isRepositoryUnreadable != unreadable else { return } if unreadable { Self.logger.error("this board's git repository could not be opened — history is paused") banners.raiseRepositoryUnreadable() } else { banners.clearRepositoryUnreadable() } var facts = BoardAnnouncer.ReloadFacts() facts.repositoryUnreadableBefore = !unreadable facts.repositoryUnreadableAfter = unreadable announce(BoardAnnouncer.speech(for: facts)) } // 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: The open card windows /// **Which of this board's cards have a card window open right now** — one fact, kept for one /// consumer: the `comments/.trash/` purge's ownership gate (13-native-undo.md ▸ Interaction with /// the trash, ruled 2026-08-06). /// /// > "One carve-out: **an open card window is itself an owner of its card's comment trash** — a /// > retirement firing while the card's window is open defers its purge to that window's close, /// > because entries deleted in the live session are backed by the window's fine steps, which the /// > board-stack inventory cannot see; the close then settles by the same gate." /// /// A window's fine comment steps live on the *window's* stack (`CardWindowUndo`), which /// `history.backedContent` — the board's stack — cannot see. So while a window is open the /// inventory is knowingly incomplete for that card, and the only honest answer to a purge is *not /// yet*. `purgeCommentTrash(inCard:)` is the one reader. /// /// **Deliberately not a reference to the window.** The store is the *board's*, a card window is a /// scene, and 02-architecture.md § Components has the ownership pointing the other way; what the /// gate needs is an identity, not an object. It is also why this is a `Set` rather than a count: /// a card window's identity is `(board, card)` (`CardWindowRef`), so a card has at most one. /// /// A window that never unmarked — a store torn down under an open window — costs nothing beyond a /// purge that did not run, which the next open's residue sweep collects. Convergence, not a leak. @ObservationIgnored public private(set) var openCardWindows: Set = [] /// **A card window opened over this card** — called from the window's open, beside the crash-residue /// sweep it already runs there (`CardWindowHost.openCommentThread`). public func cardWindowDidOpen(inCard id: ItemID) { openCardWindows.insert(id) } /// **A card window over this card is closing** — called from the window's session end, and /// **before** that close's own purge (`CardWindowSession.endSession`). /// /// The order is the whole of the carve-out's honesty: the close is the moment the window stops /// being an owner, so a close that unmarked itself *after* running its purge would defer that /// purge into a no-op and hand the work to nobody. public func cardWindowDidClose(inCard id: ItemID) { openCardWindows.remove(id) } // 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 /// **The last walk's parsed documents** (`BoardLoader.ParseMemo`, blessed 2026-07-31 — /// 02-architecture.md § Live-reload resilience): what the next reload hands the loader so an /// unchanged `index.md` is not re-read and re-parsed for the hundredth time. /// /// **The store is where it lives because the store is what has a *previous* walk.** The loader is /// stateless statics and the memo is an argument to `load`, never hidden state inside it; the /// first load of a board has no memo and walks cold, and every reload after it carries the one /// the walk before produced. /// /// **Bookkeeping, not observable state** — `@ObservationIgnored`, and updated by every successful /// landing whether or not the snapshot was assigned. A value-equal reload skips the assignment /// precisely *because* nothing moved, and the memo it produced is the freshest set of stamps /// there is; dropping it there would make every second reload cold. /// /// A failed reload leaves it alone: there is no `LoadResult` to take one from, and a memo keyed by /// path plus mtime plus size is safe to hold indefinitely — every entry it can no longer answer /// for simply misses. /// /// Internal rather than `private` for `reloadGeneration`'s reason: what the memo holds is the /// claim, and a suite that could not read it would be asserting the effect of something it had to /// take on faith. @ObservationIgnored private(set) var parseMemo = BoardLoader.ParseMemo() /// **What each reload's walk actually read** (`BoardLoader.ParseCounter`) — the seam that makes /// "an unchanged tree re-parses nothing" a test rather than an intention. /// /// `nil` in production, and the walk pays nothing for it there; a suite hangs a counter here and /// reads it after `awaitQuiescence()`. The third seam this type keeps, on `loadBarrier`'s and /// `announce`'s terms exactly: the *rules* are pure functions elsewhere, and what only a seam can /// make assertable is the wiring — here, that the store hands the previous walk's memo to the /// next walk at all. @ObservationIgnored var parseCounter: BoardLoader.ParseCounter? /// 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] = [] /// **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 `BoardLoadFailure` /// — every fail-fast defect the walk found, aggregated — 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 runs here, on whatever actor the caller is on — the storeless shape, and the one /// every test and every synchronous consumer uses. The **window's** open no longer takes it: /// 02-architecture.md's pre-snapshot loading state (ruled 2026-07-29) gave the caller something /// to render before the snapshot exists, which retired this init's old rationale ("the caller /// has nothing to render until it lands"), so `BoardStoreRegistry.acquireOffMain` walks off the /// main actor and hands the result to `init(rootURL:loaded:)` below. /// /// **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 convenience init(rootURL: URL, skipping: Set = []) throws(BoardLoadFailure) { let result = try BoardLoader.load(boardRoot: rootURL, skipping: skipping) self.init(rootURL: rootURL, loaded: result, skipping: skipping) } /// The same board, from a walk that already happened somewhere else. /// /// **The designated init, and the only one that is not a load**: `LoadResult` is `Sendable` and /// `BoardLoader` is stateless statics, so the walk can run anywhere and only this assignment has /// to be on the main actor — exactly the split `startReload` has used for every reload since the /// beginning, now available to the *first* load too. It cannot fail, because failing is the /// walk's job and the walk is over: a caller holding a `LoadResult` holds a board that loaded. /// /// `rootURL` is passed rather than read off `result.model` for the reason the property's own doc /// comment gives — the store's root follows an absorbed rename ahead of the snapshot that will /// carry it. /// /// - Parameter skipping: the skip set the walk was run with, retained for this session's reloads /// (`skippedPaths`). Defaulted to none, which is every board that opened without a decision /// surface. public init(rootURL: URL, loaded result: LoadResult, skipping: Set = []) { self.rootURL = rootURL self.rootKey = BoardRootKey(rootURL) self.snapshot = result.model self.loadWarnings = result.warnings self.defects = result.defects // The opening walk was cold by definition; what it parsed is the first reload's memo. self.parseMemo = result.memo self.skippedPaths = skipping 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?() // **This session's consented skips, on every walk it runs** (`skippedPaths`): the open's // decision stands for the session, so a reload sees the board the user chose to open. let skipping = skippedPaths // **The previous walk's parse, offered to this one** (`parseMemo`). Read on the main actor // and carried into the walk as a value, exactly like `skipping` and the ranker: the loader // stays stateless statics and this reload's memo is a fact about a walk that already // finished, not a channel back into one that has not. let memo = parseMemo let counter = parseCounter reloadInFlight = true Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))") Task.detached(priority: .userInitiated) { [weak self] in // `do throws(BoardLoadFailure)`: without the annotation the `catch` widens to `any Error` // and the loader's typed failure is lost on the way into `Result`. let outcome: Result do throws(BoardLoadFailure) { outcome = .success(try BoardLoader.load( boardRoot: root, skipping: skipping, historyRanker: historyRanker, memo: memo, counter: counter )) } 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, 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, 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 memo rides the reload, not the snapshot** (`parseMemo`): bookkeeping, so it is // taken from every landing — the value-equal ones most of all, since those are exactly // the walks whose stamps are freshest and whose successor would otherwise go cold. parseMemo = result.memo // **The value-equal reconcile skips the assignment entirely** (02-architecture.md // § Live-reload resilience, blessed 2026-07-31): "assigning an equal tree into an // `@Observable` property still costs a render pass, so 'costs nothing visible' becomes // *costs nothing*". // // It is a skip of the **observable assignment and nothing else**. Everything a landing // owes — the memo above, the landing count, the warnings and defects (which describe the // *tree*, not the model, and can move while the model does not: a new stray folder, a // loose file appearing beside an untouched `index.md`), the breakage clearing, the // comment index, the lock reconciliation, the display write-through, the heals, the // announcement and the commit seam — happens below on every landing, equal or not. // // What the guard covers is precisely what an equal model makes vacuous: `transient // .resolve(against:)` re-grounds state against a tree that did not move, and // `recoverFocus` is handed a `.survived` outcome by construction (`focusOutcome` over two // equal models can find nothing vanished), so skipping them changes no state a caller // could observe — only the render pass they would have cost. if result.model != snapshot { // **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 // 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) } } // **The walk landed, assignment or not** — the counter everything outside the snapshot // watches (`landedReloads`). Bumped here rather than beside `snapshotGeneration` above // precisely because it is not about the snapshot. landedReloads += 1 // Outside the transaction, 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. // // Each guarded by its own equality rather than by the snapshot's, because each answers a // different question about the tree: a stray folder appearing changes `loadWarnings` with // the model untouched, and a loose file appearing beside an unedited `index.md` changes // `defects` the same way. An unguarded assignment of an equal array is the render pass // the skip above exists to avoid, one property further down. // // Breakage always heals on a success — it *is* the claim "the last reload failed", and // this one did not. if loadWarnings != result.warnings { loadWarnings = result.warnings } if reloadFailure != nil { reloadFailure = nil } if defects != result.defects { 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 ) -> 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 rootKey = BoardRootKey(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(_ 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. /// /// **Returns whether bytes reached disk**, which is the same question as "is an echo reload /// coming": all three do-nothing paths above answer `false`, and so does a refusal. The edge /// drag's release hold reads it — an overlay that outlived a write nobody made would draw a /// width the board never got (`LaneResizeSession.end`). Discardable because every other caller /// has nothing to do with the answer. @discardableResult public func setLaneWidth(_ id: ItemID, units: Int) -> Bool { 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. @discardableResult public func stepLaneWidths(_ ids: Set, by delta: Int) -> Bool { let changes: [(ItemID, Int)] = snapshot.lanes .filter { ids.contains($0.id) } .map { ($0.id, max(1, LaneLayoutMath.displayUnits(of: $0) + delta)) } return 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. /// /// Returns whether the bracket actually wrote — see `setLaneWidth` for who asks and why. private func writeLaneWidths(_ changes: [(id: ItemID, units: Int)]) -> Bool { let writes: [(folder: URL, units: Int, prior: FieldValue, 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 false } // 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 false } // 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) } } return true } /// 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 public let icon: FieldValue } /// 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, priorIcon: FieldValue )] = 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) -> StyleChange { switch change { case .keep: .keep case let .set(value): field.value == value ? .keep : .set(value) case .remove: field.isMissing ? .keep : .remove } } /// Through `setStyleValue` rather than `set`/`remove` directly, which is this gesture's whole /// answer to `background` being a mapping (BackgroundField.swift): the colour is written *into* /// the key rather than over it, so a board carrying `background: {color: …, image: …}` comes out /// of a colour change still carrying its image. `icon` takes the plain scalar path through the /// same call. private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) { switch change { case .keep: break case let .set(value): document.setStyleValue(value, for: key) case .remove: document.setStyleValue(nil, for: 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, 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, 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) -> [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, 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. /// /// **`index` is counted among the lane's *full* rendered cards — the originals included** — and /// that is the one place a copy's index space differs from a move's (`moveCards` counts among /// the cards its run vacates). It is not a special case bolted on here: a copy's originals stay /// in the source board's resting layout for the whole drag (`DragSession.hiddenMembers`, ruled /// 2026-08-01), so the zones the shadow was resolved against already counted them, and the /// geometry's number is this method's number unrewritten. /// /// The ranks fall out of the same fact. The originals are still on disk holding their ranks, so /// a rank chosen in a gap they had apparently vacated would collide with them the instant they /// reappeared — and with the index counted among them there is no such gap to choose in. public func copyCards(_ ids: Set, 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 placement = 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 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 (`/` and `//`), 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 `` — answering `/.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: `/.trash/` 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 { 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) { 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 `/.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) { 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 `/.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 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) } } // MARK: - The board's noise definition /// Puts the seeded `.gitignore` on a board that has none, and does nothing at all to a board /// that has one — 06-history-undo.md ▸ Repository hygiene's whole scheduling ("a board missing /// the file gains it by scheduled heal at open (the guide-refresh cadence)", re-ruled /// 2026-07-31). /// /// **`refreshAgentGuide()`'s twin, deliberately**, down to the shape of this method: the file is /// a board-root courtesy the app owns, its defect is *presence* rather than anything a tree walk /// could report, and the decision is one `lstat`. What differs is that there is no version to /// compare and no displacement to make — a name already held by anything at all is left exactly /// as it stands (`BoardWriter.seedGitignoreIfAbsent`), including by a directory, because an /// unreadable noise definition costs the board nothing but the exclusions it never had. /// /// **Silent.** No banner row, on the guide's reasoning exactly: a courtesy file the user did not /// create and may not know exists, whose absence changed nothing they can see. Only a genuine I/O /// failure reaches the strip, through `performWrite`. /// /// **The memo is armed before the attempt and cleared on success** (`HealScheduler`'s steps 4 and /// 6), which is what makes a foreign deletion healable: the picture "missing" is restored by the /// delete, and a standing memo would make that deletion the one thing this could not answer — /// while a failing write still gets exactly one attempt per changed picture rather than one per /// reload. "Deletion is answered by re-seeding" is 06's own wording. /// /// **It registers no undo step**, like every heal: nobody asked for it. public func seedGitignore() { let root = rootURL // An empty signature is the engine's resting state and costs no bracket — which matters here // more than anywhere, because this heal runs at every open and reload tail of every board, // and a bracket schedules a reload whether or not anything was written. let signature: Set = IntegrityRules .node(at: root.appendingPathComponent(IntegrityRules.gitignoreFileName)) == nil ? ["gitignore:missing"] : [] heals.run(.missingGitignore, signature: signature, on: self) { () throws(BoardWriteError) -> Void in // The disk re-verify is the Writer's own: a file that appeared under us — another window, // an agent, a clone finishing — makes this a no-op rather than an overwrite. try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: root) } } /// **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 `.gitignore` seed goes before the relocation, and that ordering is load-bearing in the /// same way** (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-31): the seed *is* /// the noise gate the relocation obeys, so a pass that relocated first would act, once, on a /// board whose noise definition it was about to write. The window it closes is narrow by /// construction — the walk that produced this pass's work list ran before either heal, so the /// seed's own patterns cannot filter it until the next load, and both of them name hidden files /// the carve-out never touches anyway — but the order costs nothing and states the dependency. /// /// **The claimed-name displacement goes first, and that ordering is load-bearing**: a card's /// migration mints `/.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() seedGitignore() 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) } // **`landedReloads`, not `snapshotGeneration`**: comments are outside the snapshot entirely // (01-storage-format.md § Enhanced schema), so a comment arriving changes the tree and leaves // the model value-equal — and a value-equal landing skips the snapshot assignment. The // freshness signal an index of window-scoped content can use is therefore the *walk*, which // is what this counter is. commentIndex.update( query: transient.searchQuery, generation: landedReloads, 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, 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, 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, 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() } } }