One style-editor component, anchor-agnostic: a background grid (None well plus the 12 palette colors) and a curated symbol grid (the pathfinder's five-dozen set, leading well removing the icon key for the level default), selection-aware across cards, lanes, and the board itself. Batch edits compute per-dimension state — uniform, mixed (no well selected), or an off-palette value labeled verbatim outside the grids — and choosing a well applies to the whole target set as one write bracket, skipping no-ops per field. The popover tracks its target set live per the freshly ratified rule: targets re-resolve by UUID on every reload, a vanished target leaves the set, an emptied set dismisses the editor, and nothing ever silently retargets to the board. Anchors landing now: Board > Style (Opt-Cmd-S) and the card/lane context menus, which also carry the quick-style recents row (app-wide, persisted, capped at six, None never recorded) and the lane's width control twinning the menu chords. The styling system's other two renders arrive with it: a lane's background paints the C7 top-edge band, the board's paints the window content background — malformed values paint nothing and stay byte-identical on disk. 31 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
1114 lines
61 KiB
Swift
1114 lines
61 KiB
Swift
import Foundation
|
|
import Observation
|
|
import os
|
|
|
|
// MARK: - Vocabulary
|
|
|
|
/// Why a board is refusing writes — the read-only lock's cause, and now the whole of the
|
|
/// vocabulary 02-architecture.md names.
|
|
///
|
|
/// The three cases share one *scope* (§ "The lock's scope") — every mutating command disabled
|
|
/// across every window sharing the store, drops refused, ⌘-drag moves degraded to copies, editor
|
|
/// buffers kept but their debounced saves suspended — and differ only in cause and in **what
|
|
/// clears them**, which is the one thing this enum's cases are actually asked about (see
|
|
/// `BoardStore.land(_:generation:origin:)`). The banner turns a case of this into user-facing
|
|
/// phrasing (§ The banner surface); the store only owns the truth of it.
|
|
public enum ReadOnlyLockReason: Sendable, Equatable {
|
|
/// A reload that followed a bracketed wholesale operation failed, so the last-good snapshot on
|
|
/// screen may describe a tree that no longer exists — after a branch switch, a different branch
|
|
/// entirely. Writes derived from it would land nonsense, so every write is refused until a
|
|
/// reload succeeds (02-architecture.md § Live-reload resilience, "A failed reload after a
|
|
/// bracketed operation locks the board read-only").
|
|
///
|
|
/// **Clears on the next successful reload, whatever its origin** — typically once the offending
|
|
/// file is fixed.
|
|
case bracketedReloadFailed
|
|
|
|
/// The board's root is gone and its bookmark re-resolution found nothing: the volume unmounted,
|
|
/// or the folder was deleted in Finder while the board was open (02-architecture.md §
|
|
/// Write-failure surfacing, "A vanished board root locks the board read-only"). Every write
|
|
/// would land nowhere, so the last-good snapshot stays on screen, read-only.
|
|
///
|
|
/// **Clears on the next successful reload, whatever its origin**: a reload can only succeed if
|
|
/// the root is back, so success *is* the return signal. Pending dirty buffers then save
|
|
/// normally.
|
|
case vanishedRoot
|
|
|
|
/// The board opened somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a
|
|
/// read-only share) or a permission-denied folder (02-architecture.md § Write-failure
|
|
/// surfacing, "An unwritable board location enters the read-only lock at open"). Failing
|
|
/// loudly, specifically, *once* beats letting every gesture fail one at a time.
|
|
///
|
|
/// **Clears only on a successful *reconciling* reload whose writability re-probe passes** —
|
|
/// unlike its two siblings, whose cause a successful reload disproves by itself. A board on a
|
|
/// read-only DMG reloads perfectly all day long; only the probe (§ "Writability re-probes on
|
|
/// every reconciling reload" — wake, activation) can tell that the permission or the mount
|
|
/// actually changed.
|
|
case unwritableLocation
|
|
}
|
|
|
|
/// The refusal `BoardStore.performWrite` throws when the board is locked read-only.
|
|
///
|
|
/// **Deliberately temporary, and deliberately not a `BoardWriteError`.** The lock is a *store*
|
|
/// condition, not a filesystem outcome: nothing was attempted, no path failed, and folding it into
|
|
/// `BoardWriteError.io(message:)` would misrepresent a policy refusal as an I/O error in the one
|
|
/// place — the banner — where the distinction is the whole point. Widening `BoardWriteError` to
|
|
/// carry a refusal case is the banner card's job (02-architecture.md § Write-failure surfacing:
|
|
/// "The operation is a closed enum, not a string"), and it will unify this vocabulary with the
|
|
/// Writer's. Until then this thin error keeps `performWrite`'s honesty at the cost of an untyped
|
|
/// `throws` on its signature.
|
|
public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConvertible {
|
|
case readOnlyLocked(ReadOnlyLockReason)
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case let .readOnlyLocked(reason):
|
|
"the board is read-only (\(reason))"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - BoardStore
|
|
|
|
/// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the
|
|
/// board window renders (02-architecture.md § Layering ▸ Components).
|
|
///
|
|
/// **It enforces the one-way flow — files → watcher → loader → store → views — by never mutating
|
|
/// its snapshot from the write path.** A user action runs the Writer, the Writer touches disk, the
|
|
/// watcher notices, and the change arrives here as a reload like any external edit. The app trusts
|
|
/// its own writes no more than anyone else's; that is what makes external editors and agents
|
|
/// first-class, and it is why there is no `snapshot` setter anywhere below `apply(_:generation:)`.
|
|
///
|
|
/// ### What this type actually owns
|
|
///
|
|
/// 1. **The reload pipeline.** At most one tree walk in flight, off the main actor; signals arriving
|
|
/// during one coalesce into a single follow-up; only the newest result applies.
|
|
/// 2. **The failure rules.** A failed reload never replaces a good snapshot; an ordinary failure
|
|
/// raises the banner condition and leaves editing alone; a failure after a bracketed wholesale
|
|
/// operation locks the board read-only; the next success clears both.
|
|
/// 3. **Transient state across reloads.** `transient.resolve(against:)` runs on every applied
|
|
/// snapshot, re-grounding the selection, the drag, the pending cut, and the new-card placeholder.
|
|
///
|
|
/// ### What it deliberately does not own
|
|
///
|
|
/// The `FolderWatcher` itself — the registry owns one watcher and one store per board and wires
|
|
/// them together (`watcherBrackets`, `handleWatcherEvent(_:)`), so this type can be built and tested
|
|
/// without a filesystem stream. And the transient state itself, which lives in its own container
|
|
/// (`TransientBoardState`) rather than accreting here as fields: this type knows only *when* to
|
|
/// re-resolve it, never what the rules are. That includes the **new-card placeholder** — a
|
|
/// pseudo-card with no disk presence and no UUID, overlaid on the snapshot rather than merged into
|
|
/// it (02-architecture.md § Layering, the one named exception to the one-way flow). Nothing here
|
|
/// makes that awkward: `snapshot` is a pure value swap with no identity assumptions, so an overlay
|
|
/// is simply rendered on top of whatever the latest reload produced.
|
|
@MainActor
|
|
@Observable
|
|
public final class BoardStore {
|
|
|
|
// MARK: Read-side state
|
|
|
|
/// The last good tree walk. Replaced wholesale by a successful reload and **never** by the write
|
|
/// path — see the type's doc comment for why. A failed reload leaves it exactly as it was.
|
|
public private(set) var snapshot: BoardModel
|
|
|
|
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
|
|
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
|
|
/// describe the tree currently on screen.
|
|
public private(set) var loadWarnings: [LoadWarning]
|
|
|
|
/// The standing read-side condition: the error from the last reload that failed, `nil` when the
|
|
/// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path
|
|
/// and what is wrong with it — which is the whole of what the banner needs to render
|
|
/// (02-architecture.md § Live-reload resilience). This is a *condition*, not a one-shot: it
|
|
/// stands until a reload succeeds, and it heals without ceremony when one does.
|
|
public private(set) var reloadFailure: BoardLoadError?
|
|
|
|
/// Non-`nil` while the board refuses writes. Cleared by the next successful reload, per "the
|
|
/// next successful reload clears both the banner and the lock".
|
|
public private(set) var readOnlyLock: ReadOnlyLockReason?
|
|
|
|
public var isReadOnly: Bool { readOnlyLock != nil }
|
|
|
|
/// Everything shared across this board's windows that is **not on disk** — selection, drag
|
|
/// membership, the pending cut, the search query, the new-card placeholder, trash visibility
|
|
/// (02-architecture.md § Changes from Kanban).
|
|
///
|
|
/// **Created with the store and dying with it**, which is what makes its per-open values per-open
|
|
/// without any reset logic: closing the board is the reset. `let`, because it is one container
|
|
/// for the store's whole life — the windows observe *it*, not a slot on this class.
|
|
///
|
|
/// The store's only involvement is `resolve(against:)` on every successful reload; the rules that
|
|
/// call answers live over there.
|
|
public let transient: TransientBoardState
|
|
|
|
/// Where the board is **now**. Follows the folder: a rename or a move absorbed through
|
|
/// `relocate(to:)` updates it, so every URL derived from it — the Writer's paths, card-window
|
|
/// keys, Reveal in Finder — re-derives at the new location (02-architecture.md §
|
|
/// Write-failure surfacing, "A renamed or moved board root follows its file identity").
|
|
///
|
|
/// Observed, deliberately: the window title's folder-name fallback reads this, and a rename in
|
|
/// Finder should be visible in the title bar without anything else being told.
|
|
///
|
|
/// `snapshot.rootURL` is the root the *last successful reload* walked, and therefore lags this
|
|
/// by exactly one reload during an absorption. That is not a second source of truth: the
|
|
/// relocation is always followed by the watcher reattach whose reconciling reload rebuilds the
|
|
/// snapshot at the new root, after which the two agree again.
|
|
public private(set) var rootURL: URL
|
|
|
|
// MARK: Banners
|
|
|
|
/// The board window's banner strip, as a model (02-architecture.md § The banner surface).
|
|
///
|
|
/// **Owned, not injected**, and the reason is the hosting rule: the strip is "hosted by the
|
|
/// window of origin", and a board window's strip has exactly one lifetime — this store's. A
|
|
/// card window (m6) gets its *own* center for its own save, attachment, and raw-source Apply
|
|
/// failures, and re-homes those rows here when it closes; injecting a shared center would
|
|
/// erase precisely that distinction.
|
|
///
|
|
/// It holds only what nothing else does — one-shot write failures, the history suspension,
|
|
/// in-progress operations, passive signposts. The lock and the reload breakage stay this
|
|
/// store's own state and are composed in at render time by `bannerRows`.
|
|
public let banners = BannerCenter()
|
|
|
|
/// The rows the board window's strip renders, in precedence order.
|
|
///
|
|
/// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and
|
|
/// `banners` holds the rest, so a stored array would be a third copy waiting to go stale. The
|
|
/// ordering rule itself lives in `BannerCenter.rows(...)`, which is pure and tested on its own.
|
|
public var bannerRows: [BannerRow] {
|
|
BannerCenter.rows(
|
|
lock: readOnlyLock,
|
|
breakage: reloadFailure,
|
|
oneShots: banners.oneShots,
|
|
suspension: banners.historySuspension,
|
|
operations: banners.operations,
|
|
signposts: banners.signposts
|
|
)
|
|
}
|
|
|
|
// MARK: Wiring
|
|
|
|
/// The watcher's bracket calls, injected rather than owned: the registry holds the watcher and
|
|
/// the store together, and a store that reached into a watcher it did not own could not be
|
|
/// tested without one. `nil` means "no watcher attached" — every operation below still behaves,
|
|
/// it simply has nothing to suspend, which is exactly the shape unit tests want.
|
|
@ObservationIgnored
|
|
public var watcherBrackets: (begin: @MainActor () -> Void, end: @MainActor () -> Void)?
|
|
|
|
/// What to do when the watched root changes identity — injected for the same reason the
|
|
/// brackets are: the response needs the board's **security-scoped bookmark**, and this store
|
|
/// does not own one (the registry does, along with the watcher that must be re-attached and the
|
|
/// last-known path that arms the return detection). A store that reached for a bookmark it did
|
|
/// not hold could not be built or tested without a registry.
|
|
///
|
|
/// `nil` keeps the documented no-op: the last-good snapshot stays on screen, which is what
|
|
/// every other failure path here does, and which is exactly the shape unit tests and any
|
|
/// storeless use want. `BoardStoreRegistry` wires it to its own recovery loop.
|
|
@ObservationIgnored
|
|
public var rootChangeDelegate: (@MainActor () -> Void)?
|
|
|
|
// MARK: Reload machinery
|
|
|
|
/// Monotonic id of the most recently *started* reload — and therefore also the number of tree
|
|
/// walks this store has run since it opened, which is what makes the coalescing rule assertable
|
|
/// from a test rather than merely plausible.
|
|
///
|
|
/// Two rules are expressed as comparisons against it: **only the newest result applies** (a
|
|
/// result whose generation is no longer the current one is dropped), and **the wholesale
|
|
/// expectation binds to a load started after it was armed** (`wholesaleReloadFloor`).
|
|
@ObservationIgnored
|
|
private(set) var reloadGeneration = 0
|
|
|
|
/// Whether a tree walk is running. At most one ever is: a second concurrent walk would buy
|
|
/// nothing (both would produce the same snapshot) and would make "the newest result wins" a race
|
|
/// rather than a rule.
|
|
@ObservationIgnored
|
|
private var reloadInFlight = false
|
|
|
|
/// A reload owed but not started, because one was already running when the signal arrived — a
|
|
/// **flag, not a queue**: any number of signals during one walk coalesce into exactly one
|
|
/// follow-up, because the follow-up is a full tree walk that covers all of them. The origin is
|
|
/// merged by the watcher's own precedence rule (`reconciling > appMediated > foreign`) so a
|
|
/// foreign event folding into a pending reconciling one never downgrades it.
|
|
@ObservationIgnored
|
|
private var pendingReload: WatchOrigin?
|
|
|
|
/// The generation from which a bracketed wholesale operation's expectation applies, or `nil`
|
|
/// when no operation is outstanding.
|
|
///
|
|
/// **Why a floor and not a boolean.** The rule is "the reload that ends this wholesale operation
|
|
/// must succeed, or the board locks" — and a boolean cannot tell that reload apart from one that
|
|
/// was *already in flight* when the operation ended, which observed a tree from before the
|
|
/// operation touched it and therefore proves nothing about the result. Arming stores
|
|
/// `reloadGeneration + 1`: the next walk to be *started*. An in-flight walk's generation is
|
|
/// below the floor and passes through without consuming it; the first walk started at or after
|
|
/// it consumes it — succeeding clears everything as usual, failing engages the lock.
|
|
///
|
|
/// Ordinary (non-wholesale) reload failures never set the lock, because ordinary breakage is
|
|
/// per-file: the snapshot still describes the tree and editing around the broken file is safe.
|
|
@ObservationIgnored
|
|
private var wholesaleReloadFloor: Int?
|
|
|
|
/// Consumers suspended in `awaitQuiescence()`, resumed together the moment nothing is running
|
|
/// and nothing is owed.
|
|
@ObservationIgnored
|
|
private var quiescenceWaiters: [CheckedContinuation<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):
|
|
snapshot = result.model
|
|
loadWarnings = result.warnings
|
|
// 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 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.
|
|
transient.resolve(against: result.model)
|
|
|
|
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.
|
|
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) {
|
|
let clamped = max(1, units)
|
|
guard let lane = snapshot.lanes.first(where: { $0.id == id }),
|
|
LaneLayoutMath.displayUnits(of: lane) != clamped
|
|
else { return }
|
|
|
|
let folder = rootURL.appendingPathComponent(id.rawValue)
|
|
// The closure's signature is spelled out because of `try?`: with the error discarded at the
|
|
// call site Swift stops inferring the typed `throws(BoardWriteError)` and widens it to `any
|
|
// Error`, which `performWrite` will not take. Same wart as the value-returning call sites
|
|
// `performWrite`'s doc comment records, arriving from the other direction.
|
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .resize(title: nil)) { document in
|
|
document.set(FrontmatterKeys.width, to: .int(clamped))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Styling
|
|
|
|
/// One item a style gesture is about to act on: where its `index.md` is, and what the two styled
|
|
/// keys currently say there.
|
|
///
|
|
/// The editor reads these for its per-dimension current-value display (`StyleFieldState.resolve`)
|
|
/// and `applyStyle` reads the *same* values to decide what is a no-op, so the display and the
|
|
/// write can never disagree about what is already on disk. `id` is `nil` for the board root,
|
|
/// which has no `ItemID` by design (see `ItemID`'s doc comment).
|
|
public struct StyleSubject: Sendable, Equatable {
|
|
public let id: ItemID?
|
|
public let folder: URL
|
|
public let background: FieldValue<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: - Lane reorder
|
|
|
|
/// Commits a lane drag: `id` lands at display position `index` among the board's live lanes,
|
|
/// counted **with the dragged lane itself removed** — which is the index
|
|
/// `LaneReorderMath.proposedIndex` produces.
|
|
///
|
|
/// Within-board only. A cross-board lane drag is the locality model's (04-interactions.md ▸
|
|
/// Drag and drop) and belongs to m5's drag card; here source and destination board roots are
|
|
/// the same URL, so `moveItem` takes its same-parent degenerate-reorder path and rewrites
|
|
/// exactly one file — the moved lane's `order`.
|
|
///
|
|
/// **A drag that ends where it started writes nothing**: `index == from` re-inserts the lane in
|
|
/// its own slot, and a no-op must not stamp `modified` or mint a commit — the resize drag's
|
|
/// rule, and for the same reason.
|
|
public func moveLane(_ id: ItemID, toIndex index: Int) {
|
|
let lanes = snapshot.lanes.filter { !$0.isDeleted }
|
|
guard let from = lanes.firstIndex(where: { $0.id == id }) else { return }
|
|
|
|
var remaining = lanes
|
|
remaining.remove(at: from)
|
|
let target = min(max(0, index), remaining.count)
|
|
guard target != from else { return }
|
|
|
|
let root = rootURL
|
|
let folder = root.appendingPathComponent(id.rawValue)
|
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
|
var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target)
|
|
if rank == nil {
|
|
// Compact and place again. Unlike the card case the dragged lane *is* among the
|
|
// renumbered children — it is a real folder on disk — so its fresh rank is dropped
|
|
// from the ladder before the neighbours are consulted.
|
|
try BoardWriter.renumberVisibleChildren(of: root)
|
|
var compacted = Ranks.renumbered(count: lanes.count)
|
|
compacted.remove(at: from)
|
|
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
|
|
}
|
|
guard let rank else { return }
|
|
|
|
_ = try BoardWriter.moveItem(
|
|
at: folder,
|
|
toParent: root,
|
|
sourceBoardRoot: root,
|
|
destinationBoardRoot: root,
|
|
order: rank
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Selection (delegated)
|
|
|
|
// The thin pass-throughs to `transient`, and the only ones.
|
|
//
|
|
// **Conveniences, not a second home.** The selection is the transient state every command site
|
|
// touches — menu validation, ⌫, paste anchoring, Select All — and `store.selection` reads better
|
|
// at each of them than `store.transient.selection` while meaning exactly the same thing. Nothing
|
|
// is stored here: `selection` is computed and the two mutators forward, so there is no second
|
|
// copy to go stale. Drag membership, the pending cut, the query and the editors get no such
|
|
// shortcuts — they have one or two call sites each, and a delegate per field would be the
|
|
// grab-bag reassembling itself on this class.
|
|
//
|
|
// `isEditingInline` earns one for the selection's reason and no other: **every** board-mutating
|
|
// menu item validates against it (04-interactions.md's focused-editor rule), and a rule read
|
|
// that often should read as one word.
|
|
|
|
/// The board's selection, re-resolved against every snapshot this store applies —
|
|
/// `TransientBoardState.selection` under a shorter name.
|
|
public var selection: ItemReferenceSet { transient.selection }
|
|
|
|
/// Whether an inline title editor is open — `TransientBoardState.isEditingInline`, which owns
|
|
/// what it means and why every mutating command reads it.
|
|
public var isEditingInline: Bool { transient.isEditingInline }
|
|
|
|
/// Replaces the selection, and **records the lane it lands in** as the last-active one.
|
|
///
|
|
/// The lane bookkeeping lives here rather than in `TransientBoardState` for one reason: it
|
|
/// takes a snapshot to answer "which lane is that". 04-interactions.md's ⌘N target rule calls
|
|
/// for "the lane that most recently held selection or a creation", and a *card* selection is
|
|
/// its lane holding selection just as much as the lane's own header click is — so both are
|
|
/// noted here, and creation notes itself in `beginPlaceholder`.
|
|
public func select(_ ids: Set<ItemID>, liveness: Liveness) {
|
|
transient.select(ids, liveness: liveness)
|
|
transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|
|
}
|