Files
lanework/Kanban/LiveStore/BoardStore.swift
T
rzen 33bf425f25 Drop a card on the shown trash to delete it
04's ruling makes the drag the pointer's delete gesture: the shown
trash column accepts live same-board card drags, the shadow pinned
topmost — honest, since the trash sorts by deleted newest-first — and
release tombstones through the same write path as Backspace, extracted
so the two gestures cannot drift. DropTarget grew a container case for
the quasi-lane (it has no lane id by construction); lane drags,
cross-board arrivals, option-copies (re-checked at release, the one
input that can flip without a callback), trashed-side payloads, hidden
trash, and the read-only lock all refuse — and a refusal falls through
to the strip retarget, never cancelling the drag. The settle draws the
tombstoned rows in the trash under the cards' own GUIDs, so the echo is
an invisible content swap and nothing winks out for a round trip.
Selection needs no surgery: the reload's resolve rule ejects tombstoned
members as the vanish it is, pinned by a test contrasting both gestures.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 08:12:09 -04:00

2359 lines
131 KiB
Swift

import Foundation
import Observation
import os
// The one UI import in the store layer, and it earns its place: 03-board-ui.md § Motion puts the
// animate-or-snap split on the *reload*, and a reload lands here. `Motion` owns the decision and
// every curve; this file owns nothing but the `withAnimation` around the assignment (see `land`).
import SwiftUI
// MARK: - Vocabulary
/// Why a board is refusing writes — the read-only lock's cause, and now the whole of the
/// vocabulary 02-architecture.md names.
///
/// The three cases share one *scope* (§ "The lock's scope") — every mutating command disabled
/// across every window sharing the store, drops refused, ⌘-drag moves degraded to copies, editor
/// buffers kept but their debounced saves suspended — and differ only in cause and in **what
/// clears them**, which is the one thing this enum's cases are actually asked about (see
/// `BoardStore.land(_:generation:origin:)`). The banner turns a case of this into user-facing
/// phrasing (§ The banner surface); the store only owns the truth of it.
public enum ReadOnlyLockReason: Sendable, Equatable {
/// A reload that followed a bracketed wholesale operation failed, so the last-good snapshot on
/// screen may describe a tree that no longer exists — after a branch switch, a different branch
/// entirely. Writes derived from it would land nonsense, so every write is refused until a
/// reload succeeds (02-architecture.md § Live-reload resilience, "A failed reload after a
/// bracketed operation locks the board read-only").
///
/// **Clears on the next successful reload, whatever its origin** — typically once the offending
/// file is fixed.
case bracketedReloadFailed
/// The board's root is gone and its bookmark re-resolution found nothing: the volume unmounted,
/// or the folder was deleted in Finder while the board was open (02-architecture.md §
/// Write-failure surfacing, "A vanished board root locks the board read-only"). Every write
/// would land nowhere, so the last-good snapshot stays on screen, read-only.
///
/// **Clears on the next successful reload, whatever its origin**: a reload can only succeed if
/// the root is back, so success *is* the return signal. Pending dirty buffers then save
/// normally.
case vanishedRoot
/// The board opened somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a
/// read-only share) or a permission-denied folder (02-architecture.md § Write-failure
/// surfacing, "An unwritable board location enters the read-only lock at open"). Failing
/// loudly, specifically, *once* beats letting every gesture fail one at a time.
///
/// **Clears only on a successful *reconciling* reload whose writability re-probe passes** —
/// unlike its two siblings, whose cause a successful reload disproves by itself. A board on a
/// read-only DMG reloads perfectly all day long; only the probe (§ "Writability re-probes on
/// every reconciling reload" — wake, activation) can tell that the permission or the mount
/// actually changed.
case unwritableLocation
}
/// The refusal `BoardStore.performWrite` throws when the board is locked read-only.
///
/// **Deliberately temporary, and deliberately not a `BoardWriteError`.** The lock is a *store*
/// condition, not a filesystem outcome: nothing was attempted, no path failed, and folding it into
/// `BoardWriteError.io(message:)` would misrepresent a policy refusal as an I/O error in the one
/// place — the banner — where the distinction is the whole point. Widening `BoardWriteError` to
/// carry a refusal case is the banner card's job (02-architecture.md § Write-failure surfacing:
/// "The operation is a closed enum, not a string"), and it will unify this vocabulary with the
/// Writer's. Until then this thin error keeps `performWrite`'s honesty at the cost of an untyped
/// `throws` on its signature.
public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConvertible {
case readOnlyLocked(ReadOnlyLockReason)
public var description: String {
switch self {
case let .readOnlyLocked(reason):
"the board is read-only (\(reason))"
}
}
}
/// What a cross-board drop is doing to the items it carries — the **effective** operation the
/// locality model resolved (04-interactions.md ▸ Drag and drop, settled).
///
/// Not a modifier and not a direction: by the time a store sees one of these the Finder volume
/// model has already been applied — within a board a drag is a move, between boards a copy, ⌥
/// forces copy and ⌘ forces move, each a no-op where it is already the default — and the badge the
/// user was looking at said exactly this. Two cases and no `.none`: a drag with no valid proposal
/// never reaches a commit at all (▸ Drag and drop, rule 2: "release with no valid proposal
/// cancels").
public enum TransferOperation: Sendable, Equatable {
/// Fresh-GUID duplicates land at the drop, originals stay, `created` is kept — a copy is a
/// fork (01-storage-format.md).
case copy
/// A real filesystem move: identity travels, and only the import boundary remints, per folder
/// (01-storage-format.md's per-folder degradation).
case move
}
// MARK: - BoardStore
/// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the
/// board window renders (02-architecture.md § Layering ▸ Components).
///
/// **It enforces the one-way flow — files → watcher → loader → store → views — by never mutating
/// its snapshot from the write path.** A user action runs the Writer, the Writer touches disk, the
/// watcher notices, and the change arrives here as a reload like any external edit. The app trusts
/// its own writes no more than anyone else's; that is what makes external editors and agents
/// first-class, and it is why there is no `snapshot` setter anywhere below `apply(_:generation:)`.
///
/// ### What this type actually owns
///
/// 1. **The reload pipeline.** At most one tree walk in flight, off the main actor; signals arriving
/// during one coalesce into a single follow-up; only the newest result applies.
/// 2. **The failure rules.** A failed reload never replaces a good snapshot; an ordinary failure
/// raises the banner condition and leaves editing alone; a failure after a bracketed wholesale
/// operation locks the board read-only; the next success clears both.
/// 3. **Transient state across reloads.** `transient.resolve(against:)` runs on every applied
/// snapshot, re-grounding the selection, the drag, the pending cut, and the new-card placeholder.
///
/// ### What it deliberately does not own
///
/// The `FolderWatcher` itself — the registry owns one watcher and one store per board and wires
/// them together (`watcherBrackets`, `handleWatcherEvent(_:)`), so this type can be built and tested
/// without a filesystem stream. And the transient state itself, which lives in its own container
/// (`TransientBoardState`) rather than accreting here as fields: this type knows only *when* to
/// re-resolve it, never what the rules are. That includes the **new-card placeholder** — a
/// pseudo-card with no disk presence and no UUID, overlaid on the snapshot rather than merged into
/// it (02-architecture.md § Layering, the one named exception to the one-way flow). Nothing here
/// makes that awkward: `snapshot` is a pure value swap with no identity assumptions, so an overlay
/// is simply rendered on top of whatever the latest reload produced.
@MainActor
@Observable
public final class BoardStore {
// MARK: Read-side state
/// The last good tree walk. Replaced wholesale by a successful reload and **never** by the write
/// path — see the type's doc comment for why. A failed reload leaves it exactly as it was.
public private(set) var snapshot: BoardModel
/// How many snapshots this store has applied, ever — a counter, not a version.
///
/// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold):
/// a drop's overlay stands until "the next snapshot application on that store", and *application*
/// is the event, not change. A reload that produced an identical model still ends the round trip
/// the overlay was covering — comparing `snapshot` values would leave the overlay standing
/// exactly when the write turned out to be a no-op.
public private(set) var snapshotGeneration: Int = 0
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
/// describe the tree currently on screen.
public private(set) var loadWarnings: [LoadWarning]
/// The standing read-side condition: the error from the last reload that failed, `nil` when the
/// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path
/// and what is wrong with it — which is the whole of what the banner needs to render
/// (02-architecture.md § Live-reload resilience). This is a *condition*, not a one-shot: it
/// stands until a reload succeeds, and it heals without ceremony when one does.
public private(set) var reloadFailure: BoardLoadError?
/// Non-`nil` while the board refuses writes. Cleared by the next successful reload, per "the
/// next successful reload clears both the banner and the lock".
public private(set) var readOnlyLock: ReadOnlyLockReason?
public var isReadOnly: Bool { readOnlyLock != nil }
/// Everything shared across this board's windows that is **not on disk** — selection, drag
/// membership, the pending cut, the search query, the new-card placeholder, trash visibility
/// (02-architecture.md § Changes from Kanban).
///
/// **Created with the store and dying with it**, which is what makes its per-open values per-open
/// without any reset logic: closing the board is the reset. `let`, because it is one container
/// for the store's whole life — the windows observe *it*, not a slot on this class.
///
/// The store's only involvement is `resolve(against:)` on every successful reload; the rules that
/// call answers live over there.
public let transient: TransientBoardState
/// Where the board is **now**. Follows the folder: a rename or a move absorbed through
/// `relocate(to:)` updates it, so every URL derived from it — the Writer's paths, card-window
/// keys, Reveal in Finder — re-derives at the new location (02-architecture.md §
/// Write-failure surfacing, "A renamed or moved board root follows its file identity").
///
/// Observed, deliberately: the window title's folder-name fallback reads this, and a rename in
/// Finder should be visible in the title bar without anything else being told.
///
/// `snapshot.rootURL` is the root the *last successful reload* walked, and therefore lags this
/// by exactly one reload during an absorption. That is not a second source of truth: the
/// relocation is always followed by the watcher reattach whose reconciling reload rebuilds the
/// snapshot at the new root, after which the two agree again.
public private(set) var rootURL: URL
// MARK: Banners
/// The board window's banner strip, as a model (02-architecture.md § The banner surface).
///
/// **Owned, not injected**, and the reason is the hosting rule: the strip is "hosted by the
/// window of origin", and a board window's strip has exactly one lifetime — this store's. A
/// card window (m6) gets its *own* center for its own save, attachment, and raw-source Apply
/// failures, and re-homes those rows here when it closes; injecting a shared center would
/// erase precisely that distinction.
///
/// It holds only what nothing else does — one-shot write failures, loss rows, the history
/// suspension, in-progress operations, passive signposts. The lock and the reload breakage stay
/// this store's own state and are composed in at render time by `bannerRows`.
public let banners = BannerCenter()
/// The rows the board window's strip renders, in precedence order.
///
/// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and
/// `banners` holds the rest, so a stored array would be a third copy waiting to go stale. The
/// ordering rule itself lives in `BannerCenter.rows(...)`, which is pure and tested on its own.
public var bannerRows: [BannerRow] {
BannerCenter.rows(
lock: readOnlyLock,
breakage: reloadFailure,
oneShots: banners.oneShots,
losses: banners.losses,
suspension: banners.historySuspension,
operations: banners.operations,
signposts: banners.signposts
)
}
// MARK: Wiring
/// The watcher's bracket calls, injected rather than owned: the registry holds the watcher and
/// the store together, and a store that reached into a watcher it did not own could not be
/// tested without one. `nil` means "no watcher attached" — every operation below still behaves,
/// it simply has nothing to suspend, which is exactly the shape unit tests want.
@ObservationIgnored
public var watcherBrackets: (begin: @MainActor () -> Void, end: @MainActor () -> Void)?
/// What to do when the watched root changes identity — injected for the same reason the
/// brackets are: the response needs the board's **security-scoped bookmark**, and this store
/// does not own one (the registry does, along with the watcher that must be re-attached and the
/// last-known path that arms the return detection). A store that reached for a bookmark it did
/// not hold could not be built or tested without a registry.
///
/// `nil` keeps the documented no-op: the last-good snapshot stays on screen, which is what
/// every other failure path here does, and which is exactly the shape unit tests and any
/// storeless use want. `BoardStoreRegistry` wires it to its own recovery loop.
@ObservationIgnored
public var rootChangeDelegate: (@MainActor () -> Void)?
/// The registry's live write-through for this board's title, icon, and iconColor
/// (02-architecture.md § Per-board app state, "these three refresh whenever an open board's
/// reload changes them"). Injected the same way `rootChangeDelegate` is, and for the same
/// reason: the write-through needs this board's **registry record id**, which this store does
/// not own — `BoardWindowHost.configureWindow` wires it once a session's recordID exists,
/// mirroring how it wires `windowController.onFrameChanged` right beside it.
///
/// Called on **every** successful reload, whether or not the board-level display state
/// actually changed. The "did it change" comparison is against the registry's *cached*
/// record, not against this store's own previous snapshot — the registry is the only side
/// that knows the cached value, so `BoardRegistry.syncDisplayState` is what turns a call that
/// changed nothing into a no-op. `nil` (no watcher-backed session, a storeless test) simply
/// means nothing is listening, exactly like `rootChangeDelegate`'s `nil`.
@ObservationIgnored
public var displayStateDelegate: (@MainActor () -> Void)?
// MARK: Reload machinery
/// Monotonic id of the most recently *started* reload — and therefore also the number of tree
/// walks this store has run since it opened, which is what makes the coalescing rule assertable
/// from a test rather than merely plausible.
///
/// Two rules are expressed as comparisons against it: **only the newest result applies** (a
/// result whose generation is no longer the current one is dropped), and **the wholesale
/// expectation binds to a load started after it was armed** (`wholesaleReloadFloor`).
@ObservationIgnored
private(set) var reloadGeneration = 0
/// Whether a tree walk is running. At most one ever is: a second concurrent walk would buy
/// nothing (both would produce the same snapshot) and would make "the newest result wins" a race
/// rather than a rule.
@ObservationIgnored
private var reloadInFlight = false
/// A reload owed but not started, because one was already running when the signal arrived — a
/// **flag, not a queue**: any number of signals during one walk coalesce into exactly one
/// follow-up, because the follow-up is a full tree walk that covers all of them. The origin is
/// merged by the watcher's own precedence rule (`reconciling > appMediated > foreign`) so a
/// foreign event folding into a pending reconciling one never downgrades it.
@ObservationIgnored
private var pendingReload: WatchOrigin?
/// The generation from which a bracketed wholesale operation's expectation applies, or `nil`
/// when no operation is outstanding.
///
/// **Why a floor and not a boolean.** The rule is "the reload that ends this wholesale operation
/// must succeed, or the board locks" — and a boolean cannot tell that reload apart from one that
/// was *already in flight* when the operation ended, which observed a tree from before the
/// operation touched it and therefore proves nothing about the result. Arming stores
/// `reloadGeneration + 1`: the next walk to be *started*. An in-flight walk's generation is
/// below the floor and passes through without consuming it; the first walk started at or after
/// it consumes it — succeeding clears everything as usual, failing engages the lock.
///
/// Ordinary (non-wholesale) reload failures never set the lock, because ordinary breakage is
/// per-file: the snapshot still describes the tree and editing around the broken file is safe.
@ObservationIgnored
private var wholesaleReloadFloor: Int?
/// Consumers suspended in `awaitQuiescence()`, resumed together the moment nothing is running
/// and nothing is owed.
@ObservationIgnored
private var quiescenceWaiters: [CheckedContinuation<Void, Never>] = []
/// Awaited off the main actor **after** a tree walk finishes and **before** its result is
/// applied — the one seam this type keeps, `nil` in production.
///
/// It exists because two of the contracts above are ordering claims about work that runs
/// concurrently with the main actor ("only the newest result applies"; "the wholesale
/// expectation never binds to a walk already in flight"), and a test that cannot pin a finished
/// walk open can only approximate them with sleeps — which would make the suite slow, flaky, and
/// silent about the very race it exists to rule out.
@ObservationIgnored
var loadBarrier: (@Sendable () async -> Void)?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store")
// MARK: - Init
/// Opens a board: one synchronous tree walk, and **no fallback if it fails**.
///
/// Fail-fast is the *initial-load* contract (01-storage-format.md § Malformed input): there is
/// no last-good snapshot to keep on screen yet, so a broken board throws its `BoardLoadError`
/// instead of constructing a store that would have nothing to show. Every rule below — the
/// banner, the lock, "a failed reload never replaces a good snapshot" — exists only *because*
/// this one succeeded.
///
/// The walk is synchronous because the caller has nothing to render until it lands; the
/// asynchronous, off-main pipeline starts with the first reload.
public init(rootURL: URL) throws(BoardLoadError) {
let result = try BoardLoader.load(boardRoot: rootURL)
self.rootURL = rootURL
self.snapshot = result.model
self.loadWarnings = result.warnings
self.reloadFailure = nil
self.readOnlyLock = nil
self.transient = TransientBoardState()
}
// MARK: - Inbound signals
/// The single inbound signal — everything the outside world tells this store arrives here.
///
/// Deliberately one door: the watcher, a test, and (later) the registry's wake/activation
/// reconciliation all speak the same two-case vocabulary, so there is exactly one place where a
/// filesystem change becomes a reload.
public func handleWatcherEvent(_ event: WatcherEvent) {
switch event {
case let .treeChanged(origin):
requestReload(origin)
case .rootChanged:
// Delegated, never guessed at. The settled response is to re-resolve the board's
// security-scoped bookmark and either `reattach(to:)` the watcher at the new location —
// a rename absorbed with no banner and no lock — or enter the vanished-root read-only
// lock (02-architecture.md § Write-failure surfacing). Both halves need a bookmark this
// store does not own, and a guess here would be a *wrong* guess: treating a rename as a
// vanish would lock a board that is merely somewhere else.
//
// No reload is started either way. The delegate's two outcomes both end in one —
// `reattach(to:)`'s reconciling reload at the re-resolved root, or the vanished-root
// lock's eventual clearance when the root returns — and a reload fired from here would
// walk a path that just stopped being the board.
guard let rootChangeDelegate else {
Self.logger.debug("rootChanged ignored — no delegate is wired (storeless use)")
return
}
rootChangeDelegate()
}
}
/// Starts a reload, or banks one if a walk is already running.
private func requestReload(_ origin: WatchOrigin) {
guard !reloadInFlight else {
pendingReload = WatchOrigin.merged(pendingReload, origin)
return
}
startReload(origin)
}
// MARK: - The reload pipeline
/// Runs one tree walk **off the main actor** and applies its result on it.
///
/// Off-main because a board of any size is a directory walk plus a YAML parse per item, and the
/// whole point of the value-type snapshot is that this work can happen anywhere: `BoardLoader`
/// is stateless statics and `LoadResult` is `Sendable`, so the only thing that has to be on the
/// main actor is the assignment at the end. `Task.detached` rather than `Task { }`: a task
/// created inside a `@MainActor` method inherits that isolation and would run the walk on the
/// main actor — the exact thing this is avoiding.
///
/// `origin` is not branched on: every reload is a full tree walk, so no origin is less safe than
/// another. It is carried because the *policy* around a reload differs later — a `.reconciling`
/// sweep re-probes writability (02-architecture.md § Write-failure surfacing) — and because it is
/// the one thing that makes a reload's provenance legible in the log.
private func startReload(_ origin: WatchOrigin) {
reloadGeneration += 1
let generation = reloadGeneration
let root = rootURL
let barrier = loadBarrier
reloadInFlight = true
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
Task.detached(priority: .userInitiated) { [weak self] in
// `do throws(BoardLoadError)`: without the annotation the `catch` widens to `any Error`
// and the loader's typed error is lost on the way into `Result`.
let outcome: Result<LoadResult, BoardLoadError>
do throws(BoardLoadError) {
outcome = .success(try BoardLoader.load(boardRoot: root))
} catch {
outcome = .failure(error)
}
await barrier?()
await self?.apply(outcome, generation: generation, origin: origin)
}
}
/// Lands one walk's result and starts whatever it uncovered.
private func apply(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
reloadInFlight = false
// The stale-apply guard. Serialization means this should not trigger today, but "only the
// newest result applies" is the rule the wholesale floor and every future overlapping-load
// change are written against, so it is enforced rather than assumed.
if generation == reloadGeneration {
land(outcome, generation: generation, origin: origin)
}
startPendingReload()
resumeQuiescenceWaitersIfQuiet()
}
private func land(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
// Consumed here, before the branch, because *both* outcomes end the expectation: a wholesale
// operation gets exactly one reload to prove itself, and a second failure after it is
// ordinary per-file breakage again.
let endsWholesaleOperation: Bool
if let floor = wholesaleReloadFloor, generation >= floor {
wholesaleReloadFloor = nil
endsWholesaleOperation = true
} else {
endsWholesaleOperation = false
}
switch outcome {
case let .success(result):
// **The motion language's one decision point** (03-board-ui.md § Motion, via `Motion`).
// "User-initiated structural changes animate; foreign changes snap" cannot live at the
// call sites here the way it did in the pathfinder — the one-way flow means the user's
// own delete arrives back through the watcher exactly like an agent's edit — so it lives
// at the *reload*, whose origin is already classified. `Motion` owns which origins
// perform and in which voice; this store owns only the assignment they wrap.
//
// A `nil` animation is not a special case: `withAnimation(nil)` is the bare assignment,
// which is what the snapping origins and the Reduce Motion variant both want.
withAnimation(Motion.reloadAnimation(
origin: origin,
endsBracketedOperation: endsWholesaleOperation,
reduced: Motion.prefersReducedMotion
)) {
snapshot = result.model
snapshotGeneration += 1
loadWarnings = result.warnings
// The one place transient state is re-grounded. It goes last, after `snapshot` is
// the new one, because a view woken by the snapshot's change must never observe a
// selection still pointing at the old tree.
//
// Inside the transaction deliberately: 03 § Motion has the selection highlight
// "ride whatever transaction is active rather than easing on its own", and a
// re-grounding that landed outside this one would be exactly the independent ease
// that rules out.
transient.resolve(against: result.model)
}
// Outside it, equally deliberately — 03 keys transactions narrowly, and the banner
// conditions are not board structure. A lock clearing is not a thing the strip should
// slide out on the back of a card being deleted.
//
// Breakage always heals on a success — it *is* the claim "the last reload failed", and
// this one did not.
reloadFailure = nil
clearLockIfDisproved(by: origin)
// The registry write-through, for the same "not board structure" reason the lock
// clearing sits out here: whether this board's row needs a new title, icon, or
// iconColor is the registry's question to answer (`syncDisplayState`'s own no-op
// guard), not a decision this store makes by comparing against its own prior
// snapshot.
displayStateDelegate?()
case let .failure(error):
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
// never replaces a good snapshot, and state over a snapshot that did not change has
// nothing to re-resolve against. Nothing is performed here either, for the same reason —
// and neither is anything on the lock paths below (`enterVanishedRootLock`,
// `enterUnwritableLock`, `relocate`), none of which touch the snapshot at all. A board
// that did not change has no motion to show.
reloadFailure = error
// `readOnlyLock == nil` rather than an unconditional assignment: a root that vanished
// mid-bracket already raised its own, truer lock, and 02-architecture.md is explicit
// that the bracket's final reload becomes a no-op there rather than a redundant
// failure. Overwriting `.vanishedRoot` with `.bracketedReloadFailed` would also break
// the clearing rules — the vanished root's lock must not clear on a reload that never
// proves the root came back.
if endsWholesaleOperation, readOnlyLock == nil {
readOnlyLock = .bracketedReloadFailed
}
Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
}
}
private func startPendingReload() {
guard !reloadInFlight, let origin = pendingReload else { return }
pendingReload = nil
startReload(origin)
}
// MARK: - The lock's clearing rules
/// Clears the read-only lock if this successful reload actually disproved its cause.
///
/// **Reason-specific, because the causes are not alike** (02-architecture.md § Write-failure
/// surfacing):
///
/// - `.bracketedReloadFailed` and `.vanishedRoot` are *disproved by the success itself*. The
/// first says "the tree could not be re-read after a wholesale change" and the second says
/// "the root is gone" — a completed tree walk at the root contradicts both, whatever origin
/// asked for it, so any success clears them.
/// - `.unwritableLocation` is not. A board on a read-only DMG reloads flawlessly forever;
/// loading proves nothing about writing. It clears only when a **reconciling** reload — wake,
/// activation, a stream re-creation — re-probes writability and finds it changed ("Writability
/// re-probes on every reconciling reload, so a fixed permission or rewritable remount clears
/// the lock without ceremony").
///
/// `FileManager.isWritableFile(atPath:)` is `access(2)` on the root directory: a real-uid
/// permission question asked of the filesystem, which is what makes it answer correctly for
/// both halves of the case — a read-only *mount* and a permission-denied *folder*.
///
/// Deliberately **one-way**: a reconciling reload that finds the root unwritable does not
/// *raise* the lock. Arming it is the open flow's job (`enterUnwritableLock()`), and inferring
/// a lock from a probe here would be a policy decision this milestone was not asked to make.
private func clearLockIfDisproved(by origin: WatchOrigin) {
switch readOnlyLock {
case nil:
break
case .bracketedReloadFailed, .vanishedRoot:
readOnlyLock = nil
case .unwritableLocation:
guard origin == .reconciling, FileManager.default.isWritableFile(atPath: rootURL.path) else { return }
Self.logger.debug("writability re-probe passed — the unwritable-location lock clears")
readOnlyLock = nil
}
}
// MARK: - Root identity and explicit locks
/// Points this store at the board's new location, absorbing a rename or a move.
///
/// Called by the registry when a `.rootChanged` re-resolved the board's bookmark somewhere else
/// (02-architecture.md § Write-failure surfacing, "A renamed or moved board root follows its
/// file identity"): the board the app has open is the *file*, not the path string, so this is
/// bookkeeping rather than an event — **no banner, no lock, nothing was ever wrong**.
///
/// It deliberately starts **no reload**. The caller follows this with the watcher's
/// `reattach(to:)`, whose reconciling reload is the one that rebuilds the snapshot at the new
/// root; a reload fired from here would be a second walk racing that one for no gain. Until it
/// lands, `rootURL` is the new location and `snapshot.rootURL` is still the old — see
/// `rootURL`'s note.
public func relocate(to newRoot: URL) {
guard newRoot != rootURL else { return }
Self.logger.debug("board root relocated; Writer URLs now derive from the new location")
rootURL = newRoot
}
/// Raises the vanished-root read-only lock — the registry's call, after bookmark re-resolution
/// found nothing and the last-known path is not there either.
///
/// Overwrites whatever lock was standing: a root that is gone is the most current and most
/// specific truth about why writes are refused, and its clearing rule (a successful reload,
/// which can only happen if the root came back) is strictly the safer one to be holding.
public func enterVanishedRootLock() {
Self.logger.error("board root vanished — entering the read-only lock")
readOnlyLock = .vanishedRoot
}
/// Raises the unwritable-location read-only lock — the open flow's call, after probing the
/// root's writability (02 § "An unwritable board location enters the read-only lock at open").
/// Public now so the vocabulary and its clearing rule ship together; m4's open flow is the
/// producer.
///
/// Does **not** overwrite a standing lock: a board that is already locked for a vanished root
/// or a failed bracketed reload has a cause that outranks "and it is also read-only", and both
/// of those clear on a success that would then re-probe anyway.
public func enterUnwritableLock() {
guard readOnlyLock == nil else { return }
Self.logger.error("board location is not writable — entering the read-only lock")
readOnlyLock = .unwritableLocation
}
// MARK: - Write gate
/// Runs a synchronous Writer operation inside the watcher bracket, so the churn it produces
/// rounds back as one app-mediated reload rather than a scatter of foreign ones.
///
/// The store does **not** touch its snapshot here, before or after. `operation` puts bytes on
/// disk; the watcher notices; the reload applies. That indirection is the one-way flow, and it is
/// why this method's only jobs are the gate and the bracket.
///
/// **A failure posts to the banner before it is rethrown** (02-architecture.md § Write-failure
/// surfacing): the strip is how the one-way flow keeps its honesty — the action visibly did not
/// happen, and the banner is the only thing that says why — so no call site is trusted to
/// remember, and a `try?` at some future call site cannot make a failure silent. The refusal
/// below is deliberately *not* posted: the lock row is already standing, and a second row per
/// refused gesture would bury it under echoes of itself.
///
/// - Throws: `BoardStoreWriteRefusal.readOnlyLocked` if the board is locked read-only — checked
/// *before* the bracket opens, so a refusal costs no suspended watcher and no owed reload.
/// Otherwise rethrows whatever `operation` threw, which is a `BoardWriteError`. The untyped
/// `throws` is the price of those being two different error types today; see
/// `BoardStoreWriteRefusal` for why they are, and why they will not stay that way.
///
/// One ergonomic wart, recorded so it is not rediscovered: when `operation` returns a value,
/// Swift cannot infer `T` and the closure's thrown type at the same time — the thrown type
/// widens to `any Error` and the call fails to compile. Such a call site spells the closure out
/// (`{ () throws(BoardWriteError) -> ItemID in … }`). `Void`-returning operations, which are
/// most of them, infer cleanly.
@discardableResult
public func performWrite<T>(_ operation: () throws(BoardWriteError) -> T) throws -> T {
if let readOnlyLock {
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
}
watcherBrackets?.begin()
// `defer`, not a trailing call: a Writer operation that fails partway has still touched disk,
// and an unbalanced bracket would leave the watcher suspended for the rest of the session.
defer { watcherBrackets?.end() }
// `do throws(BoardWriteError)`: without the annotation the `catch` widens to `any Error` and
// the Writer's typed error is lost on the way to the banner.
do throws(BoardWriteError) {
return try operation()
} catch {
banners.post(error)
throw error
}
}
/// Runs an operation that rewrites the tree **wholesale** — pull-rebase, branch switch, undo
/// restore (06-history-undo.md, 07-sync-collab.md) — under the bracket, and arms the rule that
/// its closing reload must succeed.
///
/// Two things distinguish this from `performWrite`:
///
/// - The bracket is load-bearing rather than tidy: a reload landing mid-operation would render a
/// half-checked-out tree.
/// - Failure of the closing reload **locks the board** (`ReadOnlyLockReason.bracketedReloadFailed`).
/// After a wholesale change the last-good snapshot may describe a different branch entirely, so
/// writes derived from it would land nonsense — unlike ordinary per-file breakage, where the
/// snapshot still describes the tree.
///
/// The expectation is armed on **every** exit path, a thrown error included: an operation that
/// died partway is precisely the case where the tree's state is unknown and the next reload had
/// better be the authority on it.
///
/// - Throws: `BoardStoreWriteRefusal.readOnlyLocked` if the board is already locked — a locked
/// board refuses to *start* wholesale work, not just ordinary writes. Otherwise rethrows
/// `operation`'s error. (Spelled `throws` rather than `rethrows` because of that refusal: a
/// `rethrows` function may only throw errors its closure threw.)
public func performWholesale(_ operation: () throws -> Void) throws {
if let readOnlyLock {
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
}
watcherBrackets?.begin()
defer {
// Ordered: arm first, then close the bracket. `endBracket()` is what schedules the
// post-bracket reload, and with a `nil` watcher a consumer may signal by hand the instant
// this returns — either way the floor has to be in place before any walk can start.
wholesaleReloadFloor = reloadGeneration + 1
watcherBrackets?.end()
}
do {
try operation()
} catch let error as BoardWriteError {
// Same honesty rule as `performWrite`, applied to the one error type the banner has
// phrasing for. A wholesale operation is usually git's (m7), whose own failure
// vocabulary is not `BoardWriteError` and whose surfacing — the suspended-history
// condition, the in-progress row swapping for an error — is the committer's to drive;
// but a `BoardWriteError` escaping here is an ordinary failed write and may no more
// bypass the strip than one from `performWrite`.
banners.post(error)
throw error
}
}
// MARK: - Lane width
/// Writes a lane's width — the one commit point both width mechanisms share (03-board-ui.md §
/// Lane): the right-edge drag's release and the stepper's ⌥⌘→/⌥⌘← both land here, and they differ
/// only in what they did to the *window* on the way (the drag grew it, the stepper did not).
///
/// **The value written is an integer, replacing whatever was there.** `width` is a lenient field
/// on the read side — missing, malformed, zero and negative all render as one unit
/// (`LaneLayoutMath.displayUnits`) with the author's bytes left alone — but an explicit width
/// change is the user overwriting that value, so the Writer puts a plain integer in its place
/// (01-storage-format.md § Frontmatter).
///
/// Three ways this does nothing, all deliberate: a count below 1 clamps to 1 (a lane spans at
/// least one unit), an id that is not in the snapshot is ignored (the lane vanished under the
/// gesture — the reload that removed it is the authority), and a count already equal to what the
/// lane displays writes nothing (a drag that ends where it started must not stamp `modified` or
/// mint a git commit).
///
/// Failures are already the banner's: `performWrite` posts every `BoardWriteError` before it
/// rethrows, so the rethrow is swallowed here rather than propagated to a gesture that has no
/// second thing to do about it. The lane stays at its old width, which is the truth — nothing was
/// written.
public func setLaneWidth(_ id: ItemID, units: Int) {
writeLaneWidths([(id, max(1, units))])
}
/// Steps every lane in `ids` one unit — the Increase/Decrease Lane Width menu items' batch
/// (03-board-ui.md § Lane, settled: "they batch over a multi-lane selection — each selected
/// lane steps one unit, one gesture, one commit"; the context-menu stepper stays single-lane
/// by nature and keeps calling `setLaneWidth`).
///
/// Lanes already at the one-unit floor simply hold there on a decrease — the batch is not
/// refused because one member has nowhere to go, matching the style batch's silent-skip shape.
public func stepLaneWidths(_ ids: Set<ItemID>, by delta: Int) {
let changes: [(ItemID, Int)] = snapshot.lanes
.filter { ids.contains($0.id) && !$0.isDeleted }
.map { ($0.id, max(1, LaneLayoutMath.displayUnits(of: $0) + delta)) }
writeLaneWidths(changes)
}
/// The one commit point every width mechanism shares — the edge drag, the context-menu stepper,
/// and the menu items' batch. One `performWrite` bracket whatever the count: one gesture, one
/// app-mediated reload, one commit on git boards (the style batch's rule).
///
/// **A width landing on 1 removes the `width` key** (03-board-ui.md § Lane, settled — the
/// remove-at-default family beside the empty rename's `title` and the None well's
/// `background`): a default lane's frontmatter stays clean whichever mechanism wrote it. A
/// hand-written `width: 1` is legal and preserved until the app itself next edits width — the
/// unchanged-units guard below skips it, so only a real change reaches the remove.
private func writeLaneWidths(_ changes: [(id: ItemID, units: Int)]) {
let writes: [(folder: URL, units: Int)] = changes.compactMap { change in
guard let lane = snapshot.lanes.first(where: { $0.id == change.id }),
LaneLayoutMath.displayUnits(of: lane) != change.units
else { return nil }
return (rootURL.appendingPathComponent(change.id.rawValue), change.units)
}
guard !writes.isEmpty else { return }
// The closure's signature is spelled out because of `try?`: with the error discarded at the
// call site Swift stops inferring the typed `throws(BoardWriteError)` and widens it to `any
// Error`, which `performWrite` will not take. Same wart as the value-returning call sites
// `performWrite`'s doc comment records, arriving from the other direction.
try? performWrite { () throws(BoardWriteError) -> Void in
for write in writes {
try BoardWriter.updateIndex(inItemFolder: write.folder, operation: .resize(title: nil)) { document in
if write.units == 1 {
document.remove(FrontmatterKeys.width)
} else {
document.set(FrontmatterKeys.width, to: .int(write.units))
}
}
}
}
}
// MARK: - Styling
/// One item a style gesture is about to act on: where its `index.md` is, and what the two styled
/// keys currently say there.
///
/// The editor reads these for its per-dimension current-value display (`StyleFieldState.resolve`)
/// and `applyStyle` reads the *same* values to decide what is a no-op, so the display and the
/// write can never disagree about what is already on disk. `id` is `nil` for the board root,
/// which has no `ItemID` by design (see `ItemID`'s doc comment).
public struct StyleSubject: Sendable, Equatable {
public let id: ItemID?
public let folder: URL
public let background: FieldValue<String>
public let icon: FieldValue<String>
}
/// The live items `target` names, in display order — lanes left to right, each lane's cards top
/// to bottom.
///
/// **Vanished targets are simply absent**, ancestor walk included: a tombstoned card, a card
/// under a tombstoned lane, and an id that names nothing all contribute no subject, which is the
/// same silent skip `commitRename` gives a vanished rename target — "nothing is ever written into
/// a vanished folder". A style editor whose set has emptied dismisses (`StyleEditorSession`), so
/// an empty result is a frame's worth of nothing to show rather than a state to handle.
public func styleSubjects(of target: StyleTarget) -> [StyleSubject] {
switch target {
case .board:
return [StyleSubject(
id: nil,
folder: rootURL,
background: snapshot.background,
icon: snapshot.icon
)]
case let .items(ids):
var subjects: [StyleSubject] = []
for lane in snapshot.lanes where !lane.isDeleted {
let laneFolder = rootURL.appendingPathComponent(lane.id.rawValue)
if ids.contains(lane.id) {
subjects.append(StyleSubject(
id: lane.id,
folder: laneFolder,
background: lane.background,
icon: lane.icon
))
}
for card in lane.cards where !card.isDeleted && ids.contains(card.id) {
subjects.append(StyleSubject(
id: card.id,
folder: laneFolder.appendingPathComponent(card.id.rawValue),
background: card.background,
icon: card.icon
))
}
}
return subjects
}
}
/// Which level `target` sits at — the editor's symbol grid needs it for its leading well, "the
/// level's default symbol" (03-board-ui.md § Styling ▸ Controls).
///
/// A set naming any card is a card set: 04-interactions.md's cards-XOR-lanes rule means a live
/// selection never mixes the two, so the branch below is a total answer rather than a policy —
/// and if a mixed set ever reached here, `doc.text` is the level whose default would actually be
/// removed by the leading well.
public func styleLevel(of target: StyleTarget) -> StyleLevel {
switch target {
case .board:
return .board
case let .items(ids):
let namesACard = snapshot.lanes.contains { lane in
!lane.isDeleted && lane.cards.contains { !$0.isDeleted && ids.contains($0.id) }
}
return namesACard ? .card : .lane
}
}
/// Writes a style gesture — the **one** commit point every anchor shares (03-board-ui.md §
/// Styling ▸ Controls: "One component, one behavior, three anchors"), and the quick-style recents
/// row with them.
///
/// **One bracket, whatever the target set's size.** "Choosing a well applies to the whole
/// selection — one gesture, one commit on git boards" (§ Controls), so every target's `index.md`
/// is rewritten inside a single `performWrite`: the churn rounds back as one app-mediated reload,
/// and the auto-committer (m7) sees one operation rather than N.
///
/// **No-ops are skipped per dimension and per target** — `setLaneWidth`'s rule, for its reason: a
/// well clicked twice, or a batch where half the cards are already that colour, must not stamp
/// `modified` or mint a commit on the items that were already right. A dimension whose value is
/// already what the gesture asks contributes nothing; a target both of whose dimensions are
/// no-ops is dropped entirely; and a gesture that changes nothing anywhere never opens the
/// bracket at all.
///
/// **`iconColor` is not a parameter, and that is the design**: it is "resolved — schema yes,
/// control no" (§ Capabilities). The field renders when hand-written and the app offers no
/// control for it, so there is nothing here to pass.
///
/// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here
/// like every other gesture with no second thing to do. A batch that fails partway leaves the
/// targets written before it written — the Writer is "atomic per filesystem operation, not per
/// gesture" — and the reload shows the true state, which is the honest one.
public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) {
let edits: [(folder: URL, background: StyleChange, icon: StyleChange)] = styleSubjects(of: target)
.compactMap { subject in
let background = Self.effective(background, against: subject.background)
let icon = Self.effective(icon, against: subject.icon)
guard background != .keep || icon != .keep else { return nil }
return (folder: subject.folder, background: background, icon: icon)
}
guard !edits.isEmpty else { return }
try? performWrite { () throws(BoardWriteError) -> Void in
for edit in edits {
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a
// failure names the item by the title it still has (see `WriteOperation.style`).
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
}
}
}
}
/// `change` narrowed against what is already on disk: `.keep` when it would write what is
/// already there.
///
/// The comparison is against the **valid** reading, not the written text: a malformed value —
/// `background: [a, b]`, a sequence where a scalar belongs — is never equal to a palette name, so
/// choosing a well always replaces it, which is what "choosing any well replaces it" (§ Controls)
/// promises about a value the app could not read.
nonisolated static func effective(_ change: StyleChange, against field: FieldValue<String>) -> StyleChange {
switch change {
case .keep: .keep
case let .set(value): field.value == value ? .keep : .set(value)
case .remove: field.isMissing ? .keep : .remove
}
}
private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) {
switch change {
case .keep: break
case let .set(value): document.set(key, to: .string(value))
case .remove: document.remove(key)
}
}
// MARK: - Creation
/// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md).
///
/// **Untitled, and deliberately with no inline editor.** 03-board-ui.md gives lane titles one
/// editing surface — "Inline rename on the header" — and 04-interactions.md gives that surface
/// one entry point, Board ▸ Rename, "since Return on a lane creates a card". Nothing in either
/// doc opens an editor *at creation*, so a new lane appears with the untitled placeholder and
/// the user renames it if they want a name. Titles are optional at every level; a lane with no
/// `title` key is a legitimate resting state, not a half-finished one.
///
/// Like `setLaneWidth`, the rethrow is swallowed: `performWrite` has already posted the banner,
/// and a menu item has no second thing to do about a failure.
public func createLane() {
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.createLane(inBoard: root, title: nil)
}
}
// MARK: - The new-card placeholder's commit
/// Turns the open placeholder into a real card — the write half of 02-architecture.md §
/// Layering's one named exception to the one-way flow.
///
/// The five outcomes, all settled:
///
/// - **No placeholder, or one already committed** — nothing to do. (Idempotence matters: Return
/// commits, and the field's focus-loss handler fires immediately afterwards.)
/// - **An empty title discards it** — "creating-then-abandoning never leaves an empty card
/// behind" (04-interactions.md ▸ Grammar). Whitespace counts as empty: a title of three
/// spaces is a slip, not a deliberate untitled card.
/// - **A vanished lane discards it** — the anchor is gone, so there is nowhere to file the
/// card; the reload that removed the lane is the authority.
/// - **A failed create discards it too** (settled, 02 § Layering): "the overlay never waits for
/// a card that cannot arrive". The failure is already the banner's.
/// - **A successful create hands off**: the overlay flips to `.awaitingArrival` and stands until
/// the watcher round-trips the real card, so the user never sees a hole where they just typed.
///
/// - Returns: the created card's id, or `nil` on any of the discard paths — which is what the
/// ⌘↩ call site needs to know whether it has a card window to open.
@discardableResult
public func commitPlaceholder() -> ItemID? {
guard let placeholder = transient.newCardPlaceholder, placeholder.phase == .editing else { return nil }
let title = placeholder.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty,
let lane = snapshot.lanes.first(where: { $0.id == placeholder.laneID && !$0.isDeleted })
else {
transient.discardPlaceholder()
return nil
}
let laneFolder = rootURL.appendingPathComponent(placeholder.laneID.rawValue)
let visible = lane.cards.filter { !$0.isDeleted }
// `nil` means "append", which is `createCard`'s own default — so the anchored case is the
// only one that needs a rank at all.
let position = Self.insertionIndex(after: placeholder.anchorCardID, among: visible)
let created = try? performWrite { () throws(BoardWriteError) -> ItemID in
let id = try BoardWriter.createCard(inLane: laneFolder, title: title)
guard let position else { return id }
// The rank is computed here rather than passed to `createCard` because the create's
// contract is "append after the visible siblings" and widening it would give every
// caller a position to think about. The reposition rides the Writer's own same-parent
// degenerate reorder — "a move whose destination is the item's current parent degrades
// to a plain reorder" — inside the *same* `performWrite`, so the pair rounds back as
// one app-mediated reload rather than showing the card at the bottom for a frame.
var rank = Ranks.insertionRank(amongVisible: visible.map(\.order), at: position)
if rank == nil {
// Midpoint precision exhausted between the anchor and its neighbour
// (01-storage-format.md § Ordering). Compact, then place against the fresh ranks:
// the new card is not among the renumbered siblings — it was appended past them —
// so the compacted ladder lines up one-for-one with `visible`.
try BoardWriter.renumberVisibleChildren(of: laneFolder)
rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: visible.count), at: position)
}
guard let rank else { return id }
_ = try BoardWriter.moveItem(
at: laneFolder.appendingPathComponent(id.rawValue),
toParent: laneFolder,
sourceBoardRoot: rootURL,
destinationBoardRoot: rootURL,
order: rank
)
return id
}
guard let created else {
transient.discardPlaceholder()
return nil
}
transient.commitPlaceholder(expecting: created)
return created
}
/// The display position a new card takes, or `nil` for "append at the bottom".
///
/// An anchor that is not among `visible` degrades to `nil` rather than failing: the card the
/// ⌘N target rule named was deleted or moved away mid-typing, and the lane — the anchor that
/// actually matters — is still there. Appending is the honest fallback; refusing to create
/// would punish the user for someone else's edit.
nonisolated static func insertionIndex(after anchor: ItemID?, among visible: [Card]) -> Int? {
guard let anchor, let index = visible.firstIndex(where: { $0.id == anchor }) else { return nil }
// Already last: "immediately after it" and "at the bottom" are the same position, and
// append needs no rank of its own.
return index + 1 < visible.count ? index + 1 : nil
}
// MARK: - Inline rename
/// Writes the open rename editor's draft — the third inline editor's commit
/// (04-interactions.md ▸ Grammar), reached by Return **and** by focus loss ("a rename commits
/// … the deliberate exception being the placeholder, because nothing exists on disk yet").
///
/// Four rules, all from 04 and 03:
///
/// - **The editor closes first, unconditionally.** Every path below ends with it gone, and
/// retiring it up front is what makes this idempotent — Return commits and the field's
/// focus-loss handler fires an instant later against no editor at all.
/// - **A vanished target writes nothing, silently.** "A target that is tombstoned, deleted, or
/// gone at commit time discards the editor and its keystrokes silently … nothing is ever
/// written into a vanished folder, and no partial `index.md` can resurrect deleted data."
/// Liveness is effective — a card under a tombstoned lane is vanished too.
/// - **An empty commit removes the `title` key** (03-board-ui.md § Card face; 04 ▸ Selection:
/// "Committing an empty rename on an existing item removes its `title` key"), rather than
/// writing `title: ""` — titles are optional, and the face shows the untitled placeholder.
/// - **An unchanged title writes nothing.** `setLaneWidth`'s rule, for the same reason: an
/// editor opened and dismissed with Return must not stamp `modified` or mint a commit.
///
/// The folder is re-derived from the *current* snapshot, which is what makes a foreign move
/// mid-rename invisible: the editor follows the UUID, and the write lands wherever the item is
/// now.
public func commitRename() {
guard let editor = transient.renameEditor else { return }
transient.discardRename()
guard let target = Self.liveItem(editor.targetID, in: snapshot) else { return }
let typed = editor.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let newTitle: String? = typed.isEmpty ? nil : typed
guard newTitle != target.title else { return }
var folder = rootURL.appendingPathComponent(target.laneID.rawValue)
if let cardID = target.cardID {
folder.append(component: cardID.rawValue)
}
try? performWrite { () throws(BoardWriteError) -> Void in
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so the
// banner names the item by the title it still has rather than the one that failed to
// land (see `WriteOperation.rename`).
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let newTitle {
document.set(FrontmatterKeys.title, to: .string(newTitle))
} else {
document.remove(FrontmatterKeys.title)
}
}
}
}
/// Where a live item lives and what it is currently called, or `nil` when the id names nothing
/// the board renders.
///
/// **Effective liveness, ancestor-walked** — the same rule `CardWindowHost.cardWindowFate`
/// applies to a card window and `ItemReferenceSet` applies to the selection: a card under a
/// tombstoned lane renders nowhere, so it is as gone as a deleted one. The path is returned as
/// its two identity components rather than as a URL so the caller builds it off the store's
/// *current* `rootURL`, which a mid-session folder rename may have moved.
nonisolated static func liveItem(
_ id: ItemID,
in snapshot: BoardModel
) -> (laneID: ItemID, cardID: ItemID?, title: String?)? {
for lane in snapshot.lanes where !lane.isDeleted {
if lane.id == id {
return (laneID: lane.id, cardID: nil, title: lane.title.value)
}
if let card = lane.cards.first(where: { $0.id == id && !$0.isDeleted }) {
return (laneID: lane.id, cardID: card.id, title: card.title.value)
}
}
return nil
}
// MARK: - Board rename
/// Writes the board's own `title` — the board popover's rename field (03-board-ui.md § Board
/// popover), and the one rename in the app with no item to aim at.
///
/// **It edits frontmatter, never the folder**: "Rename edits the board's frontmatter `title`
/// only — the folder is never renamed by the app; the Finder document name is Finder's to
/// change" (§ Board popover, 01-storage-format.md § Board naming). The app's display name and
/// the Finder document name may therefore diverge, which is accepted rather than reconciled.
///
/// The three commit rules are `commitRename`'s, deliberately identical — one rename vocabulary
/// whatever level it is aimed at:
///
/// - **Trimmed**, so a title of three spaces is a slip rather than a name.
/// - **An empty commit removes the key.** A board with no `title` falls back to its *folder
/// name* (§ Board naming) — never the "Untitled" placeholder cards and lanes show, and never
/// `title: ""`, which would be a real if blank title with nothing to fall back to.
/// - **An unchanged title writes nothing**, so a popover opened and dismissed with Return
/// neither stamps `modified` nor mints a commit.
///
/// There is no vanished-target guard, because a board cannot tombstone itself out of its own
/// window (01-storage-format.md § Deletion): the only way this target goes away is the root
/// itself vanishing, which is the read-only lock's story, and `performWrite` refuses under it
/// before anything touches disk.
public func renameBoard(_ title: String?) {
let typed = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let newTitle: String? = typed.isEmpty ? nil : typed
guard newTitle != snapshot.title.value else { return }
let folder = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so a
// refusal names the board by the title it still has (see `WriteOperation.rename`).
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let newTitle {
document.set(FrontmatterKeys.title, to: .string(newTitle))
} else {
document.remove(FrontmatterKeys.title)
}
}
}
}
// MARK: - Lane reorder
/// Commits a lane drag: `id` lands at display position `index` among the board's live lanes,
/// counted **with the dragged lane itself removed** — which is the index
/// `DropSlotMath.laneSlot` produces.
///
/// Within-board only. A cross-board lane drag is the locality model's (04-interactions.md ▸
/// Drag and drop) and belongs to m5's drag card; here source and destination board roots are
/// the same URL, so `moveItem` takes its same-parent degenerate-reorder path and rewrites
/// exactly one file — the moved lane's `order`.
///
/// **A drag that ends where it started writes nothing**: `index == from` re-inserts the lane in
/// its own slot, and a no-op must not stamp `modified` or mint a commit — the resize drag's
/// rule, and for the same reason.
public func moveLane(_ id: ItemID, toIndex index: Int) {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
guard let from = lanes.firstIndex(where: { $0.id == id }) else { return }
var remaining = lanes
remaining.remove(at: from)
let target = min(max(0, index), remaining.count)
guard target != from else { return }
let root = rootURL
let folder = root.appendingPathComponent(id.rawValue)
try? performWrite { () throws(BoardWriteError) -> Void in
var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target)
if rank == nil {
// Compact and place again. Unlike the card case the dragged lane *is* among the
// renumbered children — it is a real folder on disk — so its fresh rank is dropped
// from the ladder before the neighbours are consulted.
try BoardWriter.renumberVisibleChildren(of: root)
var compacted = Ranks.renumbered(count: lanes.count)
compacted.remove(at: from)
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
}
guard let rank else { return }
_ = try BoardWriter.moveItem(
at: folder,
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
/// The within-board **lane drag**, multi-drag included: `ids` land contiguously at display
/// position `index` among the board's live lanes, counted with the dragged run removed — the
/// index `DropSlotMath.laneSlot` produces.
///
/// `moveLane`'s plural, and it exists rather than a loop over it because "one `performWrite`
/// bracket per gesture whatever the set's size" is load-bearing (DRAG-REORDER.md § The drop
/// commits): one app-mediated reload, and on git boards one commit rather than N.
///
/// The run keeps **board order**, which is the lane level's flatten order — a multi-lane drag has
/// no other relative order to preserve.
///
/// A drag that changes nothing writes nothing, stated as the arrangement rather than as a special
/// case: if the strip would render exactly what it renders now, no rank is rewritten and no
/// commit is minted.
public func moveLanes(_ ids: Set<ItemID>, toIndex index: Int) {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
let members = lanes.filter { ids.contains($0.id) }
guard !members.isEmpty else { return }
let remaining = lanes.filter { !ids.contains($0.id) }
let target = min(max(0, index), remaining.count)
guard DropSlotMath.applied(lanes.map(\.id), moving: members.map(\.id), to: target) != lanes.map(\.id)
else { return }
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
if ranks == nil {
// Compact and place again. The dragged lanes *are* among the renumbered children —
// they are real folders on disk — so their fresh rungs are dropped from the ladder
// before the neighbours are consulted, exactly as `moveLane` drops its one.
try BoardWriter.renumberVisibleChildren(of: root)
let compacted = zip(lanes, Ranks.renumbered(count: lanes.count))
.filter { !ids.contains($0.0.id) }
.map(\.1)
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
}
guard let ranks else { return }
for (member, rank) in zip(members, ranks) {
_ = try BoardWriter.moveItem(
at: root.appendingPathComponent(member.id.rawValue, isDirectory: true),
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
}
// MARK: - Drag & drop commits
// The writes a released drag performs (04-interactions.md ▸ Drag and drop; the geometry that
// produces their `index` is DRAG-REORDER.md's, implemented in `DropSlotMath`).
//
// **One `performWrite` bracket per gesture**, whatever the set's size — the style batch's and
// the tombstone batch's rule, for their reason: one gesture, one app-mediated reload, one commit
// on git boards.
//
// **`index` always means the same thing**: a position among the destination's *rendered* items
// counted with the dragged run already removed — the resting layout's own convention, so the
// number the geometry produced is the number these methods consume, unrewritten. Every one of
// them clamps it rather than trusting it: a proposal computed against a snapshot one reload old
// must not trap.
//
// **Ranks are inserted, never permuted.** A drop rewrites only the dragged items' `order`, so
// the siblings' files — and `modified`, and a git commit — stay honest about what actually
// moved. That is the one place these differ from `sortSelection`, which permutes because its
// gesture is a permutation. `Ranks.insertionRanks` answering `nil` is the renumber trigger, and
// the fallback is `moveLane`'s: compact the destination, then place against the fresh ladder.
//
// **Silent no-ops throughout**, all of them the reload being the authority rather than the
// gesture: a destination lane that is gone or tombstoned (04's "a card is never filed under a
// `deleted:` parent"), a dragged set emptied by a foreign reload, and a drop that lands exactly
// where everything already is (a drag that ends where it started must not stamp `modified` or
// mint a commit — the resize drag's rule).
/// One member of a dragged card set, resolved against the snapshot: which lane holds it *now*.
private struct DraggedCard {
let id: ItemID
let laneID: ItemID
}
/// `ids` narrowed to live cards under live lanes and sorted into **flatten order** — "lane
/// `order` first, then card `order`" (`SelectionGrammar.liveCards`), which is what "drop inserts
/// contiguously in preserved relative order" means and the only order a `Set` cannot supply.
///
/// Members that vanished or flipped liveness since the drag began are simply absent: drag
/// membership is a UUID set that vanished items leave silently (02-architecture.md), and "partial
/// vanishing drops the survivors" is the design's own wording.
private func draggedCards(_ ids: Set<ItemID>) -> [DraggedCard] {
var lanes: [ItemID: ItemID] = [:]
for lane in snapshot.lanes where !lane.isDeleted {
for card in lane.cards where !card.isDeleted {
lanes[card.id] = lane.id
}
}
return SelectionGrammar.liveCards(in: snapshot)
.filter { ids.contains($0) }
.compactMap { id in lanes[id].map { DraggedCard(id: id, laneID: $0) } }
}
/// The within-board card drop: `ids` land contiguously at logical position `index` among
/// `laneID`'s rendered cards, in flatten order.
///
/// **Uniformly `moveItem`, cross-lane members and same-lane ones alike.** A member already in
/// the destination takes the writer's same-parent degenerate path, which rewrites exactly one
/// file — its `order` — and never touches the filesystem; a member arriving from another lane
/// moves its folder and carries the same explicit rank. That is `moveItem`'s own promise ("a
/// drop that lands back in its own lane is the same gesture as one that lands elsewhere"), and
/// leaning on it is what keeps this method from growing two branches that could disagree about
/// ordering.
///
/// The selection is deliberately untouched: every id survives the move, and the cards the user
/// is dragging should stay the cards the user is dragging.
public func moveCards(_ ids: Set<ItemID>, toLane laneID: ItemID, at index: Int) {
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return }
let members = draggedCards(ids)
guard !members.isEmpty else { return }
let rendered = destination.cards.filter { !$0.isDeleted }
let memberIDs = members.map(\.id)
let remaining = rendered.filter { !ids.contains($0.id) }
let target = min(max(0, index), remaining.count)
// The no-op guard, stated as the arrangement rather than as a special case: if the lane
// would render exactly what it renders now, nothing moved. A member sitting in another lane
// makes the two lists differ by construction, so this covers the cross-lane case too.
guard DropSlotMath.applied(rendered.map(\.id), moving: memberIDs, to: target) != rendered.map(\.id)
else { return }
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
if ranks == nil {
// Compact and place again. The renumber assigns in display order over the lane's
// *live* cards, so the compacted ladder lines up one-for-one with `rendered`; the
// members already in this lane are dropped from it before the neighbours are
// consulted, exactly as `moveLane` drops the dragged lane's own rung.
try BoardWriter.renumberVisibleChildren(of: laneFolder)
let compacted = zip(rendered, Ranks.renumbered(count: rendered.count))
.filter { !ids.contains($0.0.id) }
.map(\.1)
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
}
guard let ranks else { return }
for (member, rank) in zip(members, ranks) {
let folder = root
.appendingPathComponent(member.laneID.rawValue, isDirectory: true)
.appendingPathComponent(member.id.rawValue, isDirectory: true)
_ = try BoardWriter.moveItem(
at: folder,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
}
/// The within-board ⌥-drag: fresh-GUID duplicates of `ids` land contiguously at `index` among
/// `laneID`'s rendered cards, **originals untouched** (04-interactions.md ▸ Drag and drop:
/// "originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop").
/// `created` survives because a copy is a fork — `CopyStamps.fork`, the same stamps paste uses.
///
/// **The ranks are placed among the lane's *full* rendered set**, not among the set with the
/// dragged members removed — the one place a copy's arithmetic differs from a move's. The
/// originals are lifted out of the layout for the duration of the drag whatever the effective
/// operation is (⌥ can be pressed and released mid-drag; a layout that re-admitted them on every
/// flip would flap the whole board), but they are still *on disk* holding their ranks, and a
/// rank chosen in the gap they appear to have vacated would collide with them the instant they
/// reappear. So the drop's index is mapped through to the neighbour it names — the card the run
/// lands in front of — and the rank is taken there.
public func copyCards(_ ids: Set<ItemID>, toLane laneID: ItemID, at index: Int) {
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return }
let members = draggedCards(ids)
guard !members.isEmpty else { return }
let rendered = destination.cards.filter { !$0.isDeleted }
let remaining = rendered.filter { !ids.contains($0.id) }
let target = min(max(0, index), remaining.count)
// The resting-layout index, re-read against the layout the originals are still part of.
let placement = target < remaining.count
? (rendered.firstIndex { $0.id == remaining[target].id } ?? rendered.count)
: rendered.count
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: placement, count: members.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: placement,
count: members.count
)
}
guard let ranks else { return }
for (member, rank) in zip(members, ranks) {
let folder = root
.appendingPathComponent(member.laneID.rawValue, isDirectory: true)
.appendingPathComponent(member.id.rawValue, isDirectory: true)
_ = try BoardWriter.copyItem(at: folder, toParent: laneFolder, order: rank, stamps: .fork)
}
}
}
// MARK: - Cross-board arrivals
//
// Executed by the **destination** store, inside *its* bracket, because the destination is where
// the write's effects have to round-trip. A move mutates the source board's tree outside that
// board's own bracket, which is correct and needs no coordination: the source store's watcher
// sees a foreign change and reloads, which is exactly what a foreign change is.
//
// `sources` are the items' folder URLs in the source board — both boards are open in this app,
// so both roots are already security-scoped and the payload can carry plain URLs. The source
// board root is read back off the path rather than passed alongside: 01-storage-format.md's
// fractal layout fixes the depth (`<root>/<lane>` and `<root>/<lane>/<card>`), so the URL
// already carries it and a second parameter could only ever disagree with the first.
/// The board root a lane folder sits directly under.
nonisolated static func boardRoot(ofLaneFolder folder: URL) -> URL {
folder.deletingLastPathComponent()
}
/// The board root a card folder sits two levels under.
nonisolated static func boardRoot(ofCardFolder folder: URL) -> URL {
folder.deletingLastPathComponent().deletingLastPathComponent()
}
/// Where one arriving item's bytes come from.
///
/// **Two producers, one arrival path.** A drag names folders in the source board; a paste names
/// folders in the clipboard's staging directory — and, when that snapshot is missing or
/// unreadable, the manifest's embedded `index.md` instead (04-interactions.md ▸ Clipboard's
/// staging-less fallback). Modelling the fallback as a second kind of *source* rather than as a
/// second arrival method is what keeps the rank insertion, the tombstone stripping and the
/// `deleted:` clearing stated once: everything downstream of "where do the bytes come from" is
/// identical, and a paste that half-falls-back mixes the two cases inside one bracket.
public enum ItemSource: Sendable, Equatable {
/// A folder on disk — the source board's own, or a staged snapshot of it.
case folder(URL)
/// The manifest's embedded text: the item's `index.md`, and (for a lane) its cards'.
/// Materialized by `BoardWriter.materializeItem`, byte-faithfully.
case text(index: String, cards: [String])
}
/// A cross-board card drop, landing contiguously at `index` among `laneID`'s rendered cards.
///
/// - `.copy` (the default between boards) — `copyItem` per folder: fresh GUIDs throughout,
/// `created` kept, originals untouched. Copies mint by construction, so the import boundary's
/// collision question never arises.
/// - `.move` (⌘-drag) — `moveItem` per folder: identity travels, and the import boundary remints
/// **only** the folders whose UUID the destination board already holds, per folder at the
/// finest grain (01-storage-format.md's per-folder degradation, which is `moveItem`'s own
/// behaviour rather than something this method arranges).
public func receiveCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: false)
}
/// **The clipboard's card arrival** — `receiveCards`/`receiveRestoredCards` with the two axes a
/// paste varies independently (04-interactions.md ▸ Clipboard).
///
/// It is the same commit as a drop's, deliberately: `.copy` materializes from the staged snapshot
/// (or, per entry, from the embedded `index.md`), `.move` is the armed cut's — "the ⌘-drag move
/// path — identity travels" — and `clearingTombstones` is the trash's copy-out rule, "`deleted:`
/// is stripped **at materialization**". A cut is live-only (⌘X is disabled in the trash), so the
/// two flags never both fire; the parameter is not narrowed for that, because which of them is
/// reachable is the *clipboard's* rule and this method's job is only to obey both.
public func receiveCards(
_ sources: [ItemSource],
operation: TransferOperation,
toLane laneID: ItemID,
at index: Int,
clearingTombstones: Bool
) {
receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: clearingTombstones)
}
/// The cross-board half of drag-to-restore (04-interactions.md ▸ The trash): tombstoned rows
/// dropped on *another* board.
///
/// Identical to `receiveCards` but for one extra write per arrival — `deleted:` is removed once
/// the folder is at its destination, so what lands is **live**, "like copying a file out of
/// Finder's Trash". The two cases the design names fall straight out of the operation:
///
/// - `.copy` (the default) — a live copy lands here and the tombstoned original stays in the
/// source board's trash, exactly as ⌘C out of the trash behaves.
/// - `.move` (⌘-drag) — the true cross-board restore-move: the tombstone leaves the source
/// board entirely, ordinary cross-board move semantics apply, and `deleted:` is cleared at the
/// destination.
///
/// The strip is a second `updateIndex` rather than a flag on the first because the arrival's
/// `order` is written by `copyItem`/`moveItem` before this store has a folder to point at, and
/// because `restoreItem` is already the one expression in the app for "remove the `deleted:`
/// key" — the bytes are never rewritten any other way.
public func receiveRestoredCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: true)
}
private func receive(
_ sources: [ItemSource],
operation: TransferOperation,
toLane laneID: ItemID,
at index: Int,
clearingTombstones: Bool
) {
guard !sources.isEmpty,
let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
else { return }
let rendered = destination.cards.filter { !$0.isDeleted }
let target = min(max(0, index), rendered.count)
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: target,
count: sources.count
)
}
guard let ranks else { return }
for (source, rank) in zip(sources, ranks) {
guard let arrived = try Self.materialize(
source,
operation: operation,
intoParent: laneFolder,
destinationBoardRoot: root,
sourceBoardRoot: Self.boardRoot(ofCardFolder:),
order: rank
) else { continue }
guard clearingTombstones else { continue }
try BoardWriter.restoreItem(at: laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true))
}
}
}
/// One arrival's materialization — the two `ItemSource` kinds crossed with the two operations,
/// in the one place both the card path and the lane path can share.
///
/// **`.move` of a `.text` source is unreachable and answers `nil`.** A move needs a folder whose
/// identity travels, and the only producer of text sources is the clipboard's fallback, which is
/// a *copy* by construction (04-interactions.md ▸ Clipboard: an armed cut moves the surviving
/// originals, and a cut that cannot find them is void). Skipping is the standing posture for an
/// arrival that names nothing — the same silent no-op every other drop commit gives a source that
/// has gone.
private static func materialize(
_ source: ItemSource,
operation: TransferOperation,
intoParent parent: URL,
destinationBoardRoot: URL,
sourceBoardRoot: (URL) -> URL,
order: Double
) throws(BoardWriteError) -> ItemID? {
switch (source, operation) {
case let (.folder(folder), .copy):
return try BoardWriter.copyItem(at: folder, toParent: parent, order: order, stamps: .fork)
case let (.folder(folder), .move):
return try BoardWriter.moveItem(
at: folder,
toParent: parent,
sourceBoardRoot: sourceBoardRoot(folder),
destinationBoardRoot: destinationBoardRoot,
order: order
).id
case let (.text(index, cards), .copy):
return try BoardWriter.materializeItem(
inParent: parent,
indexText: index,
children: cards,
order: order
)
case (.text, .move):
return nil
}
}
/// A cross-board lane drop, landing contiguously at `stripIndex` among this board's live lanes.
///
/// The two operations differ in exactly one place beyond identity, and it is 04-interactions.md
/// ▸ Drag and drop's rule:
///
/// - `.copy` — "Lanes copy cards and all", then **the copy strips tombstoned cards**: the copy
/// transfers content, and trash isn't content (09-templates.md's instantiation precedent — a
/// board isn't born with trash). The tombstoned originals stay recoverable in the source
/// board. `copyItem` offers no filter hook — it copies the tree verbatim by design, which is
/// what makes attachments and strays arrive byte-identical — so the strip is the line after
/// (`BoardWriter.stripTombstonedChildren`), pointed at a folder minted seconds earlier.
/// - `.move` — "A ⌘-drag *move* carries them whole — the folder moves as-is, and they land in
/// the destination's trash." Nothing to arrange: a move never reads below its root, so the
/// tombstones travel and the destination's trash quasi-lane renders them.
///
/// Within-board lane reorders are `moveLane(_:toIndex:)`, and a within-board lane *copy* does
/// not exist by drag at all (⌥ is ignored on lane drags; the clipboard is that operation's one
/// home), so this method is cross-board by construction.
public func receiveLanes(_ sources: [URL], operation: TransferOperation, at stripIndex: Int) {
receiveLanes(sources.map(ItemSource.folder), operation: operation, at: stripIndex, clearingTombstones: false)
}
/// **The clipboard's lane arrival** — `receiveLanes` with the staging-less fallback and the
/// trash's copy-out rule folded in (04-interactions.md ▸ Clipboard, ▸ The trash).
///
/// The two operations keep their drag semantics exactly, because 04 says they are the same
/// semantics: "a pasted *copy* takes fresh GUIDs throughout and **strips tombstoned cards**; a
/// cut-paste is the ⌘-drag move — the folder moves whole, tombstoned cards landing in the
/// destination's trash". `clearingTombstones` adds the one thing a drag never asks for: a lane
/// *entry* copied out of the trash arrives live, its own `deleted:` removed once it is at the
/// destination — the lane-level twin of `receiveRestoredCards`, and the reason the strip runs
/// first is that the two writes touch different files and the strip's target list is the one that
/// must be read before anything is rewritten.
public func receiveLanes(
_ sources: [ItemSource],
operation: TransferOperation,
at stripIndex: Int,
clearingTombstones: Bool
) {
guard !sources.isEmpty else { return }
let root = rootURL
let rendered = snapshot.lanes.filter { !$0.isDeleted }
let target = min(max(0, stripIndex), rendered.count)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: root)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: target,
count: sources.count
)
}
guard let ranks else { return }
for (source, rank) in zip(sources, ranks) {
guard let arrived = try Self.materialize(
source,
operation: operation,
intoParent: root,
destinationBoardRoot: root,
sourceBoardRoot: Self.boardRoot(ofLaneFolder:),
order: rank
) else { continue }
let laneFolder = root.appendingPathComponent(arrived.rawValue, isDirectory: true)
// The strip belongs to the copy alone: "a move never reads below its root, so the
// tombstones travel and the destination's trash quasi-lane renders them".
if operation == .copy {
try BoardWriter.stripTombstonedChildren(of: laneFolder)
}
if clearingTombstones {
try BoardWriter.restoreItem(at: laneFolder)
}
}
}
}
// MARK: - Finder file drops
// The writes an external Finder file drag performs (04-interactions.md ▸ Drag and drop, "Files
// from Finder"): onto a card the files join its `attachments/`, onto lane empty space they become
// one card each. The gesture's half — which card, which slot — is `BoardDropContext`'s; these are
// ordinary store writes, with the drop commits' own rules above (one `performWrite` bracket per
// gesture; a vanished or tombstoned destination is a silent no-op, the reload being the
// authority; failures are the banner's).
//
// **Folders never arrive here from a drop.** "Folders are refused at hover" (04-interactions.md):
// the gesture refuses a folders-only drag outright and `FinderDrop.land` partitions a mixed one
// before it calls either of these, naming the skipped folders in a loss row. Both functions stay
// honest about a directory anyway — `BoardWriter.importAttachments` refuses one by design — since
// nothing about their contract says a drop is the only caller.
/// Copies `urls` into `cardID`'s `attachments/` — the drop-on-a-card half.
///
/// **Liveness is ancestor-walked** (`liveItem`): a card under a tombstoned lane renders nowhere,
/// so it is as gone as a deleted one, and a drop on a target that vanished under the gesture
/// writes nothing at all. That is also the whole of "Finder file drops on tombstoned cards are
/// inert" (04-interactions.md ▸ The trash) on the write side — the gesture refuses to propose one
/// in the first place, and this refuses to serve one that slipped through a reload.
///
/// A lane id is refused for the same reason a lane folder is: attachments belong to cards.
/// Multi-file, any type, and a name already taken is renamed Finder-style rather than
/// overwritten — all `BoardWriter.importAttachments`', including its failure shape: the first
/// failing file stops the batch and banners naming it, and everything already copied stays.
public func importAttachments(_ urls: [URL], toCard cardID: ItemID) {
guard !urls.isEmpty,
let item = Self.liveItem(cardID, in: snapshot),
let card = item.cardID
else { return }
let folder = rootURL
.appendingPathComponent(item.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.importAttachments(urls, intoCard: folder)
}
}
/// Creates one card per file at `index` in `laneID`, each titled with its filename minus the
/// extension and carrying that file as its attachment — the drop-on-empty-space half.
///
/// **Ordinary store writes, with no drop-only path**: a fresh GUID and an inserted rank per card
/// (`Ranks.insertionRanks`, compacting and placing again when midpoint precision is exhausted,
/// exactly as `moveCards` does), then the m2 import machinery for the file. The whole batch is one
/// `performWrite` bracket, so a five-file drop rounds back as one reload and one commit.
///
/// **The title follows the empty-title rules**: a name that trims to nothing — a dotfile whose
/// stem is blank, a file called `" .png"` — writes no `title` key at all rather than an empty
/// string, since a missing key is the untitled state and `""` would be a real, blank title
/// (01-storage-format.md § Frontmatter).
///
/// **Partial failure is honest, and leaves no half-made card.** The batch stops at the first file
/// that cannot be imported — an unreadable source, a vanished one, a disk with no room left —
/// which banners naming it; the cards already made keep their files, matching `importAttachments`'
/// own "everything already imported stays landed". The card whose import failed is removed again
/// before the throw: it was minted moments earlier in this same bracket and holds nothing but
/// what this call put there, and "creating-then-abandoning never leaves an empty card behind"
/// (04-interactions.md ▸ Grammar) is the rule it would otherwise break.
public func createCards(fromFiles urls: [URL], inLane laneID: ItemID, at index: Int) {
guard !urls.isEmpty,
let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
else { return }
let rendered = lane.cards.filter { !$0.isDeleted }
let target = min(max(0, index), rendered.count)
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(
amongVisible: rendered.map(\.order), at: target, count: urls.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count), at: target, count: urls.count)
}
guard let ranks else { return }
for (url, rank) in zip(urls, ranks) {
// Create then place, `commitPlaceholder`'s pair: the Writer's create appends after the
// visible siblings by contract, and the rank rides its same-parent degenerate reorder
// inside this same bracket rather than widening the create's signature.
let id = try BoardWriter.createCard(inLane: laneFolder, title: Self.cardTitle(forFile: url))
let folder = laneFolder.appendingPathComponent(id.rawValue, isDirectory: true)
do throws(BoardWriteError) {
_ = try BoardWriter.moveItem(
at: folder,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
_ = try BoardWriter.importAttachments([url], intoCard: folder)
} catch {
try? FileManager.default.removeItem(at: folder)
throw error
}
}
}
}
/// The title a dropped file's card takes: **the filename without its extension**
/// (04-interactions.md ▸ Drag and drop), or `nil` — no `title` key — when that trims to nothing.
///
/// The split is `URL`'s own, which is also Finder's: an extension-less name keeps all of itself,
/// and a multi-dot name loses only the last component (`archive.tar.gz` → `archive.tar`), matching
/// the collision-rename rule the same file's attachment goes through.
nonisolated static func cardTitle(forFile url: URL) -> String? {
let stem = url.deletingPathExtension().lastPathComponent
.trimmingCharacters(in: .whitespacesAndNewlines)
return stem.isEmpty ? nil : stem
}
// MARK: - Within-lane sort
/// The lane and the new card ordering one ⌥⌘↑/⌥⌘↓ press would produce, or `nil` when the press
/// is not available — **the menu items' `disabled` condition and the write's guard, as one
/// answer** (`LaneWidthCommands`' rule).
///
/// `nil` covers every refusal the design names in one expression: an empty or tombstoned
/// selection ("⌥⌘↑/⌥⌘↓ are inert *on* tombstoned cards"), a lane selection ("with a lane
/// selected … ⌥⌘↑/⌥⌘↓ are inert"), a card selection that **spans lanes** ("cards never change
/// lanes by ⌘-arrow … so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes"), and a block
/// already at the end of its lane.
func sortPlan(_ direction: SortMath.Direction) -> (lane: Lane, ordering: [ItemID])? {
let selection = transient.selection
guard selection.liveness == .live,
SelectionGrammar.kind(of: selection, in: snapshot) == .card,
// `nil` here *is* the spans-lanes case: the helper answers only when one lane holds
// the whole set.
let laneID = Self.lane(holding: selection.ids, in: snapshot),
let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
else { return nil }
let rendered = lane.cards.filter { !$0.isDeleted }.map(\.id)
guard let ordering = SortMath.reordered(rendered, moving: selection.ids, direction) else { return nil }
return (lane, ordering)
}
/// Board ▸ Move Up / Move Down (⌥⌘↑/⌥⌘↓) — the within-lane sort (04-interactions.md ▸ The map).
///
/// **One `performWrite` bracket**, like every other batch here: one gesture, one app-mediated
/// reload, one commit on git boards.
///
/// **The ranks are permuted, not invented.** The lane's existing `order` values, read in display
/// order, are already a sorted ladder of exactly the right length — so the new ordering takes
/// them rung for rung and only the cards whose *position* changed are rewritten. A block stepping
/// past one sibling therefore touches the block plus that sibling and nothing else, which is what
/// keeps `modified` (and, later, a git commit) honest about what actually moved.
///
/// The one case that ladder cannot serve is **duplicate `order` values**, where display order is
/// decided by the folder-name tie-break (`Ranks.isOrderedForDisplay`) rather than by the rank —
/// permuting equal ranks would write the file and leave the board looking identical. That is the
/// renumber trigger, exactly as an exhausted midpoint is elsewhere: compact the lane, then place
/// against the fresh ladder (`commitPlaceholder`'s and `moveLane`'s pattern).
///
/// The selection, the anchor and the head are deliberately untouched: every id survives, and the
/// cards the user is moving should stay the cards the user is moving.
public func sortSelection(_ direction: SortMath.Direction) {
guard let plan = sortPlan(direction) else { return }
let rendered = plan.lane.cards.filter { !$0.isDeleted }
let laneFolder = rootURL.appendingPathComponent(plan.lane.id.rawValue, isDirectory: true)
let orders = rendered.map(\.order)
let positions = Dictionary(uniqueKeysWithValues: rendered.enumerated().map { ($1.id, $0) })
try? performWrite { () throws(BoardWriteError) -> Void in
var ladder = orders
if !Self.isStrictlyAscending(orders) {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
// The renumber assigns in display order, so the compacted ladder lines up one-for-one
// with `rendered` — the same alignment `commitPlaceholder` relies on.
ladder = Ranks.renumbered(count: rendered.count)
}
for (destination, id) in plan.ordering.enumerated() {
guard let origin = positions[id], origin != destination else { continue }
let rank = ladder[destination]
try BoardWriter.updateIndex(
inItemFolder: laneFolder.appendingPathComponent(id.rawValue, isDirectory: true),
// `.reorder(title: nil)`: `updateIndex` enriches it off the document it reads, so
// a failure names the card by its own title.
operation: .reorder(title: nil)
) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
}
}
}
}
/// Whether a lane's ranks separate its cards on their own — the condition under which they can
/// be permuted rather than replaced. Ties fall to the folder-name tie-break, which a permutation
/// cannot reach past.
nonisolated static func isStrictlyAscending(_ orders: [Double]) -> Bool {
zip(orders, orders.dropFirst()).allSatisfy { $0 < $1 }
}
// MARK: - The trash
/// Whether physically removing an item on this board destroys the only copy of it — and
/// therefore whether Delete Immediately stands an alert between one keystroke and unrecoverable
/// deletion (03-board-ui.md § Trash, "Delete Immediately confirms exactly where the loss is
/// real").
///
/// **Every board is `true` today**, because every board is history mode *none*: nothing in the
/// app keeps a second copy, so a purge is final everywhere.
///
// m7-git: git boards answer `false` here — "on git boards it acts immediately, since the content
// remains reachable in history" (06-history-undo.md's delete-never-forgets). Repo-nested boards
// stay `true` alongside mode none: the app manages no history for them either. The named
// predicate exists now so the committer card changes one expression rather than hunting the
// confirmation logic out of two menu items and an alert.
public var purgeIsUnrecoverable: Bool { true }
/// Tombstones the current selection — File ▸ Delete ⌘⌫ and its plain-⌫ grammar twin
/// (04-interactions.md ▸ The map, 11-command-nexus.md).
///
/// A convenience over `delete(_:)` so the two call sites cannot disagree about *what* the
/// command acts on.
public func deleteSelection() {
delete(selection.ids)
}
/// Tombstones every live item in `ids` — cards or lanes, in one bracket.
///
/// **One `performWrite` whatever the set's size**, matching the style batch's rule and for its
/// reason: one gesture, one app-mediated reload, and (on git boards) one commit rather than N.
/// A lane's tombstone rewrites only the lane's own `index.md` — hiding the subtree is the
/// renderer's ancestor walk, not a stored flag (`BoardWriter.deleteItem`).
///
/// **Tombstoned ids are silently skipped**, not refused: the paths are resolved on the live side
/// only, so a selection the next reload will drop writes nothing rather than re-stamping a
/// `deleted:` that is already there. An empty resolution never opens the bracket at all.
///
/// **The selection moves to the successor sibling** — 04-interactions.md ▸ The map's Finder-style
/// rule ("next card in the lane, next lane on the board; the last sibling's predecessor
/// otherwise; empty container = nothing selected"), whose whole point is that "repeated ⌫ walks
/// down a lane".
///
/// Two things make that hold. The successor is computed from the **pre-write** snapshot, which is
/// the last one that still knows where the doomed items sat; and it is selected **immediately**,
/// rather than waiting for the reload the tombstone will echo back — a second ⌫ pressed before
/// the watcher rounds the first one back must already have somewhere to land.
///
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
/// reload-survival rule), and neither do `putBack`/`deleteImmediately` — the item merely changed
/// sides, or nothing survives on either.
public func delete(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
// The successor is drawn from what the lane is *showing*, so a delete under an active search
// walks the filtered lane rather than selecting a card the query has hidden.
let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot, filter: searchFilter)
tombstone(folders)
if let successor {
select([successor], liveness: .live, anchor: successor, head: successor)
} else {
clearSelection()
}
}
/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28): "the drag
/// becomes the pointer's delete gesture — release tombstones the dragged card(s), exactly the ⌫
/// tombstone".
///
/// *Exactly* the ⌫ tombstone is a claim about the disk, and `tombstone(_:)` is what makes it
/// structural rather than a matter of two call sites staying in step: one write op, one bracket,
/// one set of stamps, so a card deleted by drop and a card deleted by keystroke are
/// byte-indistinguishable afterwards (`TrashDropWriteTests`).
///
/// ### The one thing it does not share is the successor
///
/// ⌫ moves the selection to the deleted item's successor sibling because *the selection* lost its
/// cards and "repeated ⌫ walks down a lane" — the rule exists to keep a keyboard gesture
/// repeatable. A drag has no such continuation, and its run is **not necessarily the selection at
/// all**: dragging a card outside the selection drags that card alone and leaves the selection
/// exactly where it was (`LaneView.startCardDrag`), so picking a successor for it would re-point a
/// selection that never lost anything.
///
/// So this writes and says nothing about the selection, and the ordinary reload does the rest: a
/// live-side set ejects members that flip to tombstoned, as the vanish it is (02-architecture.md's
/// reload-survival rule). Drag the selection itself onto the trash and the selection empties;
/// drag something else and it is untouched. Neither case needs surgery here.
///
/// Cards only, by the gesture's own gate (`TrashDrop.accepts`) — but nothing here depends on
/// that: the paths resolve on the live side exactly as `delete(_:)`'s do.
public func deleteByDrag(cardIDs: [ItemID]) {
let folders = TrashModel.paths(of: Set(cardIDs), on: .live, in: snapshot)
.map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
tombstone(folders)
}
/// The tombstone write itself — **one `performWrite` bracket, whatever the set's size and
/// whichever gesture asked** (DRAG-REORDER.md § The drop commits; the style batch's rule).
///
/// Spelled once so ⌫ and drop-on-trash cannot drift apart on disk; everything that differs
/// between them is about the *selection*, and lives in the callers.
private func tombstone(_ folders: [URL]) {
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.deleteItem(at: folder)
}
}
}
/// Put Back: removes `deleted:` from every tombstoned item in `ids`, in one bracket
/// (03-board-ui.md § Trash).
///
/// **Restore fidelity is perfect because nothing ever moved** — the item re-enters the visible
/// set at its recorded `order` among its current siblings, and the folder is exactly where it
/// has been all along (`BoardWriter.restoreItem`).
///
/// **Putting back a lane splits its contents by flag for free.** The write is the lane's own
/// `index.md` and nothing else, so cards hidden *with* the lane return with it while cards
/// carrying their own `deleted:` stay tombstoned — and their rows reappear in the trash, which
/// is exactly the two-step recovery the design settled on.
///
/// A card whose lane is itself tombstoned is **not reachable here at all**, by construction
/// rather than by the UI happening not to offer it: it has no trash row, and the trashed side of
/// `TrashModel.paths` is that row set exactly (`Liveness.walk`). Recovering it stays the two-step
/// the design settled on — put the lane back, then put the card back from the row it regains.
///
/// The selection is deliberately left alone: the restored items flip liveness, and the reload's
/// resolve rule ejects them from a `.trashed` set as a vanish — the same silent shrink an
/// external restore would produce.
public func putBack(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .trashed, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.restoreItem(at: folder)
}
}
}
/// Delete Immediately ⌥⌘⌫: physically removes every tombstoned item in `ids` (03-board-ui.md §
/// Trash), in one bracket.
///
/// **The confirmation is not here.** Whether the loss is real is `purgeIsUnrecoverable`'s
/// question and the alert is the window's; a store method that put up its own dialog could not
/// be driven from a test, and the same purge is reached by two surfaces (the menu item and the
/// trash row's context menu) that must not each grow their own copy of the rule.
///
/// Purging a **lane** takes its whole folder — every card inside it, tombstoned or not. That is
/// what the lane entry subsuming its subtree means on disk.
public func deleteImmediately(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .trashed, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.purgeItem(at: folder)
}
}
// Nothing the set named exists any more, on either side of the boundary — unlike Put Back,
// where the items merely changed sides, there is no vanish for the reload to notice on the
// trashed side that would not equally be a vanish here.
clearSelection()
}
/// Empty Trash… ⇧⌘⌫: purges **every** tombstone on the board, in one bracket.
///
/// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the targets come
/// from the snapshot, never from the filtered view — "a bulk command about the trash itself never
/// silently narrows to the visible subset". The filter does not reach this method at all, which
/// is the strongest form of that guarantee.
///
/// Cards carrying their own `deleted:` under a tombstoned lane go too, without being listed:
/// they live inside the lane folder this removes (`TrashModel.emptyTrashTargets`).
public func emptyTrash() {
let folders = TrashModel.emptyTrashTargets(in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.purgeItem(at: folder)
}
}
clearSelection()
}
/// Drag-to-restore: a tombstoned card row dropped over a live lane comes back **into that lane,
/// at the drop position** — `deleted:` removed and `order` set (03-board-ui.md § Trash,
/// 04-interactions.md ▸ The trash: "dropping a tombstoned card into one of its own board's lanes
/// restores it at the drop position").
///
/// `index` is the drag model's own index — a position among the destination lane's rendered
/// cards, which the tombstoned card is by definition not among (DRAG-REORDER.md § The drop
/// commits). Clamped, like every other drop commit.
///
/// **Cross-lane is two writes in one bracket, and the order is load-bearing**: `restoreItem`
/// first — the folder is still where the trash row said it was — then the move, carrying the
/// rank. Doing it the other way round would have the second call chasing a folder the first one
/// had already relocated.
///
/// **Same lane never moves a folder**, so it is one write: the key removed and, only when the
/// drop actually names a different rank than the card already carries, the `order` beside it.
/// That guard is what preserves the position-perfect restore the trash's pure-view design pays
/// for — a row dropped back where its recorded order already puts it comes back *exactly* there,
/// with no rank invented for it and no neighbour disturbed.
///
/// **Within-board only.** A drop on another board follows the locality model instead
/// (`receiveRestoredCards`): a live copy by default with the tombstoned original staying put,
/// and ⌘-drag forcing the true restore-move.
///
/// Silent no-ops, all of them the reload being the authority rather than this gesture: a
/// destination lane that is gone or tombstoned, a card that is not a trash row (its own flag
/// unset, or its lane tombstoned so it has no row to drag), and an id that names nothing.
public func restoreByDrag(cardID: ItemID, intoLane laneID: ItemID, at index: Int) {
restoreByDrag(cardIDs: [cardID], intoLane: laneID, at: index)
}
/// The multi-drag face of the same gesture: N trash rows dropped over one live lane land
/// contiguously at `index`, **in drop order** — the order the payload carries, which is the
/// trash's own sorted order (03-board-ui.md § Trash ▸ Contents).
///
/// It is the plural rather than a loop over the singular for `moveLanes`' reason: one
/// `performWrite` bracket per gesture whatever the set's size, so one reload and one commit
/// (DRAG-REORDER.md § The drop commits). Every rule above holds per member — the same-lane
/// single write, the cross-lane restore-then-move pair, and the recorded-`order` preservation,
/// which is what makes a row dropped back where it already belonged come back exactly there.
public func restoreByDrag(cardIDs: [ItemID], intoLane laneID: ItemID, at index: Int) {
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return }
// A row exists only for a card whose *own* flag is set under a *live* lane — the trash's
// absolute ancestor walk (03-board-ui.md § Trash ▸ Contents). Anything else in the list names
// nothing draggable and is silently skipped, which is this method's standing posture.
let rows: [(laneID: ItemID, card: Card)] = cardIDs.compactMap { id in
guard let source = snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == id && $0.isDeleted }
}),
let card = source.cards.first(where: { $0.id == id })
else { return nil }
return (source.id, card)
}
guard !rows.isEmpty else { return }
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
let rendered = destination.cards.filter { !$0.isDeleted }
let target = min(max(0, index), rendered.count)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: rows.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: target,
count: rows.count
)
}
guard let ranks else { return }
for (row, rank) in zip(rows, ranks) {
let cardFolder = TrashModel.ItemPath(laneID: row.laneID, cardID: row.card.id).folder(under: root)
guard row.laneID != laneID else {
try BoardWriter.updateIndex(
inItemFolder: cardFolder,
// `.restore(title: nil)`: `updateIndex` enriches it off the document it reads.
operation: .restore(title: nil)
) { document in
document.remove(FrontmatterKeys.deleted)
if rank != row.card.order {
document.set(FrontmatterKeys.order, to: .double(rank))
}
}
continue
}
try BoardWriter.restoreItem(at: cardFolder)
_ = try BoardWriter.moveItem(
at: cardFolder,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
}
// MARK: - Selection (delegated)
// The thin pass-throughs to `transient`, and the only ones.
//
// **Conveniences, not a second home.** The selection is the transient state every command site
// touches — menu validation, ⌫, paste anchoring, Select All — and `store.selection` reads better
// at each of them than `store.transient.selection` while meaning exactly the same thing. Nothing
// is stored here: `selection` is computed and the two mutators forward, so there is no second
// copy to go stale. Drag membership, the pending cut, the query and the editors get no such
// shortcuts — they have one or two call sites each, and a delegate per field would be the
// grab-bag reassembling itself on this class.
//
// `isEditingInline` earns one for the selection's reason and no other: **every** board-mutating
// menu item validates against it (04-interactions.md's focused-editor rule), and a rule read
// that often should read as one word.
/// The board's selection, re-resolved against every snapshot this store applies —
/// `TransientBoardState.selection` under a shorter name.
public var selection: ItemReferenceSet { transient.selection }
/// Whether an inline title editor is open — `TransientBoardState.isEditingInline`, which owns
/// what it means and why every mutating command reads it.
///
/// **The search field is not one of these**, and that is 04-interactions.md § Search's settled
/// dispatch rule as one absence: "the field is a *control*, not a content editor — the
/// focused-editor lockdown does not apply", so board menu commands stay enabled and act on the
/// selection while the user types a query. The narrow exception — the caret chords — is the
/// menu items' own (`caretChordsYield`), not this flag's.
public var isEditingInline: Bool { transient.isEditingInline }
// MARK: - The live search filter
/// The search field's text (04-interactions.md § Search), and **the one funnel every change to
/// it goes through**.
///
/// The setter is where the filter's one consequence lives: narrowing the query narrows what the
/// board shows, and "hidden cards leave the selection" — so every write re-applies
/// `TransientBoardState.constrainToSearch(in:)` against the current snapshot. Putting it here
/// rather than at the field's binding is what makes it true for Escape's clear and for any later
/// caller equally, without either having to remember.
///
/// **The equality guard is not an optimisation.** `NSSearchField` reports its text on events
/// that did not change it, and a re-entrant assignment during a live keystroke would re-run the
/// constraint (harmlessly) and re-fire observation (not harmlessly — the strip's animated
/// transaction is keyed on this value).
public var searchQuery: String {
get { transient.searchQuery }
set {
guard newValue != transient.searchQuery else { return }
transient.searchQuery = newValue
transient.constrainToSearch(in: snapshot)
}
}
/// The query as the predicate, for the selection grammar's order lists — read wherever the board
/// asks "what is on the board, in what order" (`SelectionGrammar.order`).
public var searchFilter: SearchFilter { SearchFilter(query: transient.searchQuery) }
/// Clears the search — **Escape's middle step** (04 § Search's staged Escape: "with *board*
/// focus and an active search, one press clears the search and the full board returns"), and the
/// search field's own Escape in a non-empty field.
///
/// Widening, so it constrains nothing; it goes through the setter anyway so there is exactly one
/// place the query is written on the store.
public func clearSearch() {
searchQuery = ""
}
/// Replaces the selection, and **records the lane it lands in** as the last-active one.
///
/// The lane bookkeeping lives here rather than in `TransientBoardState` for one reason: it
/// takes a snapshot to answer "which lane is that". 04-interactions.md's ⌘N target rule calls
/// for "the lane that most recently held selection or a creation", and a *card* selection is
/// its lane holding selection just as much as the lane's own header click is — so both are
/// noted here, and creation notes itself in `beginPlaceholder`.
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) {
transient.select(ids, liveness: liveness, anchor: anchor, head: head)
transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
}
/// **Every pointer click on a selectable surface goes through here** — card face, lane header,
/// lane empty space, trash row — so 04-interactions.md § Selection's grammar is stated once
/// (`SelectionGrammar`) rather than four times with three of them subtly different.
///
/// The store's whole contribution is supplying the three inputs the grammar cannot see (the
/// snapshot, the selection, the anchor) and storing the outcome. An emptied outcome clears
/// rather than storing an empty set on a side, because that is what "nothing selected" is
/// everywhere else in the app.
///
/// - Parameter togglesOnRepeat: the lane's click-again-to-unselect — see `SelectionGrammar`.
public func click(_ target: SelectionTarget, modifier: ClickModifier, togglesOnRepeat: Bool = false) {
let outcome = SelectionGrammar.click(
target,
modifier: modifier,
selection: selection,
anchor: transient.selectionAnchor,
snapshot: snapshot,
togglesOnRepeat: togglesOnRepeat,
// A ⇧-range walks the *filtered* board (04 § Search); the other two branches ignore it.
filter: searchFilter
)
guard !outcome.selection.isEmpty else {
clearSelection()
return
}
// Both cursors are passed through explicitly: `select`'s default would otherwise re-anchor a
// ⇧-range's single-member edge case on the target, and the grammar's answer is the one that
// knows whether this click was an origin or an extension. The head is the clicked item in
// every branch — see `SelectionGrammar.Outcome`.
select(
outcome.selection.ids,
liveness: outcome.selection.liveness,
anchor: outcome.anchor,
head: outcome.head
)
}
/// **Select All** — "all visible cards on the board" (04-interactions.md ▸ The map), with the
/// trash's own reading of the same command when the trash side is the one in play.
///
/// Two branches, and the trash's is the narrow one: it fires only when the column is **shown**,
/// the selection is on the trashed side, and it still names a row — the exact conditions under
/// which "all" could mean anything but the board. It then selects every trash row **of the
/// selection's kind**, because 04 ▸ The trash's card-entries-XOR-lane-entries rule binds a
/// wholesale selection as tightly as it binds a click. A trashed selection naming nothing (a
/// foreign Put Back, a purge) falls through to the board rather than selecting the trash
/// wholesale on a guess.
///
/// The anchor — and the navigation head with it — **survives if it is still in the set** and is
/// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor,
/// but it has no business discarding ones that are still standing inside what it selected.
///
/// **"All visible cards" means the filter's survivors** — "filter-respecting, like every
/// surface" (04 ▸ The map). The universe is `SelectionGrammar`'s order lists, which is where the
/// filter threads in, so this command and every ⇧-range narrow together by construction.
public func selectAll() {
let filter = searchFilter
if transient.isTrashVisible, selection.liveness == .trashed, !selection.isEmpty,
let kind = SelectionGrammar.kind(of: selection, in: snapshot) {
apply(Set(SelectionGrammar.trashEntries(of: kind, in: snapshot, filter: filter)), on: .trashed)
return
}
apply(Set(SelectionGrammar.liveCards(in: snapshot, filter: filter)), on: .live)
}
/// Select All's storage half: an empty universe clears rather than storing an empty set, and the
/// anchor and head are kept only while they are still inside what was selected.
private func apply(_ ids: Set<ItemID>, on side: Liveness) {
guard !ids.isEmpty else {
clearSelection()
return
}
let anchor = transient.selectionAnchor.flatMap { ids.contains($0) ? $0 : nil }
let head = transient.selectionHead.flatMap { ids.contains($0) ? $0 : nil }
select(ids, liveness: side, anchor: anchor, head: head)
}
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar).
///
/// The last-active lane deliberately survives: it is a high-water mark of where the user has
/// been working, and ⌘N after a deselect is exactly the case it exists to answer.
public func clearSelection() {
transient.clearSelection()
}
/// The lane a selection sits in, or `nil` when it names no single one — a live lane selects
/// itself; live cards select their lane, but only when they all share one (a cross-lane
/// selection has no single home to remember).
nonisolated static func lane(holding ids: Set<ItemID>, in snapshot: BoardModel) -> ItemID? {
guard !ids.isEmpty else { return nil }
var found: ItemID?
for lane in snapshot.lanes where !lane.isDeleted {
let names = ids.contains(lane.id) || lane.cards.contains { !$0.isDeleted && ids.contains($0.id) }
guard names else { continue }
guard found == nil else { return nil }
found = lane.id
}
return found
}
// MARK: - Quiescence
/// Suspends until no reload is running and none is owed.
///
/// The store is not a request/response object — a signal in does not produce a result out — so
/// this is how a consumer (and every test below) says "let the pipeline settle" without polling.
/// Returns immediately when the store is already quiet.
public func awaitQuiescence() async {
guard !isQuiescent else { return }
await withCheckedContinuation { continuation in
quiescenceWaiters.append(continuation)
}
}
private var isQuiescent: Bool { !reloadInFlight && pendingReload == nil }
private func resumeQuiescenceWaitersIfQuiet() {
guard isQuiescent, !quiescenceWaiters.isEmpty else { return }
let waiters = quiescenceWaiters
quiescenceWaiters.removeAll()
for waiter in waiters {
waiter.resume()
}
}
}