Files
lanework/Kanban/LiveStore/BoardStore.swift
T
rzen cf87b72092 Implement live search filtering
The board's live title+body filter per 04-interactions.md § Search:

- SearchFilter — a pure value folding the query once (case- and
  diacritic-insensitive substring, locale-stable); title OR body matches,
  attachment filenames never searched; only the literal empty string is
  inactive.
- One universe: the filter threads through SelectionGrammar's order lists
  as a defaulted parameter, so ranges, Select All, arrow navigation, the
  marquee, drop zones, count badges, and the shown trash all read the same
  filtered set by construction; lanes are deliberately never filtered out
  (an emptied lane keeps its slot with a 0 badge). Hidden cards leave the
  selection through the existing constrain primitive, run on every query
  change and as the last line of the reload resolve; the delete successor
  is filtered so ⌫ never selects a hidden neighbour.
- The field: an NSSearchField-backed toolbar item (the toolbar's sole
  default item); Edit ▸ Find ⌘F focuses it through a focused-value
  presentation; stock field-editor dispatch — Return swallowed, Tab is the
  keep-filter path to the board, board commands stay enabled except the
  caret-chord pair, now one shared caretChordsYield expression.
- Escape is staged: clear the non-empty query (focus stays), hand an empty
  field back to the board, clear an active search from board focus —
  before Escape's clear-selection meaning.
- Creating a card clears the search (the placeholder funnel); a rename
  deliberately gets no carve-out; filter reflow rides the content spring
  keyed narrowly on the query.

903 unit tests (24 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 22:49:15 -04:00

2310 lines
128 KiB
Swift

import Foundation
import Observation
import os
// The one UI import in the store layer, and it earns its place: 03-board-ui.md § Motion puts the
// animate-or-snap split on the *reload*, and a reload lands here. `Motion` owns the decision and
// every curve; this file owns nothing but the `withAnimation` around the assignment (see `land`).
import SwiftUI
// MARK: - Vocabulary
/// Why a board is refusing writes — the read-only lock's cause, and now the whole of the
/// vocabulary 02-architecture.md names.
///
/// The three cases share one *scope* (§ "The lock's scope") — every mutating command disabled
/// across every window sharing the store, drops refused, ⌘-drag moves degraded to copies, editor
/// buffers kept but their debounced saves suspended — and differ only in cause and in **what
/// clears them**, which is the one thing this enum's cases are actually asked about (see
/// `BoardStore.land(_:generation:origin:)`). The banner turns a case of this into user-facing
/// phrasing (§ The banner surface); the store only owns the truth of it.
public enum ReadOnlyLockReason: Sendable, Equatable {
/// A reload that followed a bracketed wholesale operation failed, so the last-good snapshot on
/// screen may describe a tree that no longer exists — after a branch switch, a different branch
/// entirely. Writes derived from it would land nonsense, so every write is refused until a
/// reload succeeds (02-architecture.md § Live-reload resilience, "A failed reload after a
/// bracketed operation locks the board read-only").
///
/// **Clears on the next successful reload, whatever its origin** — typically once the offending
/// file is fixed.
case bracketedReloadFailed
/// The board's root is gone and its bookmark re-resolution found nothing: the volume unmounted,
/// or the folder was deleted in Finder while the board was open (02-architecture.md §
/// Write-failure surfacing, "A vanished board root locks the board read-only"). Every write
/// would land nowhere, so the last-good snapshot stays on screen, read-only.
///
/// **Clears on the next successful reload, whatever its origin**: a reload can only succeed if
/// the root is back, so success *is* the return signal. Pending dirty buffers then save
/// normally.
case vanishedRoot
/// The board opened somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a
/// read-only share) or a permission-denied folder (02-architecture.md § Write-failure
/// surfacing, "An unwritable board location enters the read-only lock at open"). Failing
/// loudly, specifically, *once* beats letting every gesture fail one at a time.
///
/// **Clears only on a successful *reconciling* reload whose writability re-probe passes** —
/// unlike its two siblings, whose cause a successful reload disproves by itself. A board on a
/// read-only DMG reloads perfectly all day long; only the probe (§ "Writability re-probes on
/// every reconciling reload" — wake, activation) can tell that the permission or the mount
/// actually changed.
case unwritableLocation
}
/// The refusal `BoardStore.performWrite` throws when the board is locked read-only.
///
/// **Deliberately temporary, and deliberately not a `BoardWriteError`.** The lock is a *store*
/// condition, not a filesystem outcome: nothing was attempted, no path failed, and folding it into
/// `BoardWriteError.io(message:)` would misrepresent a policy refusal as an I/O error in the one
/// place — the banner — where the distinction is the whole point. Widening `BoardWriteError` to
/// carry a refusal case is the banner card's job (02-architecture.md § Write-failure surfacing:
/// "The operation is a closed enum, not a string"), and it will unify this vocabulary with the
/// Writer's. Until then this thin error keeps `performWrite`'s honesty at the cost of an untyped
/// `throws` on its signature.
public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConvertible {
case readOnlyLocked(ReadOnlyLockReason)
public var description: String {
switch self {
case let .readOnlyLocked(reason):
"the board is read-only (\(reason))"
}
}
}
/// What a cross-board drop is doing to the items it carries — the **effective** operation the
/// locality model resolved (04-interactions.md ▸ Drag and drop, settled).
///
/// Not a modifier and not a direction: by the time a store sees one of these the Finder volume
/// model has already been applied — within a board a drag is a move, between boards a copy, ⌥
/// forces copy and ⌘ forces move, each a no-op where it is already the default — and the badge the
/// user was looking at said exactly this. Two cases and no `.none`: a drag with no valid proposal
/// never reaches a commit at all (▸ Drag and drop, rule 2: "release with no valid proposal
/// cancels").
public enum TransferOperation: Sendable, Equatable {
/// Fresh-GUID duplicates land at the drop, originals stay, `created` is kept — a copy is a
/// fork (01-storage-format.md).
case copy
/// A real filesystem move: identity travels, and only the import boundary remints, per folder
/// (01-storage-format.md's per-folder degradation).
case move
}
// MARK: - BoardStore
/// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the
/// board window renders (02-architecture.md § Layering ▸ Components).
///
/// **It enforces the one-way flow — files → watcher → loader → store → views — by never mutating
/// its snapshot from the write path.** A user action runs the Writer, the Writer touches disk, the
/// watcher notices, and the change arrives here as a reload like any external edit. The app trusts
/// its own writes no more than anyone else's; that is what makes external editors and agents
/// first-class, and it is why there is no `snapshot` setter anywhere below `apply(_:generation:)`.
///
/// ### What this type actually owns
///
/// 1. **The reload pipeline.** At most one tree walk in flight, off the main actor; signals arriving
/// during one coalesce into a single follow-up; only the newest result applies.
/// 2. **The failure rules.** A failed reload never replaces a good snapshot; an ordinary failure
/// raises the banner condition and leaves editing alone; a failure after a bracketed wholesale
/// operation locks the board read-only; the next success clears both.
/// 3. **Transient state across reloads.** `transient.resolve(against:)` runs on every applied
/// snapshot, re-grounding the selection, the drag, the pending cut, and the new-card placeholder.
///
/// ### What it deliberately does not own
///
/// The `FolderWatcher` itself — the registry owns one watcher and one store per board and wires
/// them together (`watcherBrackets`, `handleWatcherEvent(_:)`), so this type can be built and tested
/// without a filesystem stream. And the transient state itself, which lives in its own container
/// (`TransientBoardState`) rather than accreting here as fields: this type knows only *when* to
/// re-resolve it, never what the rules are. That includes the **new-card placeholder** — a
/// pseudo-card with no disk presence and no UUID, overlaid on the snapshot rather than merged into
/// it (02-architecture.md § Layering, the one named exception to the one-way flow). Nothing here
/// makes that awkward: `snapshot` is a pure value swap with no identity assumptions, so an overlay
/// is simply rendered on top of whatever the latest reload produced.
@MainActor
@Observable
public final class BoardStore {
// MARK: Read-side state
/// The last good tree walk. Replaced wholesale by a successful reload and **never** by the write
/// path — see the type's doc comment for why. A failed reload leaves it exactly as it was.
public private(set) var snapshot: BoardModel
/// How many snapshots this store has applied, ever — a counter, not a version.
///
/// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold):
/// a drop's overlay stands until "the next snapshot application on that store", and *application*
/// is the event, not change. A reload that produced an identical model still ends the round trip
/// the overlay was covering — comparing `snapshot` values would leave the overlay standing
/// exactly when the write turned out to be a no-op.
public private(set) var snapshotGeneration: Int = 0
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
/// describe the tree currently on screen.
public private(set) var loadWarnings: [LoadWarning]
/// The 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)?
/// 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)?
// 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<Void, Never>] = []
/// 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<LoadResult, BoardLoadError>
do throws(BoardLoadError) {
outcome = .success(try BoardLoader.load(boardRoot: root))
} catch {
outcome = .failure(error)
}
await barrier?()
await self?.apply(outcome, generation: generation, origin: origin)
}
}
/// Lands one walk's result and starts whatever it uncovered.
private func apply(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
reloadInFlight = false
// The stale-apply guard. Serialization means this should not trigger today, but "only the
// newest result applies" is the rule the wholesale floor and every future overlapping-load
// change are written against, so it is enforced rather than assumed.
if generation == reloadGeneration {
land(outcome, generation: generation, origin: origin)
}
startPendingReload()
resumeQuiescenceWaitersIfQuiet()
}
private func land(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
// Consumed here, before the branch, because *both* outcomes end the expectation: a wholesale
// operation gets exactly one reload to prove itself, and a second failure after it is
// ordinary per-file breakage again.
let endsWholesaleOperation: Bool
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
snapshotGeneration += 1
loadWarnings = result.warnings
// The one place transient state is re-grounded. It goes last, after `snapshot` is
// the new one, because a view woken by the snapshot's change must never observe a
// selection still pointing at the old tree.
//
// Inside the transaction deliberately: 03 § Motion has the selection highlight
// "ride whatever transaction is active rather than easing on its own", and a
// re-grounding that landed outside this one would be exactly the independent ease
// that rules out.
transient.resolve(against: result.model)
}
// 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)
// 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?()
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<T>(_ operation: () throws(BoardWriteError) -> T) throws -> T {
if let readOnlyLock {
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
}
watcherBrackets?.begin()
// `defer`, not a trailing call: a Writer operation that fails partway has still touched disk,
// and an unbalanced bracket would leave the watcher suspended for the rest of the session.
defer { watcherBrackets?.end() }
// `do throws(BoardWriteError)`: without the annotation the `catch` widens to `any Error` and
// the Writer's typed error is lost on the way to the banner.
do throws(BoardWriteError) {
return try operation()
} catch {
banners.post(error)
throw error
}
}
/// Runs an operation that rewrites the tree **wholesale** — pull-rebase, branch switch, undo
/// restore (06-history-undo.md, 07-sync-collab.md) — under the bracket, and arms the rule that
/// its closing reload must succeed.
///
/// Two things distinguish this from `performWrite`:
///
/// - The bracket is load-bearing rather than tidy: a reload landing mid-operation would render a
/// half-checked-out tree.
/// - Failure of the closing reload **locks the board** (`ReadOnlyLockReason.bracketedReloadFailed`).
/// After a wholesale change the last-good snapshot may describe a different branch entirely, so
/// writes derived from it would land nonsense — unlike ordinary per-file breakage, where the
/// snapshot still describes the tree.
///
/// The expectation is armed on **every** exit path, a thrown error included: an operation that
/// died partway is precisely the case where the tree's state is unknown and the next reload had
/// better be the authority on it.
///
/// - 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) {
writeLaneWidths([(id, max(1, units))])
}
/// Steps every lane in `ids` one unit — the Increase/Decrease Lane Width menu items' batch
/// (03-board-ui.md § Lane, settled: "they batch over a multi-lane selection — each selected
/// lane steps one unit, one gesture, one commit"; the context-menu stepper stays single-lane
/// by nature and keeps calling `setLaneWidth`).
///
/// Lanes already at the one-unit floor simply hold there on a decrease — the batch is not
/// refused because one member has nowhere to go, matching the style batch's silent-skip shape.
public func stepLaneWidths(_ ids: Set<ItemID>, by delta: Int) {
let changes: [(ItemID, Int)] = snapshot.lanes
.filter { ids.contains($0.id) && !$0.isDeleted }
.map { ($0.id, max(1, LaneLayoutMath.displayUnits(of: $0) + delta)) }
writeLaneWidths(changes)
}
/// The one commit point every width mechanism shares — the edge drag, the context-menu stepper,
/// and the menu items' batch. One `performWrite` bracket whatever the count: one gesture, one
/// app-mediated reload, one commit on git boards (the style batch's rule).
///
/// **A width landing on 1 removes the `width` key** (03-board-ui.md § Lane, settled — the
/// remove-at-default family beside the empty rename's `title` and the None well's
/// `background`): a default lane's frontmatter stays clean whichever mechanism wrote it. A
/// hand-written `width: 1` is legal and preserved until the app itself next edits width — the
/// unchanged-units guard below skips it, so only a real change reaches the remove.
private func writeLaneWidths(_ changes: [(id: ItemID, units: Int)]) {
let writes: [(folder: URL, units: Int)] = 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)
}
guard !writes.isEmpty else { return }
// The closure's signature is spelled out because of `try?`: with the error discarded at the
// call site Swift stops inferring the typed `throws(BoardWriteError)` and widens it to `any
// Error`, which `performWrite` will not take. Same wart as the value-returning call sites
// `performWrite`'s doc comment records, arriving from the other direction.
try? performWrite { () throws(BoardWriteError) -> Void in
for write in writes {
try BoardWriter.updateIndex(inItemFolder: write.folder, operation: .resize(title: nil)) { document in
if write.units == 1 {
document.remove(FrontmatterKeys.width)
} else {
document.set(FrontmatterKeys.width, to: .int(write.units))
}
}
}
}
}
// MARK: - Styling
/// One item a style gesture is about to act on: where its `index.md` is, and what the two styled
/// keys currently say there.
///
/// The editor reads these for its per-dimension current-value display (`StyleFieldState.resolve`)
/// and `applyStyle` reads the *same* values to decide what is a no-op, so the display and the
/// write can never disagree about what is already on disk. `id` is `nil` for the board root,
/// which has no `ItemID` by design (see `ItemID`'s doc comment).
public struct StyleSubject: Sendable, Equatable {
public let id: ItemID?
public let folder: URL
public let background: FieldValue<String>
public let icon: FieldValue<String>
}
/// The live items `target` names, in display order — lanes left to right, each lane's cards top
/// to bottom.
///
/// **Vanished targets are simply absent**, ancestor walk included: a tombstoned card, a card
/// under a tombstoned lane, and an id that names nothing all contribute no subject, which is the
/// same silent skip `commitRename` gives a vanished rename target — "nothing is ever written into
/// a vanished folder". A style editor whose set has emptied dismisses (`StyleEditorSession`), so
/// an empty result is a frame's worth of nothing to show rather than a state to handle.
public func styleSubjects(of target: StyleTarget) -> [StyleSubject] {
switch target {
case .board:
return [StyleSubject(
id: nil,
folder: rootURL,
background: snapshot.background,
icon: snapshot.icon
)]
case let .items(ids):
var subjects: [StyleSubject] = []
for lane in snapshot.lanes 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<String>) -> StyleChange {
switch change {
case .keep: .keep
case let .set(value): field.value == value ? .keep : .set(value)
case .remove: field.isMissing ? .keep : .remove
}
}
private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) {
switch change {
case .keep: break
case let .set(value): document.set(key, to: .string(value))
case .remove: document.remove(key)
}
}
// MARK: - Creation
/// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md).
///
/// **Untitled, and deliberately with no inline editor.** 03-board-ui.md gives lane titles one
/// editing surface — "Inline rename on the header" — and 04-interactions.md gives that surface
/// one entry point, Board ▸ Rename, "since Return on a lane creates a card". Nothing in either
/// doc opens an editor *at creation*, so a new lane appears with the untitled placeholder and
/// the user renames it if they want a name. Titles are optional at every level; a lane with no
/// `title` key is a legitimate resting state, not a half-finished one.
///
/// Like `setLaneWidth`, the rethrow is swallowed: `performWrite` has already posted the banner,
/// and a menu item has no second thing to do about a failure.
public func createLane() {
let root = rootURL
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
/// `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.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
)
}
}
/// The within-board **lane drag**, multi-drag included: `ids` land contiguously at display
/// position `index` among the board's live lanes, counted with the dragged run removed — the
/// index `DropSlotMath.laneSlot` produces.
///
/// `moveLane`'s plural, and it exists rather than a loop over it because "one `performWrite`
/// bracket per gesture whatever the set's size" is load-bearing (DRAG-REORDER.md § The drop
/// commits): one app-mediated reload, and on git boards one commit rather than N.
///
/// The run keeps **board order**, which is the lane level's flatten order — a multi-lane drag has
/// no other relative order to preserve.
///
/// A drag that changes nothing writes nothing, stated as the arrangement rather than as a special
/// case: if the strip would render exactly what it renders now, no rank is rewritten and no
/// commit is minted.
public func moveLanes(_ ids: Set<ItemID>, toIndex index: Int) {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
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
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
if ranks == nil {
// Compact and place again. The dragged lanes *are* among the renumbered children —
// they are real folders on disk — so their fresh rungs are dropped from the ladder
// before the neighbours are consulted, exactly as `moveLane` drops its one.
try BoardWriter.renumberVisibleChildren(of: root)
let compacted = zip(lanes, Ranks.renumbered(count: lanes.count))
.filter { !ids.contains($0.0.id) }
.map(\.1)
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
}
guard let ranks else { return }
for (member, rank) in zip(members, ranks) {
_ = try BoardWriter.moveItem(
at: root.appendingPathComponent(member.id.rawValue, isDirectory: true),
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
}
// 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: which lane holds it *now*.
private struct DraggedCard {
let id: ItemID
let laneID: ItemID
}
/// `ids` narrowed to live cards under live lanes and sorted into **flatten order** — "lane
/// `order` first, then card `order`" (`SelectionGrammar.liveCards`), which is what "drop inserts
/// contiguously in preserved relative order" means and the only order a `Set` cannot supply.
///
/// Members that vanished or flipped liveness since the drag began are simply absent: drag
/// membership is a UUID set that vanished items leave silently (02-architecture.md), and "partial
/// vanishing drops the survivors" is the design's own wording.
private func draggedCards(_ ids: Set<ItemID>) -> [DraggedCard] {
var lanes: [ItemID: ItemID] = [:]
for lane in snapshot.lanes where !lane.isDeleted {
for card in lane.cards where !card.isDeleted {
lanes[card.id] = lane.id
}
}
return SelectionGrammar.liveCards(in: snapshot)
.filter { ids.contains($0) }
.compactMap { id in lanes[id].map { DraggedCard(id: id, laneID: $0) } }
}
/// The within-board card drop: `ids` land contiguously at logical position `index` among
/// `laneID`'s rendered cards, in flatten order.
///
/// **Uniformly `moveItem`, cross-lane members and same-lane ones alike.** A member already in
/// the destination takes the writer's same-parent degenerate path, which rewrites exactly one
/// file — its `order` — and never touches the filesystem; a member arriving from another lane
/// moves its folder and carries the same explicit rank. That is `moveItem`'s own promise ("a
/// drop that lands back in its own lane is the same gesture as one that lands elsewhere"), and
/// leaning on it is what keeps this method from growing two branches that could disagree about
/// ordering.
///
/// The selection is deliberately untouched: every id survives the move, and the cards the user
/// is dragging should stay the cards the user is dragging.
public func moveCards(_ ids: Set<ItemID>, toLane laneID: ItemID, at index: Int) {
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return }
let members = draggedCards(ids)
guard !members.isEmpty else { return }
let rendered = destination.cards.filter { !$0.isDeleted }
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
// makes the two lists differ by construction, so this covers the cross-lane case too.
guard DropSlotMath.applied(rendered.map(\.id), moving: memberIDs, to: target) != rendered.map(\.id)
else { return }
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
if ranks == nil {
// Compact and place again. The renumber assigns in display order over the lane's
// *live* cards, so the compacted ladder lines up one-for-one with `rendered`; the
// members already in this lane are dropped from it before the neighbours are
// consulted, exactly as `moveLane` drops the dragged lane's own rung.
try BoardWriter.renumberVisibleChildren(of: laneFolder)
let compacted = zip(rendered, Ranks.renumbered(count: rendered.count))
.filter { !ids.contains($0.0.id) }
.map(\.1)
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
}
guard let ranks else { return }
for (member, rank) in zip(members, ranks) {
let folder = root
.appendingPathComponent(member.laneID.rawValue, isDirectory: true)
.appendingPathComponent(member.id.rawValue, isDirectory: true)
_ = try BoardWriter.moveItem(
at: folder,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
}
/// The within-board ⌥-drag: fresh-GUID duplicates of `ids` land contiguously at `index` among
/// `laneID`'s rendered cards, **originals untouched** (04-interactions.md ▸ Drag and drop:
/// "originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop").
/// `created` survives because a copy is a fork — `CopyStamps.fork`, the same stamps paste uses.
///
/// **The ranks are placed among the lane's *full* rendered set**, not among the set with the
/// dragged members removed — the one place a copy's arithmetic differs from a move's. The
/// originals are lifted out of the layout for the duration of the drag whatever the effective
/// operation is (⌥ can be pressed and released mid-drag; a layout that re-admitted them on every
/// flip would flap the whole board), but they are still *on disk* holding their ranks, and a
/// rank chosen in the gap they appear to have vacated would collide with them the instant they
/// reappear. So the drop's index is mapped through to the neighbour it names — the card the run
/// lands in front of — and the rank is taken there.
public func copyCards(_ ids: Set<ItemID>, toLane laneID: ItemID, at index: Int) {
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return }
let members = draggedCards(ids)
guard !members.isEmpty else { return }
let rendered = destination.cards.filter { !$0.isDeleted }
let remaining = rendered.filter { !ids.contains($0.id) }
let target = min(max(0, index), remaining.count)
// The resting-layout index, re-read against the layout the originals are still part of.
let placement = target < remaining.count
? (rendered.firstIndex { $0.id == remaining[target].id } ?? rendered.count)
: rendered.count
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: placement, count: members.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: placement,
count: members.count
)
}
guard let ranks else { return }
for (member, rank) in zip(members, ranks) {
let folder = root
.appendingPathComponent(member.laneID.rawValue, isDirectory: true)
.appendingPathComponent(member.id.rawValue, isDirectory: true)
_ = try BoardWriter.copyItem(at: folder, 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.
//
// `sources` are the items' folder URLs in the source board — both boards are open in this app,
// so both roots are already security-scoped and the payload can carry plain URLs. The source
// board root is read back off the path rather than passed alongside: 01-storage-format.md's
// fractal layout fixes the depth (`<root>/<lane>` and `<root>/<lane>/<card>`), so the URL
// already carries it and a second parameter could only ever disagree with the first.
/// The board root a lane folder sits directly under.
nonisolated static func boardRoot(ofLaneFolder folder: URL) -> URL {
folder.deletingLastPathComponent()
}
/// The board root a card folder sits two levels under.
nonisolated static func boardRoot(ofCardFolder folder: URL) -> URL {
folder.deletingLastPathComponent().deletingLastPathComponent()
}
/// Where one arriving item's bytes come from.
///
/// **Two producers, one arrival path.** A drag names folders in the source board; a paste names
/// folders in the clipboard's staging directory — and, when that snapshot is missing or
/// unreadable, the manifest's embedded `index.md` instead (04-interactions.md ▸ Clipboard's
/// staging-less fallback). Modelling the fallback as a second kind of *source* rather than as a
/// second arrival method is what keeps the rank insertion, the tombstone stripping and the
/// `deleted:` clearing stated once: everything downstream of "where do the bytes come from" is
/// identical, and a paste that half-falls-back mixes the two cases inside one bracket.
public enum ItemSource: Sendable, Equatable {
/// A folder on disk — the source board's own, or a staged snapshot of it.
case folder(URL)
/// The manifest's embedded text: the item's `index.md`, and (for a lane) its cards'.
/// Materialized by `BoardWriter.materializeItem`, byte-faithfully.
case text(index: String, cards: [String])
}
/// A cross-board card drop, landing contiguously at `index` among `laneID`'s rendered cards.
///
/// - `.copy` (the default between boards) — `copyItem` per folder: fresh GUIDs throughout,
/// `created` kept, originals untouched. Copies mint by construction, so the import boundary's
/// collision question never arises.
/// - `.move` (⌘-drag) — `moveItem` per folder: identity travels, and the import boundary remints
/// **only** the folders whose UUID the destination board already holds, per folder at the
/// finest grain (01-storage-format.md's per-folder degradation, which is `moveItem`'s own
/// behaviour rather than something this method arranges).
public func receiveCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: false)
}
/// **The clipboard's card arrival** — `receiveCards`/`receiveRestoredCards` with the two axes a
/// paste varies independently (04-interactions.md ▸ Clipboard).
///
/// It is the same commit as a drop's, deliberately: `.copy` materializes from the staged snapshot
/// (or, per entry, from the embedded `index.md`), `.move` is the armed cut's — "the ⌘-drag move
/// path — identity travels" — and `clearingTombstones` is the trash's copy-out rule, "`deleted:`
/// is stripped **at materialization**". A cut is live-only (⌘X is disabled in the trash), so the
/// two flags never both fire; the parameter is not narrowed for that, because which of them is
/// reachable is the *clipboard's* rule and this method's job is only to obey both.
public func receiveCards(
_ sources: [ItemSource],
operation: TransferOperation,
toLane laneID: ItemID,
at index: Int,
clearingTombstones: Bool
) {
receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: clearingTombstones)
}
/// The cross-board half of drag-to-restore (04-interactions.md ▸ The trash): tombstoned rows
/// dropped on *another* board.
///
/// Identical to `receiveCards` but for one extra write per arrival — `deleted:` is removed once
/// the folder is at its destination, so what lands is **live**, "like copying a file out of
/// Finder's Trash". The two cases the design names fall straight out of the operation:
///
/// - `.copy` (the default) — a live copy lands here and the tombstoned original stays in the
/// source board's trash, exactly as ⌘C out of the trash behaves.
/// - `.move` (⌘-drag) — the true cross-board restore-move: the tombstone leaves the source
/// board entirely, ordinary cross-board move semantics apply, and `deleted:` is cleared at the
/// destination.
///
/// The strip is a second `updateIndex` rather than a flag on the first because the arrival's
/// `order` is written by `copyItem`/`moveItem` before this store has a folder to point at, and
/// because `restoreItem` is already the one expression in the app for "remove the `deleted:`
/// key" — the bytes are never rewritten any other way.
public func receiveRestoredCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: true)
}
private func receive(
_ sources: [ItemSource],
operation: TransferOperation,
toLane laneID: ItemID,
at index: Int,
clearingTombstones: Bool
) {
guard !sources.isEmpty,
let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
else { return }
let rendered = destination.cards.filter { !$0.isDeleted }
let target = min(max(0, index), rendered.count)
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: target,
count: sources.count
)
}
guard let ranks else { return }
for (source, rank) in zip(sources, ranks) {
guard let arrived = try Self.materialize(
source,
operation: operation,
intoParent: laneFolder,
destinationBoardRoot: root,
sourceBoardRoot: Self.boardRoot(ofCardFolder:),
order: rank
) else { continue }
guard clearingTombstones else { continue }
try BoardWriter.restoreItem(at: laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true))
}
}
}
/// One arrival's materialization — the two `ItemSource` kinds crossed with the two operations,
/// in the one place both the card path and the lane path can share.
///
/// **`.move` of a `.text` source is unreachable and answers `nil`.** A move needs a folder whose
/// identity travels, and the only producer of text sources is the clipboard's fallback, which is
/// a *copy* by construction (04-interactions.md ▸ Clipboard: an armed cut moves the surviving
/// originals, and a cut that cannot find them is void). Skipping is the standing posture for an
/// arrival that names nothing — the same silent no-op every other drop commit gives a source that
/// has gone.
private static func materialize(
_ source: ItemSource,
operation: TransferOperation,
intoParent parent: URL,
destinationBoardRoot: URL,
sourceBoardRoot: (URL) -> URL,
order: Double
) throws(BoardWriteError) -> ItemID? {
switch (source, operation) {
case let (.folder(folder), .copy):
return try BoardWriter.copyItem(at: folder, toParent: parent, order: order, stamps: .fork)
case let (.folder(folder), .move):
return try BoardWriter.moveItem(
at: folder,
toParent: parent,
sourceBoardRoot: sourceBoardRoot(folder),
destinationBoardRoot: destinationBoardRoot,
order: order
).id
case let (.text(index, cards), .copy):
return try BoardWriter.materializeItem(
inParent: parent,
indexText: index,
children: cards,
order: order
)
case (.text, .move):
return nil
}
}
/// A cross-board lane drop, landing contiguously at `stripIndex` among this board's live lanes.
///
/// The two operations differ in exactly one place beyond identity, and it is 04-interactions.md
/// ▸ Drag and drop's rule:
///
/// - `.copy` — "Lanes copy cards and all", then **the copy strips tombstoned cards**: the copy
/// transfers content, and trash isn't content (09-templates.md's instantiation precedent — a
/// board isn't born with trash). The tombstoned originals stay recoverable in the source
/// board. `copyItem` offers no filter hook — it copies the tree verbatim by design, which is
/// what makes attachments and strays arrive byte-identical — so the strip is the line after
/// (`BoardWriter.stripTombstonedChildren`), pointed at a folder minted seconds earlier.
/// - `.move` — "A ⌘-drag *move* carries them whole — the folder moves as-is, and they land in
/// the destination's trash." Nothing to arrange: a move never reads below its root, so the
/// tombstones travel and the destination's trash quasi-lane renders them.
///
/// 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, clearingTombstones: false)
}
/// **The clipboard's lane arrival** — `receiveLanes` with the staging-less fallback and the
/// trash's copy-out rule folded in (04-interactions.md ▸ Clipboard, ▸ The trash).
///
/// The two operations keep their drag semantics exactly, because 04 says they are the same
/// semantics: "a pasted *copy* takes fresh GUIDs throughout and **strips tombstoned cards**; a
/// cut-paste is the ⌘-drag move — the folder moves whole, tombstoned cards landing in the
/// destination's trash". `clearingTombstones` adds the one thing a drag never asks for: a lane
/// *entry* copied out of the trash arrives live, its own `deleted:` removed once it is at the
/// destination — the lane-level twin of `receiveRestoredCards`, and the reason the strip runs
/// first is that the two writes touch different files and the strip's target list is the one that
/// must be read before anything is rewritten.
public func receiveLanes(
_ sources: [ItemSource],
operation: TransferOperation,
at stripIndex: Int,
clearingTombstones: Bool
) {
guard !sources.isEmpty else { return }
let root = rootURL
let rendered = snapshot.lanes.filter { !$0.isDeleted }
let target = min(max(0, stripIndex), rendered.count)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: root)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: target,
count: sources.count
)
}
guard let ranks else { return }
for (source, rank) in zip(sources, ranks) {
guard let arrived = try Self.materialize(
source,
operation: operation,
intoParent: root,
destinationBoardRoot: root,
sourceBoardRoot: Self.boardRoot(ofLaneFolder:),
order: rank
) else { continue }
let laneFolder = root.appendingPathComponent(arrived.rawValue, isDirectory: true)
// The strip belongs to the copy alone: "a move never reads below its root, so the
// tombstones travel and the destination's trash quasi-lane renders them".
if operation == .copy {
try BoardWriter.stripTombstonedChildren(of: laneFolder)
}
if clearingTombstones {
try BoardWriter.restoreItem(at: laneFolder)
}
}
}
}
// MARK: - Finder file drops
// 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).
/// Copies `urls` into `cardID`'s `attachments/` — the drop-on-a-card half.
///
/// **Liveness is ancestor-walked** (`liveItem`): a card under a tombstoned lane renders nowhere,
/// so it is as gone as a deleted one, 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 tombstoned 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.liveItem(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)
}
}
/// 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-on-empty-space half.
///
/// **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).
///
/// **Partial failure is honest, and leaves no half-made card.** The batch stops at the first file
/// that cannot be imported — a folder rather than a file, an unreadable source — 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 && !$0.isDeleted })
else { return }
let rendered = lane.cards.filter { !$0.isDeleted }
let target = min(max(0, index), rendered.count)
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(
amongVisible: rendered.map(\.order), at: target, count: urls.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count), at: target, count: urls.count)
}
guard let ranks else { return }
for (url, rank) in zip(urls, ranks) {
// Create then place, `commitPlaceholder`'s pair: the Writer's create appends after the
// visible siblings by contract, and the rank rides its same-parent degenerate reorder
// inside this same bracket rather than widening the create's signature.
let id = try BoardWriter.createCard(inLane: laneFolder, title: Self.cardTitle(forFile: url))
let folder = laneFolder.appendingPathComponent(id.rawValue, isDirectory: true)
do throws(BoardWriteError) {
_ = try BoardWriter.moveItem(
at: folder,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
_ = try BoardWriter.importAttachments([url], intoCard: folder)
} catch {
try? FileManager.default.removeItem(at: folder)
throw error
}
}
}
}
/// 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 tombstoned
/// selection ("⌥⌘↑/⌥⌘↓ are inert *on* tombstoned cards"), 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.liveness == .live,
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 && !$0.isDeleted })
else { return nil }
let rendered = lane.cards.filter { !$0.isDeleted }.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.filter { !$0.isDeleted }
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) })
try? performWrite { () throws(BoardWriteError) -> Void in
var ladder = orders
if !Self.isStrictlyAscending(orders) {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
// The renumber assigns in display order, so the compacted ladder lines up one-for-one
// with `rendered` — the same alignment `commitPlaceholder` relies on.
ladder = Ranks.renumbered(count: rendered.count)
}
for (destination, id) in plan.ordering.enumerated() {
guard let origin = positions[id], origin != destination else { continue }
let rank = ladder[destination]
try BoardWriter.updateIndex(
inItemFolder: laneFolder.appendingPathComponent(id.rawValue, isDirectory: true),
// `.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))
}
}
}
}
/// 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 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 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 tombstone 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), and neither do `putBack`/`deleteImmediately` — the item merely changed
/// sides, or nothing survives on either.
public func delete(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
// The successor is drawn from what the lane 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: snapshot, filter: searchFilter)
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.deleteItem(at: folder)
}
}
if let successor {
select([successor], liveness: .live, anchor: successor, head: successor)
} else {
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<ItemID>) {
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<ItemID>) {
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,
/// at the drop position** — `deleted:` removed and `order` set (03-board-ui.md § Trash,
/// 04-interactions.md ▸ The trash: "dropping a tombstoned card into one of its own board's lanes
/// restores it at the drop position").
///
/// `index` is the drag model's own index — a position among the destination lane's rendered
/// cards, which the tombstoned card is by definition not among (DRAG-REORDER.md § The drop
/// commits). Clamped, like every other drop commit.
///
/// **Cross-lane is 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 the move, carrying the
/// rank. Doing it the other way round would have the second call chasing a folder the first one
/// had already relocated.
///
/// **Same lane never moves a folder**, so it is one write: the key removed and, only when the
/// drop actually names a different rank than the card already carries, the `order` beside it.
/// That guard is what preserves the position-perfect restore the trash's pure-view design pays
/// for — a row dropped back where its recorded order already puts it comes back *exactly* there,
/// with no rank invented for it and no neighbour disturbed.
///
/// **Within-board only.** A drop on another board follows the locality model instead
/// (`receiveRestoredCards`): a live copy by default with the tombstoned original staying put,
/// and ⌘-drag forcing the true restore-move.
///
/// 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, at index: Int) {
restoreByDrag(cardIDs: [cardID], intoLane: laneID, at: index)
}
/// The multi-drag face of the same gesture: N trash rows dropped over one live lane land
/// contiguously at `index`, **in drop order** — the order the payload carries, which is the
/// trash's own sorted order (03-board-ui.md § Trash ▸ Contents).
///
/// It is the plural rather than a loop over the singular for `moveLanes`' reason: one
/// `performWrite` bracket per gesture whatever the set's size, so one reload and one commit
/// (DRAG-REORDER.md § The drop commits). Every rule above holds per member — the same-lane
/// single write, the cross-lane restore-then-move pair, and the recorded-`order` preservation,
/// which is what makes a row dropped back where it already belonged come back exactly there.
public func restoreByDrag(cardIDs: [ItemID], intoLane laneID: ItemID, at index: Int) {
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return }
// A row exists only for a card whose *own* flag is set under a *live* lane — the trash's
// absolute ancestor walk (03-board-ui.md § Trash ▸ Contents). Anything else in the list names
// nothing draggable and is silently skipped, which is this method's standing posture.
let rows: [(laneID: ItemID, card: Card)] = cardIDs.compactMap { id in
guard let source = snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == id && $0.isDeleted }
}),
let card = source.cards.first(where: { $0.id == id })
else { return nil }
return (source.id, card)
}
guard !rows.isEmpty else { return }
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
let rendered = destination.cards.filter { !$0.isDeleted }
let target = min(max(0, index), rendered.count)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: rows.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: target,
count: rows.count
)
}
guard let ranks else { return }
for (row, rank) in zip(rows, ranks) {
let cardFolder = TrashModel.ItemPath(laneID: row.laneID, cardID: row.card.id).folder(under: root)
guard row.laneID != laneID else {
try BoardWriter.updateIndex(
inItemFolder: cardFolder,
// `.restore(title: nil)`: `updateIndex` enriches it off the document it reads.
operation: .restore(title: nil)
) { document in
document.remove(FrontmatterKeys.deleted)
if rank != row.card.order {
document.set(FrontmatterKeys.order, to: .double(rank))
}
}
continue
}
try BoardWriter.restoreItem(at: cardFolder)
_ = try BoardWriter.moveItem(
at: cardFolder,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
}
// MARK: - Selection (delegated)
// The thin pass-throughs to `transient`, and the only ones.
//
// **Conveniences, not a second home.** The selection is the transient state every command site
// touches — menu validation, ⌫, paste anchoring, Select All — and `store.selection` reads better
// at each of them than `store.transient.selection` while meaning exactly the same thing. Nothing
// is stored here: `selection` is computed and the two mutators forward, so there is no second
// copy to go stale. Drag membership, the pending cut, the query and the editors get no such
// shortcuts — they have one or two call sites each, and a delegate per field would be the
// grab-bag reassembling itself on this class.
//
// `isEditingInline` earns one for the selection's reason and no other: **every** board-mutating
// menu item validates against it (04-interactions.md's focused-editor rule), and a rule read
// that often should read as one word.
/// The board's selection, re-resolved against every snapshot this store applies —
/// `TransientBoardState.selection` under a shorter name.
public var selection: ItemReferenceSet { transient.selection }
/// Whether an inline title editor is open — `TransientBoardState.isEditingInline`, which owns
/// what it means and why every mutating command reads it.
///
/// **The search field is not one of these**, and that is 04-interactions.md § Search's settled
/// dispatch rule as one absence: "the field is a *control*, not a content editor — the
/// focused-editor lockdown does not apply", so board menu commands stay enabled and act on the
/// selection while the user types a query. The narrow exception — the caret chords — is the
/// menu items' own (`caretChordsYield`), not this flag's.
public var isEditingInline: Bool { transient.isEditingInline }
// MARK: - The live search filter
/// The search field's text (04-interactions.md § Search), and **the one funnel every change to
/// it goes through**.
///
/// The setter is where the filter's one consequence lives: narrowing the query narrows what the
/// board shows, and "hidden cards leave the selection" — so every write re-applies
/// `TransientBoardState.constrainToSearch(in:)` against the current snapshot. Putting it here
/// rather than at the field's binding is what makes it true for Escape's clear and for any later
/// caller equally, without either having to remember.
///
/// **The equality guard is not an optimisation.** `NSSearchField` reports its text on events
/// that did not change it, and a re-entrant assignment during a live keystroke would re-run the
/// constraint (harmlessly) and re-fire observation (not harmlessly — the strip's animated
/// transaction is keyed on this value).
public var searchQuery: String {
get { transient.searchQuery }
set {
guard newValue != transient.searchQuery else { return }
transient.searchQuery = newValue
transient.constrainToSearch(in: snapshot)
}
}
/// The query as the predicate, for the selection grammar's order lists — read wherever the board
/// asks "what is on the board, in what order" (`SelectionGrammar.order`).
public var searchFilter: SearchFilter { SearchFilter(query: transient.searchQuery) }
/// Clears the search — **Escape's middle step** (04 § Search's staged Escape: "with *board*
/// focus and an active search, one press clears the search and the full board returns"), and the
/// search field's own Escape in a non-empty field.
///
/// Widening, so it constrains nothing; it goes through the setter anyway so there is exactly one
/// place the query is written on the store.
public func clearSearch() {
searchQuery = ""
}
/// Replaces the selection, and **records the lane it lands in** as the last-active one.
///
/// The lane bookkeeping lives here rather than in `TransientBoardState` for one reason: it
/// takes a snapshot to answer "which lane is that". 04-interactions.md's ⌘N target rule calls
/// for "the lane that most recently held selection or a creation", and a *card* selection is
/// its lane holding selection just as much as the lane's own header click is — so both are
/// noted here, and creation notes itself in `beginPlaceholder`.
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) {
transient.select(ids, liveness: liveness, 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,
liveness: outcome.selection.liveness,
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 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 — 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.liveness == .trashed, !selection.isEmpty,
let kind = SelectionGrammar.kind(of: selection, in: snapshot) {
apply(Set(SelectionGrammar.trashEntries(of: kind, in: snapshot, filter: filter)), on: .trashed)
return
}
apply(Set(SelectionGrammar.liveCards(in: snapshot, filter: filter)), on: .live)
}
/// Select All's storage half: an empty universe clears rather than storing an empty set, and the
/// anchor and head are kept only while they are still inside what was selected.
private func apply(_ ids: Set<ItemID>, on side: Liveness) {
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, liveness: side, 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 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<ItemID>, 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()
}
}
}