The window-title widget arrives as a leading titlebar accessory — a quiet chevron on board windows only, installed and removed by the window controller's own attach lifecycle — anchoring the one board-level surface as a transient popover (the board window deliberately grows no toolbar item for it). Inside: board rename editing frontmatter title only (the folder is never renamed; an empty commit removes the key and the window title falls back to the folder name), the embedded shared style editor permanently targeting the board, and the labeled Git section that this milestone only reserves — a mode-none explanation and a disabled stub where m7's add-git, branch, remote, and authentication controls land. Cmd-I (File > Board Info) toggles it per window through a focused scene value, kept apart from board-scoped transient state since a titlebar popover belongs to one window, not to the board. Escape reverts a dirty rename field and falls through to dismiss otherwise; a foreign rename resyncs the field only while unfocused. 12 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
1157 lines
63 KiB
Swift
1157 lines
63 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: - Board rename
|
|
|
|
/// Writes the board's own `title` — the board popover's rename field (03-board-ui.md § Board
|
|
/// popover), and the one rename in the app with no item to aim at.
|
|
///
|
|
/// **It edits frontmatter, never the folder**: "Rename edits the board's frontmatter `title`
|
|
/// only — the folder is never renamed by the app; the Finder document name is Finder's to
|
|
/// change" (§ Board popover, 01-storage-format.md § Board naming). The app's display name and
|
|
/// the Finder document name may therefore diverge, which is accepted rather than reconciled.
|
|
///
|
|
/// The three commit rules are `commitRename`'s, deliberately identical — one rename vocabulary
|
|
/// whatever level it is aimed at:
|
|
///
|
|
/// - **Trimmed**, so a title of three spaces is a slip rather than a name.
|
|
/// - **An empty commit removes the key.** A board with no `title` falls back to its *folder
|
|
/// name* (§ Board naming) — never the "Untitled" placeholder cards and lanes show, and never
|
|
/// `title: ""`, which would be a real if blank title with nothing to fall back to.
|
|
/// - **An unchanged title writes nothing**, so a popover opened and dismissed with Return
|
|
/// neither stamps `modified` nor mints a commit.
|
|
///
|
|
/// There is no vanished-target guard, because a board cannot tombstone itself out of its own
|
|
/// window (01-storage-format.md § Deletion): the only way this target goes away is the root
|
|
/// itself vanishing, which is the read-only lock's story, and `performWrite` refuses under it
|
|
/// before anything touches disk.
|
|
public func renameBoard(_ title: String?) {
|
|
let typed = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let newTitle: String? = typed.isEmpty ? nil : typed
|
|
guard newTitle != snapshot.title.value else { return }
|
|
|
|
let folder = rootURL
|
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
|
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so a
|
|
// refusal names the board by the title it still has (see `WriteOperation.rename`).
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
|
|
if let newTitle {
|
|
document.set(FrontmatterKeys.title, to: .string(newTitle))
|
|
} else {
|
|
document.remove(FrontmatterKeys.title)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Lane reorder
|
|
|
|
/// Commits a lane drag: `id` lands at display position `index` among the board's live lanes,
|
|
/// counted **with the dragged lane itself removed** — which is the index
|
|
/// `LaneReorderMath.proposedIndex` produces.
|
|
///
|
|
/// Within-board only. A cross-board lane drag is the locality model's (04-interactions.md ▸
|
|
/// Drag and drop) and belongs to m5's drag card; here source and destination board roots are
|
|
/// the same URL, so `moveItem` takes its same-parent degenerate-reorder path and rewrites
|
|
/// exactly one file — the moved lane's `order`.
|
|
///
|
|
/// **A drag that ends where it started writes nothing**: `index == from` re-inserts the lane in
|
|
/// its own slot, and a no-op must not stamp `modified` or mint a commit — the resize drag's
|
|
/// rule, and for the same reason.
|
|
public func moveLane(_ id: ItemID, toIndex index: Int) {
|
|
let lanes = snapshot.lanes.filter { !$0.isDeleted }
|
|
guard let from = lanes.firstIndex(where: { $0.id == id }) else { return }
|
|
|
|
var remaining = lanes
|
|
remaining.remove(at: from)
|
|
let target = min(max(0, index), remaining.count)
|
|
guard target != from else { return }
|
|
|
|
let root = rootURL
|
|
let folder = root.appendingPathComponent(id.rawValue)
|
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
|
var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target)
|
|
if rank == nil {
|
|
// Compact and place again. Unlike the card case the dragged lane *is* among the
|
|
// renumbered children — it is a real folder on disk — so its fresh rank is dropped
|
|
// from the ladder before the neighbours are consulted.
|
|
try BoardWriter.renumberVisibleChildren(of: root)
|
|
var compacted = Ranks.renumbered(count: lanes.count)
|
|
compacted.remove(at: from)
|
|
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
|
|
}
|
|
guard let rank else { return }
|
|
|
|
_ = try BoardWriter.moveItem(
|
|
at: folder,
|
|
toParent: root,
|
|
sourceBoardRoot: root,
|
|
destinationBoardRoot: root,
|
|
order: rank
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - 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()
|
|
}
|
|
}
|
|
}
|