import Foundation import Observation import os // The one UI import in the store layer, and it earns its place: 03-board-ui.md § Motion puts the // animate-or-snap split on the *reload*, and a reload lands here. `Motion` owns the decision and // every curve; this file owns nothing but the `withAnimation` around the assignment (see `land`). import SwiftUI // MARK: - Vocabulary /// Why a board is refusing writes — the read-only lock's cause, and now the whole of the /// vocabulary 02-architecture.md names. /// /// The three cases share one *scope* (§ "The lock's scope") — every mutating command disabled /// across every window sharing the store, drops refused, ⌘-drag moves degraded to copies, editor /// buffers kept but their debounced saves suspended — and differ only in cause and in **what /// clears them**, which is the one thing this enum's cases are actually asked about (see /// `BoardStore.land(_:generation:origin:)`). The banner turns a case of this into user-facing /// phrasing (§ The banner surface); the store only owns the truth of it. public enum ReadOnlyLockReason: Sendable, Equatable { /// A reload that followed a bracketed wholesale operation failed, so the last-good snapshot on /// screen may describe a tree that no longer exists — after a branch switch, a different branch /// entirely. Writes derived from it would land nonsense, so every write is refused until a /// reload succeeds (02-architecture.md § Live-reload resilience, "A failed reload after a /// bracketed operation locks the board read-only"). /// /// **Clears on the next successful reload, whatever its origin** — typically once the offending /// file is fixed. case bracketedReloadFailed /// The board's root is gone and its bookmark re-resolution found nothing: the volume unmounted, /// or the folder was deleted in Finder while the board was open (02-architecture.md § /// Write-failure surfacing, "A vanished board root locks the board read-only"). Every write /// would land nowhere, so the last-good snapshot stays on screen, read-only. /// /// **Clears on the next successful reload, whatever its origin**: a reload can only succeed if /// the root is back, so success *is* the return signal. Pending dirty buffers then save /// normally. case vanishedRoot /// The board opened somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a /// read-only share) or a permission-denied folder (02-architecture.md § Write-failure /// surfacing, "An unwritable board location enters the read-only lock at open"). Failing /// loudly, specifically, *once* beats letting every gesture fail one at a time. /// /// **Clears only on a successful *reconciling* reload whose writability re-probe passes** — /// unlike its two siblings, whose cause a successful reload disproves by itself. A board on a /// read-only DMG reloads perfectly all day long; only the probe (§ "Writability re-probes on /// every reconciling reload" — wake, activation) can tell that the permission or the mount /// actually changed. case unwritableLocation } /// The refusal `BoardStore.performWrite` throws when the board is locked read-only. /// /// **Deliberately temporary, and deliberately not a `BoardWriteError`.** The lock is a *store* /// condition, not a filesystem outcome: nothing was attempted, no path failed, and folding it into /// `BoardWriteError.io(message:)` would misrepresent a policy refusal as an I/O error in the one /// place — the banner — where the distinction is the whole point. Widening `BoardWriteError` to /// carry a refusal case is the banner card's job (02-architecture.md § Write-failure surfacing: /// "The operation is a closed enum, not a string"), and it will unify this vocabulary with the /// Writer's. Until then this thin error keeps `performWrite`'s honesty at the cost of an untyped /// `throws` on its signature. public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConvertible { case readOnlyLocked(ReadOnlyLockReason) public var description: String { switch self { case let .readOnlyLocked(reason): "the board is read-only (\(reason))" } } } // MARK: - BoardStore /// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the /// board window renders (02-architecture.md § Layering ▸ Components). /// /// **It enforces the one-way flow — files → watcher → loader → store → views — by never mutating /// its snapshot from the write path.** A user action runs the Writer, the Writer touches disk, the /// watcher notices, and the change arrives here as a reload like any external edit. The app trusts /// its own writes no more than anyone else's; that is what makes external editors and agents /// first-class, and it is why there is no `snapshot` setter anywhere below `apply(_:generation:)`. /// /// ### What this type actually owns /// /// 1. **The reload pipeline.** At most one tree walk in flight, off the main actor; signals arriving /// during one coalesce into a single follow-up; only the newest result applies. /// 2. **The failure rules.** A failed reload never replaces a good snapshot; an ordinary failure /// raises the banner condition and leaves editing alone; a failure after a bracketed wholesale /// operation locks the board read-only; the next success clears both. /// 3. **Transient state across reloads.** `transient.resolve(against:)` runs on every applied /// snapshot, re-grounding the selection, the drag, the pending cut, and the new-card placeholder. /// /// ### What it deliberately does not own /// /// The `FolderWatcher` itself — the registry owns one watcher and one store per board and wires /// them together (`watcherBrackets`, `handleWatcherEvent(_:)`), so this type can be built and tested /// without a filesystem stream. And the transient state itself, which lives in its own container /// (`TransientBoardState`) rather than accreting here as fields: this type knows only *when* to /// re-resolve it, never what the rules are. That includes the **new-card placeholder** — a /// pseudo-card with no disk presence and no UUID, overlaid on the snapshot rather than merged into /// it (02-architecture.md § Layering, the one named exception to the one-way flow). Nothing here /// makes that awkward: `snapshot` is a pure value swap with no identity assumptions, so an overlay /// is simply rendered on top of whatever the latest reload produced. @MainActor @Observable public final class BoardStore { // MARK: Read-side state /// The last good tree walk. Replaced wholesale by a successful reload and **never** by the write /// path — see the type's doc comment for why. A failed reload leaves it exactly as it was. public private(set) var snapshot: BoardModel /// 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 standing read-side condition: the error from the last reload that failed, `nil` when the /// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path /// and what is wrong with it — which is the whole of what the banner needs to render /// (02-architecture.md § Live-reload resilience). This is a *condition*, not a one-shot: it /// stands until a reload succeeds, and it heals without ceremony when one does. public private(set) var reloadFailure: BoardLoadError? /// Non-`nil` while the board refuses writes. Cleared by the next successful reload, per "the /// next successful reload clears both the banner and the lock". public private(set) var readOnlyLock: ReadOnlyLockReason? public var isReadOnly: Bool { readOnlyLock != nil } /// Everything shared across this board's windows that is **not on disk** — selection, drag /// membership, the pending cut, the search query, the new-card placeholder, trash visibility /// (02-architecture.md § Changes from Kanban). /// /// **Created with the store and dying with it**, which is what makes its per-open values per-open /// without any reset logic: closing the board is the reset. `let`, because it is one container /// for the store's whole life — the windows observe *it*, not a slot on this class. /// /// The store's only involvement is `resolve(against:)` on every successful reload; the rules that /// call answers live over there. public let transient: TransientBoardState /// Where the board is **now**. Follows the folder: a rename or a move absorbed through /// `relocate(to:)` updates it, so every URL derived from it — the Writer's paths, card-window /// keys, Reveal in Finder — re-derives at the new location (02-architecture.md § /// Write-failure surfacing, "A renamed or moved board root follows its file identity"). /// /// Observed, deliberately: the window title's folder-name fallback reads this, and a rename in /// Finder should be visible in the title bar without anything else being told. /// /// `snapshot.rootURL` is the root the *last successful reload* walked, and therefore lags this /// by exactly one reload during an absorption. That is not a second source of truth: the /// relocation is always followed by the watcher reattach whose reconciling reload rebuilds the /// snapshot at the new root, after which the two agree again. public private(set) var rootURL: URL // MARK: Banners /// The board window's banner strip, as a model (02-architecture.md § The banner surface). /// /// **Owned, not injected**, and the reason is the hosting rule: the strip is "hosted by the /// window of origin", and a board window's strip has exactly one lifetime — this store's. A /// card window (m6) gets its *own* center for its own save, attachment, and raw-source Apply /// failures, and re-homes those rows here when it closes; injecting a shared center would /// erase precisely that distinction. /// /// It holds only what nothing else does — one-shot write failures, the history suspension, /// in-progress operations, passive signposts. The lock and the reload breakage stay this /// store's own state and are composed in at render time by `bannerRows`. public let banners = BannerCenter() /// The rows the board window's strip renders, in precedence order. /// /// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and /// `banners` holds the rest, so a stored array would be a third copy waiting to go stale. The /// ordering rule itself lives in `BannerCenter.rows(...)`, which is pure and tested on its own. public var bannerRows: [BannerRow] { BannerCenter.rows( lock: readOnlyLock, breakage: reloadFailure, oneShots: banners.oneShots, suspension: banners.historySuspension, operations: banners.operations, signposts: banners.signposts ) } // MARK: Wiring /// The watcher's bracket calls, injected rather than owned: the registry holds the watcher and /// the store together, and a store that reached into a watcher it did not own could not be /// tested without one. `nil` means "no watcher attached" — every operation below still behaves, /// it simply has nothing to suspend, which is exactly the shape unit tests want. @ObservationIgnored public var watcherBrackets: (begin: @MainActor () -> Void, end: @MainActor () -> Void)? /// What to do when the watched root changes identity — injected for the same reason the /// brackets are: the response needs the board's **security-scoped bookmark**, and this store /// does not own one (the registry does, along with the watcher that must be re-attached and the /// last-known path that arms the return detection). A store that reached for a bookmark it did /// not hold could not be built or tested without a registry. /// /// `nil` keeps the documented no-op: the last-good snapshot stays on screen, which is what /// every other failure path here does, and which is exactly the shape unit tests and any /// storeless use want. `BoardStoreRegistry` wires it to its own recovery loop. @ObservationIgnored public var rootChangeDelegate: (@MainActor () -> Void)? // MARK: Reload machinery /// Monotonic id of the most recently *started* reload — and therefore also the number of tree /// walks this store has run since it opened, which is what makes the coalescing rule assertable /// from a test rather than merely plausible. /// /// Two rules are expressed as comparisons against it: **only the newest result applies** (a /// result whose generation is no longer the current one is dropped), and **the wholesale /// expectation binds to a load started after it was armed** (`wholesaleReloadFloor`). @ObservationIgnored private(set) var reloadGeneration = 0 /// Whether a tree walk is running. At most one ever is: a second concurrent walk would buy /// nothing (both would produce the same snapshot) and would make "the newest result wins" a race /// rather than a rule. @ObservationIgnored private var reloadInFlight = false /// A reload owed but not started, because one was already running when the signal arrived — a /// **flag, not a queue**: any number of signals during one walk coalesce into exactly one /// follow-up, because the follow-up is a full tree walk that covers all of them. The origin is /// merged by the watcher's own precedence rule (`reconciling > appMediated > foreign`) so a /// foreign event folding into a pending reconciling one never downgrades it. @ObservationIgnored private var pendingReload: WatchOrigin? /// The generation from which a bracketed wholesale operation's expectation applies, or `nil` /// when no operation is outstanding. /// /// **Why a floor and not a boolean.** The rule is "the reload that ends this wholesale operation /// must succeed, or the board locks" — and a boolean cannot tell that reload apart from one that /// was *already in flight* when the operation ended, which observed a tree from before the /// operation touched it and therefore proves nothing about the result. Arming stores /// `reloadGeneration + 1`: the next walk to be *started*. An in-flight walk's generation is /// below the floor and passes through without consuming it; the first walk started at or after /// it consumes it — succeeding clears everything as usual, failing engages the lock. /// /// Ordinary (non-wholesale) reload failures never set the lock, because ordinary breakage is /// per-file: the snapshot still describes the tree and editing around the broken file is safe. @ObservationIgnored private var wholesaleReloadFloor: Int? /// Consumers suspended in `awaitQuiescence()`, resumed together the moment nothing is running /// and nothing is owed. @ObservationIgnored private var quiescenceWaiters: [CheckedContinuation] = [] /// 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)? private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store") // MARK: - Init /// Opens a board: one synchronous tree walk, and **no fallback if it fails**. /// /// Fail-fast is the *initial-load* contract (01-storage-format.md § Malformed input): there is /// no last-good snapshot to keep on screen yet, so a broken board throws its `BoardLoadError` /// instead of constructing a store that would have nothing to show. Every rule below — the /// banner, the lock, "a failed reload never replaces a good snapshot" — exists only *because* /// this one succeeded. /// /// The walk is synchronous because the caller has nothing to render until it lands; the /// asynchronous, off-main pipeline starts with the first reload. public init(rootURL: URL) throws(BoardLoadError) { let result = try BoardLoader.load(boardRoot: rootURL) self.rootURL = rootURL self.snapshot = result.model self.loadWarnings = result.warnings self.reloadFailure = nil self.readOnlyLock = nil self.transient = TransientBoardState() } // MARK: - Inbound signals /// The single inbound signal — everything the outside world tells this store arrives here. /// /// Deliberately one door: the watcher, a test, and (later) the registry's wake/activation /// reconciliation all speak the same two-case vocabulary, so there is exactly one place where a /// filesystem change becomes a reload. public func handleWatcherEvent(_ event: WatcherEvent) { switch event { case let .treeChanged(origin): requestReload(origin) case .rootChanged: // Delegated, never guessed at. The settled response is to re-resolve the board's // security-scoped bookmark and either `reattach(to:)` the watcher at the new location — // a rename absorbed with no banner and no lock — or enter the vanished-root read-only // lock (02-architecture.md § Write-failure surfacing). Both halves need a bookmark this // store does not own, and a guess here would be a *wrong* guess: treating a rename as a // vanish would lock a board that is merely somewhere else. // // No reload is started either way. The delegate's two outcomes both end in one — // `reattach(to:)`'s reconciling reload at the re-resolved root, or the vanished-root // lock's eventual clearance when the root returns — and a reload fired from here would // walk a path that just stopped being the board. guard let rootChangeDelegate else { Self.logger.debug("rootChanged ignored — no delegate is wired (storeless use)") return } rootChangeDelegate() } } /// Starts a reload, or banks one if a walk is already running. private func requestReload(_ origin: WatchOrigin) { guard !reloadInFlight else { pendingReload = WatchOrigin.merged(pendingReload, origin) return } startReload(origin) } // MARK: - The reload pipeline /// Runs one tree walk **off the main actor** and applies its result on it. /// /// Off-main because a board of any size is a directory walk plus a YAML parse per item, and the /// whole point of the value-type snapshot is that this work can happen anywhere: `BoardLoader` /// is stateless statics and `LoadResult` is `Sendable`, so the only thing that has to be on the /// main actor is the assignment at the end. `Task.detached` rather than `Task { }`: a task /// created inside a `@MainActor` method inherits that isolation and would run the walk on the /// main actor — the exact thing this is avoiding. /// /// `origin` is not branched on: every reload is a full tree walk, so no origin is less safe than /// another. It is carried because the *policy* around a reload differs later — a `.reconciling` /// sweep re-probes writability (02-architecture.md § Write-failure surfacing) — and because it is /// the one thing that makes a reload's provenance legible in the log. private func startReload(_ origin: WatchOrigin) { reloadGeneration += 1 let generation = reloadGeneration let root = rootURL let barrier = loadBarrier reloadInFlight = true Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))") Task.detached(priority: .userInitiated) { [weak self] in // `do throws(BoardLoadError)`: without the annotation the `catch` widens to `any Error` // and the loader's typed error is lost on the way into `Result`. let outcome: Result do throws(BoardLoadError) { outcome = .success(try BoardLoader.load(boardRoot: root)) } catch { outcome = .failure(error) } await barrier?() await self?.apply(outcome, generation: generation, origin: origin) } } /// Lands one walk's result and starts whatever it uncovered. private func apply(_ outcome: Result, 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 if let floor = wholesaleReloadFloor, generation >= floor { wholesaleReloadFloor = nil endsWholesaleOperation = true } else { endsWholesaleOperation = false } switch outcome { case let .success(result): // **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 loadWarnings = result.warnings // The one place transient state is re-grounded. It goes last, after `snapshot` is // the new one, because a view woken by the snapshot's change must never observe a // selection still pointing at the old tree. // // Inside the transaction deliberately: 03 § Motion has the selection highlight // "ride whatever transaction is active rather than easing on its own", and a // re-grounding that landed outside this one would be exactly the independent ease // that rules out. transient.resolve(against: result.model) } // Outside it, equally deliberately — 03 keys transactions narrowly, and the banner // conditions are not board structure. A lock clearing is not a thing the strip should // slide out on the back of a card being deleted. // // Breakage always heals on a success — it *is* the claim "the last reload failed", and // this one did not. reloadFailure = nil clearLockIfDisproved(by: origin) case let .failure(error): // `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload // never replaces a good snapshot, and state over a snapshot that did not change has // nothing to re-resolve against. Nothing is performed here either, for the same reason — // and neither is anything on the lock paths below (`enterVanishedRootLock`, // `enterUnwritableLock`, `relocate`), none of which touch the snapshot at all. A board // that did not change has no motion to show. reloadFailure = error // `readOnlyLock == nil` rather than an unconditional assignment: a root that vanished // mid-bracket already raised its own, truer lock, and 02-architecture.md is explicit // that the bracket's final reload becomes a no-op there rather than a redundant // failure. Overwriting `.vanishedRoot` with `.bracketedReloadFailed` would also break // the clearing rules — the vanished root's lock must not clear on a reload that never // proves the root came back. if endsWholesaleOperation, readOnlyLock == nil { readOnlyLock = .bracketedReloadFailed } Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)") } } private func startPendingReload() { guard !reloadInFlight, let origin = pendingReload else { return } pendingReload = nil startReload(origin) } // MARK: - The lock's clearing rules /// Clears the read-only lock if this successful reload actually disproved its cause. /// /// **Reason-specific, because the causes are not alike** (02-architecture.md § Write-failure /// surfacing): /// /// - `.bracketedReloadFailed` and `.vanishedRoot` are *disproved by the success itself*. The /// first says "the tree could not be re-read after a wholesale change" and the second says /// "the root is gone" — a completed tree walk at the root contradicts both, whatever origin /// asked for it, so any success clears them. /// - `.unwritableLocation` is not. A board on a read-only DMG reloads flawlessly forever; /// loading proves nothing about writing. It clears only when a **reconciling** reload — wake, /// activation, a stream re-creation — re-probes writability and finds it changed ("Writability /// re-probes on every reconciling reload, so a fixed permission or rewritable remount clears /// the lock without ceremony"). /// /// `FileManager.isWritableFile(atPath:)` is `access(2)` on the root directory: a real-uid /// permission question asked of the filesystem, which is what makes it answer correctly for /// both halves of the case — a read-only *mount* and a permission-denied *folder*. /// /// Deliberately **one-way**: a reconciling reload that finds the root unwritable does not /// *raise* the lock. Arming it is the open flow's job (`enterUnwritableLock()`), and inferring /// a lock from a probe here would be a policy decision this milestone was not asked to make. private func clearLockIfDisproved(by origin: WatchOrigin) { switch readOnlyLock { case nil: break case .bracketedReloadFailed, .vanishedRoot: readOnlyLock = nil case .unwritableLocation: guard origin == .reconciling, FileManager.default.isWritableFile(atPath: rootURL.path) else { return } Self.logger.debug("writability re-probe passed — the unwritable-location lock clears") readOnlyLock = nil } } // MARK: - Root identity and explicit locks /// Points this store at the board's new location, absorbing a rename or a move. /// /// Called by the registry when a `.rootChanged` re-resolved the board's bookmark somewhere else /// (02-architecture.md § Write-failure surfacing, "A renamed or moved board root follows its /// file identity"): the board the app has open is the *file*, not the path string, so this is /// bookkeeping rather than an event — **no banner, no lock, nothing was ever wrong**. /// /// It deliberately starts **no reload**. The caller follows this with the watcher's /// `reattach(to:)`, whose reconciling reload is the one that rebuilds the snapshot at the new /// root; a reload fired from here would be a second walk racing that one for no gain. Until it /// lands, `rootURL` is the new location and `snapshot.rootURL` is still the old — see /// `rootURL`'s note. public func relocate(to newRoot: URL) { guard newRoot != rootURL else { return } Self.logger.debug("board root relocated; Writer URLs now derive from the new location") rootURL = newRoot } /// Raises the vanished-root read-only lock — the registry's call, after bookmark re-resolution /// found nothing and the last-known path is not there either. /// /// Overwrites whatever lock was standing: a root that is gone is the most current and most /// specific truth about why writes are refused, and its clearing rule (a successful reload, /// which can only happen if the root came back) is strictly the safer one to be holding. public func enterVanishedRootLock() { Self.logger.error("board root vanished — entering the read-only lock") readOnlyLock = .vanishedRoot } /// Raises the unwritable-location read-only lock — the open flow's call, after probing the /// root's writability (02 § "An unwritable board location enters the read-only lock at open"). /// Public now so the vocabulary and its clearing rule ship together; m4's open flow is the /// producer. /// /// Does **not** overwrite a standing lock: a board that is already locked for a vanished root /// or a failed bracketed reload has a cause that outranks "and it is also read-only", and both /// of those clear on a success that would then re-probe anyway. public func enterUnwritableLock() { guard readOnlyLock == nil else { return } Self.logger.error("board location is not writable — entering the read-only lock") readOnlyLock = .unwritableLocation } // 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) } watcherBrackets?.begin() // `defer`, not a trailing call: a Writer operation that fails partway has still touched disk, // and an unbalanced bracket would leave the watcher suspended for the rest of the session. defer { watcherBrackets?.end() } // `do throws(BoardWriteError)`: without the annotation the `catch` widens to `any Error` and // the Writer's typed error is lost on the way to the banner. do throws(BoardWriteError) { return try operation() } catch { banners.post(error) throw error } } /// Runs an operation that rewrites the tree **wholesale** — pull-rebase, branch switch, undo /// restore (06-history-undo.md, 07-sync-collab.md) — under the bracket, and arms the rule that /// its closing reload must succeed. /// /// Two things distinguish this from `performWrite`: /// /// - The bracket is load-bearing rather than tidy: a reload landing mid-operation would render a /// half-checked-out tree. /// - Failure of the closing reload **locks the board** (`ReadOnlyLockReason.bracketedReloadFailed`). /// After a wholesale change the last-good snapshot may describe a different branch entirely, so /// writes derived from it would land nonsense — unlike ordinary per-file breakage, where the /// snapshot still describes the tree. /// /// The expectation is armed on **every** exit path, a thrown error included: an operation that /// died partway is precisely the case where the tree's state is unknown and the next reload had /// better be the authority on it. /// /// - 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(_ 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. wholesaleReloadFloor = reloadGeneration + 1 watcherBrackets?.end() } do { try operation() } catch let error as BoardWriteError { // Same honesty rule as `performWrite`, applied to the one error type the banner has // phrasing for. A wholesale operation is usually git's (m7), whose own failure // vocabulary is not `BoardWriteError` and whose surfacing — the suspended-history // condition, the in-progress row swapping for an error — is the committer's to drive; // but a `BoardWriteError` escaping here is an ordinary failed write and may no more // bypass the strip than one from `performWrite`. banners.post(error) throw error } } // MARK: - Lane width /// Writes a lane's width — the one commit point both width mechanisms share (03-board-ui.md § /// Lane): the right-edge drag's release and the stepper's ⌥⌘→/⌥⌘← both land here, and they differ /// only in what they did to the *window* on the way (the drag grew it, the stepper did not). /// /// **The value written is an integer, replacing whatever was there.** `width` is a lenient field /// on the read side — missing, malformed, zero and negative all render as one unit /// (`LaneLayoutMath.displayUnits`) with the author's bytes left alone — but an explicit width /// change is the user overwriting that value, so the Writer puts a plain integer in its place /// (01-storage-format.md § Frontmatter). /// /// Three ways this does nothing, all deliberate: a count below 1 clamps to 1 (a lane spans at /// least one unit), an id that is not in the snapshot is ignored (the lane vanished under the /// gesture — the reload that removed it is the authority), and a count already equal to what the /// lane displays writes nothing (a drag that ends where it started must not stamp `modified` or /// mint a git commit). /// /// Failures are already the banner's: `performWrite` posts every `BoardWriteError` before it /// rethrows, so the rethrow is swallowed here rather than propagated to a gesture that has no /// second thing to do about it. The lane stays at its old width, which is the truth — nothing was /// written. public func setLaneWidth(_ id: ItemID, units: Int) { let clamped = max(1, units) guard let lane = snapshot.lanes.first(where: { $0.id == id }), LaneLayoutMath.displayUnits(of: lane) != clamped else { return } let folder = rootURL.appendingPathComponent(id.rawValue) // 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. try? performWrite { () throws(BoardWriteError) -> Void in try BoardWriter.updateIndex(inItemFolder: folder, operation: .resize(title: nil)) { document in document.set(FrontmatterKeys.width, to: .int(clamped)) } } } // 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 where !lane.isDeleted { 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 !card.isDeleted && 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.isDeleted && lane.cards.contains { !$0.isDeleted && ids.contains($0.id) } } return namesACard ? .card : .lane } } /// Writes a style gesture — the **one** commit point every anchor shares (03-board-ui.md § /// Styling ▸ Controls: "One component, one behavior, three anchors"), and the quick-style recents /// row with them. /// /// **One bracket, whatever the target set's size.** "Choosing a well applies to the whole /// selection — one gesture, one commit on git boards" (§ Controls), so every target's `index.md` /// is rewritten inside a single `performWrite`: the churn rounds back as one app-mediated reload, /// and the auto-committer (m7) sees one operation rather than N. /// /// **No-ops are skipped per dimension and per target** — `setLaneWidth`'s rule, for its reason: a /// well clicked twice, or a batch where half the cards are already that colour, must not stamp /// `modified` or mint a commit on the items that were already right. A dimension whose value is /// already what the gesture asks contributes nothing; a target both of whose dimensions are /// no-ops is dropped entirely; and a gesture that changes nothing anywhere never opens the /// bracket at all. /// /// **`iconColor` is not a parameter, and that is the design**: it is "resolved — schema yes, /// control no" (§ Capabilities). The field renders when hand-written and the app offers no /// control for it, so there is nothing here to pass. /// /// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here /// like every other gesture with no second thing to do. A batch that fails partway leaves the /// targets written before it written — the Writer is "atomic per filesystem operation, not per /// gesture" — and the reload shows the true state, which is the honest one. public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) { let edits: [(folder: URL, background: StyleChange, icon: StyleChange)] = styleSubjects(of: target) .compactMap { subject in let background = Self.effective(background, against: subject.background) let icon = Self.effective(icon, against: subject.icon) guard background != .keep || icon != .keep else { return nil } return (folder: subject.folder, background: background, icon: icon) } guard !edits.isEmpty else { return } try? performWrite { () throws(BoardWriteError) -> Void in for edit in edits { // `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a // failure names the item by the title it still has (see `WriteOperation.style`). try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in Self.apply(edit.background, to: FrontmatterKeys.background, in: &document) Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document) } } } } /// `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 } } private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) { switch change { case .keep: break case let .set(value): document.set(key, to: .string(value)) case .remove: document.remove(key) } } // MARK: - Creation /// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md). /// /// **Untitled, and deliberately with no inline editor.** 03-board-ui.md gives lane titles one /// editing surface — "Inline rename on the header" — and 04-interactions.md gives that surface /// one entry point, Board ▸ Rename, "since Return on a lane creates a card". Nothing in either /// doc opens an editor *at creation*, so a new lane appears with the untitled placeholder and /// the user renames it if they want a name. Titles are optional at every level; a lane with no /// `title` key is a legitimate resting state, not a half-finished one. /// /// Like `setLaneWidth`, the rethrow is swallowed: `performWrite` has already posted the banner, /// and a menu item has no second thing to do about a failure. public func createLane() { let root = rootURL try? performWrite { () throws(BoardWriteError) -> Void in _ = try BoardWriter.createLane(inBoard: root, title: nil) } } // 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 && !$0.isDeleted }) else { transient.discardPlaceholder() return nil } let laneFolder = rootURL.appendingPathComponent(placeholder.laneID.rawValue) let visible = lane.cards.filter { !$0.isDeleted } // `nil` means "append", which is `createCard`'s own default — so the anchored case is the // only one that needs a rank at all. let position = Self.insertionIndex(after: placeholder.anchorCardID, among: visible) let created = try? performWrite { () throws(BoardWriteError) -> ItemID in let id = try BoardWriter.createCard(inLane: laneFolder, title: title) guard let position else { return id } // The rank is computed here rather than passed to `createCard` because the create's // contract is "append after the visible siblings" and widening it would give every // caller a position to think about. The reposition rides the Writer's own same-parent // degenerate reorder — "a move whose destination is the item's current parent degrades // to a plain reorder" — inside the *same* `performWrite`, so the pair rounds back as // one app-mediated reload rather than showing the card at the bottom for a frame. var rank = Ranks.insertionRank(amongVisible: visible.map(\.order), at: position) if rank == nil { // Midpoint precision exhausted between the anchor and its neighbour // (01-storage-format.md § Ordering). Compact, then place against the fresh ranks: // the new card is not among the renumbered siblings — it was appended past them — // so the compacted ladder lines up one-for-one with `visible`. try BoardWriter.renumberVisibleChildren(of: laneFolder) rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: visible.count), at: position) } guard let rank else { return id } _ = try BoardWriter.moveItem( at: laneFolder.appendingPathComponent(id.rawValue), toParent: laneFolder, sourceBoardRoot: rootURL, destinationBoardRoot: rootURL, order: rank ) return id } guard let created else { transient.discardPlaceholder() return nil } transient.commitPlaceholder(expecting: created) 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." /// Liveness is effective — a card under a tombstoned lane is vanished too. /// - **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.liveItem(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) } 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 BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in if let newTitle { document.set(FrontmatterKeys.title, to: .string(newTitle)) } else { document.remove(FrontmatterKeys.title) } } } } /// Where a live item lives and what it is currently called, or `nil` when the id names nothing /// the board renders. /// /// **Effective liveness, ancestor-walked** — the same rule `CardWindowHost.cardWindowFate` /// applies to a card window and `ItemReferenceSet` applies to the selection: a card under a /// tombstoned lane renders nowhere, so it is as gone as a deleted one. 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 liveItem( _ id: ItemID, in snapshot: BoardModel ) -> (laneID: ItemID, cardID: ItemID?, title: String?)? { for lane in snapshot.lanes where !lane.isDeleted { if lane.id == id { return (laneID: lane.id, cardID: nil, title: lane.title.value) } if let card = lane.cards.first(where: { $0.id == id && !$0.isDeleted }) { return (laneID: lane.id, cardID: card.id, title: card.title.value) } } return nil } // 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 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 BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in if let newTitle { document.set(FrontmatterKeys.title, to: .string(newTitle)) } else { document.remove(FrontmatterKeys.title) } } } } // 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 /// `LaneReorderMath.proposedIndex` 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.filter { !$0.isDeleted } 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) try? performWrite { () throws(BoardWriteError) -> Void in var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target) if rank == nil { // Compact and place again. Unlike the card case the dragged lane *is* among the // renumbered children — it is a real folder on disk — so its fresh rank is dropped // from the ladder before the neighbours are consulted. try BoardWriter.renumberVisibleChildren(of: root) var compacted = Ranks.renumbered(count: lanes.count) compacted.remove(at: from) rank = Ranks.insertionRank(amongVisible: compacted, at: target) } guard let rank else { return } _ = try BoardWriter.moveItem( at: folder, toParent: root, sourceBoardRoot: root, destinationBoardRoot: root, order: rank ) } } // MARK: - The trash /// Whether physically removing an item on this board destroys the only copy of it — and /// therefore whether Delete Immediately stands an alert between one keystroke and unrecoverable /// deletion (03-board-ui.md § Trash, "Delete Immediately confirms 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 it acts immediately, since the content // remains reachable in history" (06-history-undo.md's delete-never-forgets). 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 } /// Tombstones the current selection — File ▸ Delete ⌘⌫ and its plain-⌫ grammar twin /// (04-interactions.md ▸ The map, 11-command-nexus.md). /// /// A convenience over `delete(_:)` so the two call sites cannot disagree about *what* the /// command acts on. public func deleteSelection() { delete(selection.ids) } /// Tombstones every live item in `ids` — cards or lanes, in one bracket. /// /// **One `performWrite` whatever the set's size**, matching the style batch's rule and for its /// reason: one gesture, one app-mediated reload, and (on git boards) one commit rather than N. /// A lane's tombstone rewrites only the lane's own `index.md` — hiding the subtree is the /// renderer's ancestor walk, not a stored flag (`BoardWriter.deleteItem`). /// /// **Tombstoned ids are silently skipped**, not refused: the paths are resolved on the live side /// only, so a selection the next reload will drop writes nothing rather than re-stamping a /// `deleted:` that is already there. An empty resolution never opens the bracket at all. /// /// The selection is **cleared**, not moved to a successor. 04-interactions.md ▸ The map asks for /// the Finder-style successor sibling ("repeated ⌫ walks down a lane"), which needs the /// navigation order the keyboard grammar defines — that is m5's card. Clearing is the honest /// interim: what was selected renders nowhere now, and the reload's resolve rule would empty the /// set a moment later anyway. public func delete(_ ids: Set) { let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) } guard !folders.isEmpty else { return } try? performWrite { () throws(BoardWriteError) -> Void in for folder in folders { try BoardWriter.deleteItem(at: folder) } } // m5-keyboard: the successor-selection grammar replaces this line. clearSelection() } /// Put Back: removes `deleted:` from every tombstoned item in `ids`, in one bracket /// (03-board-ui.md § Trash). /// /// **Restore fidelity is perfect because nothing ever moved** — the item re-enters the visible /// set at its recorded `order` among its current siblings, and the folder is exactly where it /// has been all along (`BoardWriter.restoreItem`). /// /// **Putting back a lane splits its contents by flag for free.** The write is the lane's own /// `index.md` and nothing else, so cards hidden *with* the lane return with it while cards /// carrying their own `deleted:` stay tombstoned — and their rows reappear in the trash, which /// is exactly the two-step recovery the design settled on. /// /// A card whose lane is itself tombstoned cannot be reached this way through the UI (it has no /// row — `TrashModel.entries`), but the path resolution admits it, and the outcome is the pure /// view's honest one: the key is removed, and the card still renders nowhere because its lane /// is still tombstoned. Putting the lane back then shows it. /// /// The selection is deliberately left alone: the restored items flip liveness, and the reload's /// resolve rule ejects them from a `.trashed` set as a vanish — the same silent shrink an /// external restore would produce. public func putBack(_ ids: Set) { let folders = TrashModel.paths(of: ids, on: .trashed, in: snapshot).map { $0.folder(under: rootURL) } guard !folders.isEmpty else { return } try? performWrite { () throws(BoardWriteError) -> Void in for folder in folders { try BoardWriter.restoreItem(at: folder) } } } /// Delete Immediately ⌥⌘⌫: physically removes every tombstoned item in `ids` (03-board-ui.md § /// Trash), in one bracket. /// /// **The confirmation is not here.** Whether the loss is real is `purgeIsUnrecoverable`'s /// question and the alert is the window's; a store method that put up its own dialog could not /// be driven from a test, and the same purge is reached by two surfaces (the menu item and the /// trash row's context menu) that must not each grow their own copy of the rule. /// /// Purging a **lane** takes its whole folder — every card inside it, tombstoned or not. That is /// what the lane entry subsuming its subtree means on disk. public func deleteImmediately(_ ids: Set) { let folders = TrashModel.paths(of: ids, on: .trashed, in: snapshot).map { $0.folder(under: rootURL) } guard !folders.isEmpty else { return } try? performWrite { () throws(BoardWriteError) -> Void in for folder in folders { try BoardWriter.purgeItem(at: folder) } } // Nothing the set named exists any more, on either side of the boundary — unlike Put Back, // where the items merely changed sides, there is no vanish for the reload to notice on the // trashed side that would not equally be a vanish here. clearSelection() } /// Empty Trash… ⇧⌘⌫: purges **every** tombstone on the board, in one bracket. /// /// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the targets come /// from the snapshot, never from the 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. /// /// Cards carrying their own `deleted:` under a tombstoned lane go too, without being listed: /// they live inside the lane folder this removes (`TrashModel.emptyTrashTargets`). public func emptyTrash() { let folders = TrashModel.emptyTrashTargets(in: snapshot).map { $0.folder(under: rootURL) } guard !folders.isEmpty else { return } try? performWrite { () throws(BoardWriteError) -> Void in for folder in folders { try BoardWriter.purgeItem(at: folder) } } clearSelection() } /// Drag-to-restore: a tombstoned card row dropped over a live lane comes back **into that lane** /// (03-board-ui.md § Trash, 04-interactions.md ▸ The trash). /// /// Two writes in **one bracket**, and the order is load-bearing: `restoreItem` first — the folder /// is still where the trash row said it was — then, only when the destination differs, the move. /// Doing it the other way round would have the second call chasing a folder the first one had /// already relocated. /// /// **Same lane is a plain Put Back**: the key is removed and nothing else is touched, so the card /// returns at its recorded `order` rather than at the bottom. "Folder moved only if the /// destination lane differs" is the design's own wording, and the position-perfect restore is the /// point of the trash being a pure view. /// // m5-drag: two things arrive with the drag card's `DropSlot` port. (1) The **positional** drop — // the design restores "at the drop position", and the append below is the interim; the rank comes // from `Ranks.insertionRank` over the destination's visible cards, exactly as `commitPlaceholder` // computes it. (2) **Cross-board locality** — a drop on another board is a live *copy* by // default with the tombstoned original staying put, and ⌘-drag forces the true restore-move. // Both need the drag controller's target vocabulary; this method is deliberately within-board. /// /// Silent no-ops, all of them the reload being the authority rather than this gesture: a /// destination lane that is gone or tombstoned, a card that is not a trash row (its own flag /// unset, or its lane tombstoned so it has no row to drag), and an id that names nothing. public func restoreByDrag(cardID: ItemID, intoLane laneID: ItemID) { guard snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }), let source = snapshot.lanes.first(where: { lane in !lane.isDeleted && lane.cards.contains { $0.id == cardID && $0.isDeleted } }) else { return } let root = rootURL let cardFolder = TrashModel.ItemPath(laneID: source.id, cardID: cardID).folder(under: root) let destination = root.appendingPathComponent(laneID.rawValue, isDirectory: true) let crossesLanes = source.id != laneID try? performWrite { () throws(BoardWriteError) -> Void in try BoardWriter.restoreItem(at: cardFolder) guard crossesLanes else { return } // `order: nil` is the Writer's own append — computed over the destination's *visible* // siblings, which the arriving card is not yet among. _ = try BoardWriter.moveItem( at: cardFolder, toParent: destination, sourceBoardRoot: root, destinationBoardRoot: root, order: nil ) } } // 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. public var isEditingInline: Bool { transient.isEditingInline } /// 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, liveness: Liveness, anchor: ItemID? = nil) { transient.select(ids, liveness: liveness, anchor: anchor) 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 ) guard !outcome.selection.isEmpty else { clearSelection() return } // The anchor is 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. select(outcome.selection.ids, liveness: outcome.selection.liveness, anchor: outcome.anchor) } /// **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 on the trashed side, and it still names a row — the exact conditions under /// which "all" could mean anything but the board. It then selects every trash row **of the /// selection's kind**, because 04 ▸ The trash's card-entries-XOR-lane-entries rule binds a /// wholesale selection as tightly as it binds a click. A trashed selection naming nothing (a /// foreign Put Back, a purge) falls through to the board rather than selecting the trash /// wholesale on a guess. /// /// The anchor **survives if it is still in the set** and is dropped otherwise: Select All is not /// a click, so it names no new origin, but it has no business discarding one that is still /// standing inside what it selected. /// // m5-search: "filter-respecting, like every surface" (04 ▸ The map). The universe here is // `SelectionGrammar`'s order lists, which is where the filter threads in — one change, and both // this command and every ⇧-range narrow together. public func selectAll() { if transient.isTrashVisible, selection.liveness == .trashed, !selection.isEmpty, let kind = SelectionGrammar.kind(of: selection, in: snapshot) { apply(Set(SelectionGrammar.trashEntries(of: kind, in: snapshot)), on: .trashed) return } apply(Set(SelectionGrammar.liveCards(in: snapshot)), on: .live) } /// Select All's storage half: an empty universe clears rather than storing an empty set, and the /// anchor is kept only while it is still inside what was selected. private func apply(_ ids: Set, on side: Liveness) { guard !ids.isEmpty else { clearSelection() return } let anchor = transient.selectionAnchor.flatMap { ids.contains($0) ? $0 : nil } select(ids, liveness: side, anchor: anchor) } /// 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 live lane selects /// itself; live cards select their lane, but only when they all share one (a cross-lane /// selection has no single home to remember). 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 where !lane.isDeleted { let names = ids.contains(lane.id) || lane.cards.contains { !$0.isDeleted && 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() } } }