Closes the gap found at m10: enterUnwritableLock existed with zero call
sites. WritabilityProbe classifies the cause volume-first - a board on a
read-only DMG is also permission-denied by access(2), and "you don't
have permission" would send the user to a Get Info panel that cannot
help - with a pure classify(volumeIsReadOnly:isWritable:) truth table
and a two-syscall probe that rebuilds its URL to defeat NSURL resource
caching. ReadOnlyLockReason.unwritableLocation now carries the cause;
BannerCenter phrases the two ("this board's volume is read-only" vs
"you don't have permission to change this folder").
The probe wires once in BoardStoreRegistry.acquire, immediately after
the store loads - every open path funnels through it, and running
before the loose-file relocation and agent-guide hooks makes the
skipped-with-log guide write true by construction (its isWritableFile
pre-check demotes to second line of defense). The board still opens:
lock, not refusal.
The reconciling re-probe is now symmetric per 02's settled text - a
volume gone read-only mid-session raises the lock at the next probe
(sibling locks settle first, so a root returning read-only lands the
honest lock); the stale "deliberately one-way" comment and its pinning
test are gone. Save as Template's carve-out predicate extracted to a
testable allowsSave (behavior unchanged); Duplicate stays disabled.
11 tests added. 1649 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
3926 lines
220 KiB
Swift
3926 lines
220 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 is 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.
|
|
///
|
|
/// **It carries which** (settled): "the probe distinguishes read-only volume from
|
|
/// permission-denied folder and the lock reason carries it … the fixes being different acts".
|
|
/// The payload is the *only* thing the two spellings of this lock differ in — same scope, same
|
|
/// clearing rule — so it is an associated value rather than two cases, and `BannerCenter` turns
|
|
/// it into the one line the user reads.
|
|
///
|
|
/// **Raised and cleared by the probe, not by the reload's success** — 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 changed, in *either*
|
|
/// direction: it clears a lock whose cause is gone and raises one whose cause has appeared
|
|
/// mid-session (§ "the probe is symmetric").
|
|
case unwritableLocation(UnwritableCause)
|
|
}
|
|
|
|
/// 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 became of a card-body save — the card window's Edit buffer meeting disk
|
|
/// (05-card-window.md ▸ Edit; `BoardStore.writeCardBody(inCard:body:)`).
|
|
///
|
|
/// A returned value rather than a thrown error, because **four of the five cases are not failures**
|
|
/// and the caller's response to each differs: only `.written` and `.unchanged` mean the buffer may
|
|
/// stop being held dirty. Making them one enum is what keeps that decision in one `switch` rather
|
|
/// than spread across a `try?` and two guards.
|
|
public enum CardBodyWriteOutcome: Sendable, Equatable {
|
|
/// The bytes landed. The buffer matches disk; the echoing reload is now on its way.
|
|
case written
|
|
|
|
/// **Nothing to write** — the body on disk already reads exactly like the buffer. The three-gate
|
|
/// write rule's outcome (05 ▸ Write rules: untouched, reverted, or the echo of an external
|
|
/// edit), and as good as `.written` from the buffer's point of view: disk says what the user
|
|
/// means it to say, and nothing was re-serialized to make that true.
|
|
case unchanged
|
|
|
|
/// The board is locked read-only, so the save is **suspended, not failed** (02-architecture.md §
|
|
/// the lock's scope: "editor buffers kept but their debounced saves suspended"). The buffer stays
|
|
/// dirty, the standing lock row already explains why, and nothing is posted — a banner per
|
|
/// suppressed tick would bury the row that matters under echoes of itself.
|
|
case suspended(ReadOnlyLockReason)
|
|
|
|
/// The card is not in this board's tree at all any more — hard-deleted in Finder, or moved to
|
|
/// another board. **Not a failure either**: there is nowhere for the text to land, which is 05 ▸
|
|
/// Deletion & lifecycle's own answer ("A card hard-deleted externally (folder gone) discards
|
|
/// both — nowhere left to write"). A *tombstoned* card is not this case; it is still on disk and
|
|
/// is written to.
|
|
case vanished
|
|
|
|
/// The write was attempted and failed. The banner has already been posted by `performWrite`; the
|
|
/// buffer must stay dirty, and a close standing on it is `DirtyBufferGuard`'s modal moment.
|
|
case failed(BoardWriteError)
|
|
}
|
|
|
|
/// What came of opening a card's file in the raw-source outlet (05-card-window.md ▸ Raw source
|
|
/// outlet; `BoardStore.readCardSource(inCard:)`).
|
|
///
|
|
/// Three cases because the *entry* can be refused, which is the half of the outlet the design leaves
|
|
/// to the implementation: 05 fixes what Apply does with a bad buffer and says nothing about a file
|
|
/// that cannot be shown at all. The settled reading is that source mode does not open — see
|
|
/// `CardRawSourceSession.enter()`.
|
|
public enum RawSourceReadOutcome: Sendable, Equatable {
|
|
/// The file, byte-honest, as the editor will show it.
|
|
case read(String)
|
|
|
|
/// The card is not live in this board any more — hard-deleted, moved away, or tombstoned. The
|
|
/// window is dismissing itself in the same breath; there is nothing to open.
|
|
case vanished
|
|
|
|
/// The file could not be read, or is not UTF-8. The alert names it and the toggle stays
|
|
/// unchecked; nothing on disk was touched.
|
|
case failed(BoardWriteError)
|
|
}
|
|
|
|
/// What came of a raw-source Apply (05-card-window.md ▸ Raw source outlet;
|
|
/// `BoardStore.applyCardSource(inCard:text:)`).
|
|
///
|
|
/// `CardBodyWriteOutcome`'s shape and for its reason — the caller holds a buffer and has to know
|
|
/// whether it may stop holding it — plus the one case the body write cannot have: a proposal that
|
|
/// would not load. **Only `.applied`, `.unchanged` and `.vanished` leave source mode**; the other
|
|
/// three keep the buffer on screen with its text intact.
|
|
public enum RawSourceApplyOutcome: Sendable, Equatable {
|
|
/// The bytes landed exactly as typed. The echoing reload refreshes every window.
|
|
case applied
|
|
|
|
/// The file already read exactly like the buffer, so nothing was written — an Apply on a buffer
|
|
/// that was only read. As good as `.applied`: disk says what the user means it to say, and no
|
|
/// `mtime` churn, watcher round-trip or empty commit was spent making that true.
|
|
case unchanged
|
|
|
|
/// **The text would not load** — the fail-fast parse refused it (`BoardLoader.validateCardIndex`).
|
|
/// Nothing was attempted and nothing changed: source mode stays open with the detailed alert, and
|
|
/// the toggle stays checked (05: "a failed validation keeps source mode open").
|
|
case invalid(BoardLoadError)
|
|
|
|
/// The board is locked read-only. Suspended rather than failed, `CardBodyWriteOutcome.suspended`'s
|
|
/// rule: the buffer is kept, the standing lock row is the message, and nothing is posted.
|
|
case suspended(ReadOnlyLockReason)
|
|
|
|
/// The card left the board (or was tombstoned) under the open buffer. 05 ▸ Deletion & lifecycle
|
|
/// is explicit that this buffer discards rather than writes — "a foreign delete is never reverted
|
|
/// by a stale buffer" — so source mode closes with nothing written.
|
|
case vanished
|
|
|
|
/// The write was attempted and failed; `performWrite` has already posted the banner. The buffer
|
|
/// stays on screen, because the text is only in it.
|
|
case failed(BoardWriteError)
|
|
}
|
|
|
|
/// 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 cards the load that produced `snapshot` found holding loose files, exactly as the loader
|
|
/// reported them — the loose-file carve-out's detection channel (01-storage-format.md § Fractal
|
|
/// layout ▸ Rules, settled 2026-07-28). Replaced with the snapshot, like `loadWarnings`, so it
|
|
/// always describes the tree currently on screen.
|
|
///
|
|
/// **Nothing renders it.** A loose file is not content — it reaches no view, and the card it
|
|
/// sits in draws exactly as it would without it. Its one consumer is
|
|
/// `relocateLooseCardFiles()`, immediately below the reload that produced it.
|
|
public private(set) var looseCardFiles: [LooseCardFiles]
|
|
|
|
/// The legacy `deleted:` keys the load that produced `snapshot` found — the retired tombstone
|
|
/// model's migration input (01-storage-format.md § Deletion, resettled 2026-07-28), in the
|
|
/// loose-file channel's idiom and replaced with the snapshot exactly as it is.
|
|
///
|
|
/// **Nothing renders it either.** Its one consumer is `migrateLegacyTombstones()`, immediately
|
|
/// below the reload that produced it.
|
|
public private(set) var legacyTombstones: [LegacyTombstone]
|
|
|
|
/// 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()
|
|
|
|
/// **This board's write-provenance ledger** (02-architecture.md ▸ Components ▸ EchoLedger):
|
|
/// what the app wrote, so a landing reload can tell its own echo from someone else's edit.
|
|
///
|
|
/// **In-memory, per-store, dies with the session** — a `let` beside `transient` and `banners`,
|
|
/// for the same reason all three are: closing the board is the reset, and "losing it costs
|
|
/// attribution and nothing else". Owned rather than injected because there is exactly one
|
|
/// answer to "which ledger is this board's", and a second one would be a second provenance.
|
|
///
|
|
/// Its consumers today are the announcer's digest and the vanishing-focus sentence, both
|
|
/// through `land`. Pro's auto-committer (06-history-undo.md) becomes the second one without
|
|
/// this line changing — which is why the type lives in `LiveStore/` beside `BoardDiff` rather
|
|
/// than in `KanbanPro/`.
|
|
@ObservationIgnored
|
|
public let echoes = EchoLedger()
|
|
|
|
/// 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)?
|
|
|
|
/// **Where this board's inverses go** — the undo/redo substrate every write below registers into
|
|
/// (13-native-undo.md ▸ Rules: "Registration at the Writer boundary … each Writer call site
|
|
/// registers the inverse operation, computed from the pre-write snapshot the store already
|
|
/// holds").
|
|
///
|
|
/// The store is the Writer boundary: every app-mediated mutation in the app goes through one of
|
|
/// the methods below and out through `performWrite`, which is precisely the set of call sites 13
|
|
/// names. So the sink belongs here, injected like `watcherBrackets` and for the same reason — the
|
|
/// stack is **the session's**, "one stack per board, owned by the board session", and a store that
|
|
/// made its own would be a second answer to which stack a board has.
|
|
/// `AppModel.beginSession` wires it the moment the session's provider exists.
|
|
///
|
|
/// **Weak, deliberately.** The session owns both the store and the provider, and the provider's
|
|
/// steps hold closures over *this* store: a strong reference here would close that loop, leaving a
|
|
/// board that could only be freed by remembering to empty its undo stack first. `nil` — no session
|
|
/// yet, a storeless test, a board whose stack has been cleared away — keeps every method below
|
|
/// behaving exactly as it did before this milestone, registering nothing, which is `watcherBrackets`'
|
|
/// `nil` rule restated for a second seam.
|
|
@ObservationIgnored
|
|
public weak var history: (any HistoryProviding)?
|
|
|
|
// 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?
|
|
|
|
/// What the outstanding wholesale operation wants said when its reload lands, or `nil` for one
|
|
/// that has nothing to announce — **"bracketed operations announce once, at completion … never
|
|
/// their internal churn"** (10-accessibility.md ▸ Live board announcements).
|
|
///
|
|
/// Stored beside the floor and consumed by the same reload, because the announcement's whole
|
|
/// claim is that the operation *finished*: a phrase spoken when `performWholesale` returns would
|
|
/// be describing a tree the store has not read yet, and one spoken per file would be the churn
|
|
/// the design rules out. It is dropped along with the floor whichever way that reload went — a
|
|
/// failed closing reload locks the board and says so instead (`BoardAnnouncer`'s ladder puts the
|
|
/// raised lock above the completion), and the phrase must not survive to be spoken by some
|
|
/// later, unrelated reload.
|
|
///
|
|
/// **`nil` on every base-edition bracket today.** Base has no git operations, and the design's
|
|
/// examples ("Pulled 3 commits", "Switched to branch 'redesign'") are pro-m1's; the parameter
|
|
/// exists so that milestone supplies phrasing rather than re-plumbing the seam.
|
|
@ObservationIgnored
|
|
private var wholesaleCompletion: String?
|
|
|
|
/// Consumers suspended in `awaitQuiescence()`, resumed together the moment nothing is running
|
|
/// and nothing is owed.
|
|
@ObservationIgnored
|
|
private var quiescenceWaiters: [CheckedContinuation<Void, Never>] = []
|
|
|
|
/// The loose-file set the last relocation attempt was made against — the loop guard
|
|
/// `relocateLooseCardFiles()` documents. Empty means "nothing has been attempted against the
|
|
/// current picture", which is both the opening state and what a clean board resets it to.
|
|
@ObservationIgnored
|
|
private var attemptedRelocation: Set<String> = []
|
|
|
|
/// The tombstone set the last migration attempt was made against — `attemptedRelocation`'s twin,
|
|
/// documented at `migrateLegacyTombstones()`.
|
|
@ObservationIgnored
|
|
private var attemptedTombstoneMigration: Set<String> = []
|
|
|
|
/// The board root's agent-guide picture the last refresh acted on — the third of the same loop
|
|
/// guard, documented at `refreshAgentGuide()`. `nil` means "nothing has been acted on against
|
|
/// the current picture", which is both the opening state and what a board whose guide is already
|
|
/// current resets it to (so the state it holds is never a guide's own text for longer than the
|
|
/// one reload that wrote it).
|
|
@ObservationIgnored
|
|
private var attemptedGuideRefresh: AgentGuide.State?
|
|
|
|
/// 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)?
|
|
|
|
/// **This board's one outlet for spoken announcements** — `AccessibilityAnnouncer.post` in
|
|
/// production, and the second seam this type keeps (`loadBarrier` is the first, and this is the
|
|
/// same bargain).
|
|
///
|
|
/// Every *decision* about what the board says is already a pure function of values
|
|
/// (`BoardAnnouncer.speech(for:)`, `AccessibilityPhrases`), so the rules are not here and are not
|
|
/// tested through here. What this makes assertable is the **wiring**: that a foreign reload's
|
|
/// sentence actually reaches an outlet, that an app-mediated echo produces none, and that a
|
|
/// bracket's completion phrase is spoken by the reload that closed it and by no later one. Those
|
|
/// are claims about the reload path rather than about phrasing, and the alternative way to check
|
|
/// them is a screen reader and a human ear.
|
|
///
|
|
/// One outlet rather than a call per producer, for `setTrashVisible`'s own reason: the board's
|
|
/// announcements are one voice, and a producer that posted around this would be a second voice
|
|
/// nothing could see.
|
|
@ObservationIgnored
|
|
var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
|
|
|
|
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.
|
|
///
|
|
/// **It writes nothing, the opened board's loose files included.** `looseCardFiles` is recorded
|
|
/// here and acted on by whoever wired this store up — `BoardStoreRegistry.acquire` calls
|
|
/// `relocateLooseCardFiles()` once the watcher and the brackets exist, so the relocation is a
|
|
/// bracketed write with a reload behind it rather than a write into a board nothing is watching
|
|
/// yet. A store built directly (a test, a storeless consumer) relocates when it is asked to, and
|
|
/// on every reload thereafter.
|
|
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.looseCardFiles = result.looseCardFiles
|
|
self.legacyTombstones = result.legacyTombstones
|
|
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
|
|
let completion: String?
|
|
if let floor = wholesaleReloadFloor, generation >= floor {
|
|
wholesaleReloadFloor = nil
|
|
completion = wholesaleCompletion
|
|
wholesaleCompletion = nil
|
|
endsWholesaleOperation = true
|
|
} else {
|
|
completion = nil
|
|
endsWholesaleOperation = false
|
|
}
|
|
|
|
// The two standing conditions as they stood *before* this reload touched them —
|
|
// 10-accessibility.md makes the live-reload-resilience banner an announced element "when it
|
|
// appears and when it clears", and appearing and clearing are transitions, not states. Read
|
|
// here rather than at each mutation below so there is one before-picture for the whole
|
|
// landing, whichever branch it takes.
|
|
var facts = BoardAnnouncer.ReloadFacts()
|
|
facts.endsBracketedOperation = endsWholesaleOperation
|
|
facts.completion = completion
|
|
facts.lockBefore = readOnlyLock
|
|
facts.breakageBefore = reloadFailure
|
|
|
|
switch outcome {
|
|
case let .success(result):
|
|
// **What changed, who changed it, and what it cost the cursor** — all three computed
|
|
// against the *outgoing* snapshot, so they have to be taken before the assignment below
|
|
// replaces it. All three are functions of two value types; nothing here reads disk and
|
|
// nothing here decides whether anyone is told.
|
|
//
|
|
// **Asked on every origin, reconciling included** (10-accessibility.md ▸ Live board
|
|
// announcements, ruled 2026-07-29): what buys silence is the *ledger*, not the reload's
|
|
// label. An app-mediated echo is silent because its files carry receipts that still
|
|
// match; a reconciling sweep over a blind window is not, because the files it reveals
|
|
// carry none — "the app never vouches for changes it didn't witness". The one exemption
|
|
// is the bracket, which 10 gives a single sentence at completion rather than a
|
|
// description of its churn, and which 02 keeps out of the ledger entirely.
|
|
let focus: BoardAnnouncer.FocusOutcome
|
|
if !endsWholesaleOperation {
|
|
// **The digest covers the trash only while the trash lane is shown**
|
|
// (10-accessibility.md ▸ Live board announcements, ruled 2026-07-29). This is the
|
|
// seam that reading takes: visibility is view state on the board's own transient
|
|
// container — one per store, shared by every window onto this board
|
|
// (`TransientBoardState.isTrashVisible`) — so the store asks it here and both the
|
|
// summarizer and the classifier stay pure functions of two snapshots plus one fact.
|
|
let shownTrash = transient.isTrashVisible
|
|
let diff = BoardDiff.between(snapshot, result.model, includingTrash: shownTrash)
|
|
let verdicts = echoes.verdicts(
|
|
from: snapshot,
|
|
to: result.model,
|
|
diff: diff,
|
|
includingTrash: shownTrash
|
|
)
|
|
facts.diff = verdicts.foreign
|
|
// The vanishing-focus sentence takes the same gate, one rung up the ladder: it says
|
|
// "deleted *externally*", which would be a lie about an app-mediated delete — whose
|
|
// own command already chose a successor (04-interactions.md ▸ The map's ⌫ rule) and
|
|
// must not have it overridden here. So a vanishing the ledger vouches for is no
|
|
// vanishing at all as far as speech and focus are concerned.
|
|
let outcome = BoardAnnouncer.focusOutcome(
|
|
old: snapshot,
|
|
new: result.model,
|
|
selection: transient.selection,
|
|
focused: focusedItem
|
|
)
|
|
focus = Self.vanishingIsForeign(
|
|
focused: focusedItem,
|
|
old: snapshot,
|
|
new: result.model,
|
|
foreignItems: verdicts.foreignItems
|
|
) ? outcome : .survived
|
|
} else {
|
|
focus = .survived
|
|
}
|
|
facts.vanishedFocus = focus.vanished
|
|
|
|
// **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)
|
|
// And *then* the recovery, on top of the set rule rather than instead of it: the
|
|
// resolution leaves an emptied selection wherever the focused item used to be, and
|
|
// this is 10-accessibility.md's answer to the hole ("focus recovers to the card's
|
|
// lane … walks up then sideways"). Inside the same transaction for the highlight's
|
|
// sake, exactly like the resolution it follows.
|
|
recoverFocus(focus.recovery)
|
|
}
|
|
// 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
|
|
looseCardFiles = result.looseCardFiles
|
|
legacyTombstones = result.legacyTombstones
|
|
reconcileLock(after: 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?()
|
|
// Last, and after `reconcileLock` deliberately: this is the seam the two deferred
|
|
// app-initiated writes are armed on. A board that was locked read-only tolerated its
|
|
// loose files and its legacy tombstones for exactly as long as the lock stood, and the
|
|
// reload that clears the lock is the reload that lets them move — see
|
|
// `relocateLooseCardFiles()` and `migrateLegacyTombstones()`. The ordering cuts the
|
|
// other way too now that the probe is symmetric: a reconciling reload that *raises* the
|
|
// lock raises it before these three run, so none of them writes into a location the
|
|
// same reload just learned is read-only.
|
|
//
|
|
// The migration goes second only because the relocation is the older rule; they touch
|
|
// disjoint files (loose files beside an `index.md` vs the `deleted:` key inside one) and
|
|
// share one bracket-per-call posture, so neither can see the other's work half-done —
|
|
// each opens its own bracket and each is re-armed by the reload the other's write
|
|
// produces.
|
|
//
|
|
// The agent guide joins them last, and is the one of the three that runs on *every*
|
|
// board rather than only on one an older version or an outside writer left work in: it
|
|
// re-checks a single board-root file and writes only when the version marker says to
|
|
// (08-agent-integration.md ▸ The agent guide). Running it here rather than at open alone
|
|
// is what makes it self-healing — see `refreshAgentGuide()`.
|
|
relocateLooseCardFiles()
|
|
migrateLegacyTombstones()
|
|
refreshAgentGuide()
|
|
|
|
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)")
|
|
}
|
|
|
|
// **One announcement per reload**, chosen by `BoardAnnouncer`'s precedence ladder and posted
|
|
// last, after both branches have finished moving the store — so the after-picture the
|
|
// decision reads is the settled one, and so a sentence is never spoken about a state that a
|
|
// line below it then changed.
|
|
facts.lockAfter = readOnlyLock
|
|
facts.breakageAfter = reloadFailure
|
|
announce(BoardAnnouncer.speech(for: facts))
|
|
}
|
|
|
|
/// Installs the recovery `BoardAnnouncer` chose for a focus that vanished under a foreign
|
|
/// reload — the storage half of the rule, with every decision already made.
|
|
///
|
|
/// The board container case is spelled rather than skipped: `resolve(against:)` has already
|
|
/// emptied the selection by the time this runs, so `clearSelection()` is a no-op on membership —
|
|
/// but it also drops the anchor and the head, which is the difference between "nothing is
|
|
/// selected" and "nothing is selected and the next ⇧-arrow ranges from a ghost".
|
|
///
|
|
/// `noteActiveLane` rides along on the lane case for `lastActiveLaneID`'s own reason: the
|
|
/// resolution just cleared that memory along with the lane it named, and a ⌘N after a foreign
|
|
/// delete should file the card where the user has been left, not at the far left of the board.
|
|
private func recoverFocus(_ recovery: BoardAnnouncer.FocusRecovery?) {
|
|
switch recovery {
|
|
case nil:
|
|
break
|
|
case let .lane(id):
|
|
transient.select([id], in: .board)
|
|
transient.noteActiveLane(id)
|
|
case .boardContainer:
|
|
transient.clearSelection()
|
|
}
|
|
}
|
|
|
|
/// **Whether the hole under the cursor was somebody else's doing** — the gate that used to be
|
|
/// `origin == .foreign`, now asked of the ledger per file (10-accessibility.md, ruled
|
|
/// 2026-07-29).
|
|
///
|
|
/// It mirrors `BoardAnnouncer.focusOutcome`'s own choice of subject rather than second-guessing
|
|
/// it, because the two must agree about *what vanished*: when the lane went, the lane is the
|
|
/// subject and the lane's classification is the one that decides — a card swept away with an
|
|
/// app-mediated lane delete carries no receipt of its own (the removal took the whole subtree's
|
|
/// receipts with it), and letting the child's verdict speak would announce the user's own
|
|
/// gesture back at them.
|
|
///
|
|
/// `false` for a focus that was never on the board's old side: there is nothing to classify, and
|
|
/// `focusOutcome` already answers `.survived` there.
|
|
nonisolated static func vanishingIsForeign(
|
|
focused: ItemID?,
|
|
old: BoardModel,
|
|
new: BoardModel,
|
|
foreignItems: Set<ItemID>
|
|
) -> Bool {
|
|
guard let focused else { return false }
|
|
if old.lanes.contains(where: { $0.id == focused }) {
|
|
return foreignItems.contains(focused)
|
|
}
|
|
guard let home = old.lanes.first(where: { lane in lane.cards.contains { $0.id == focused } })
|
|
else { return false }
|
|
if new.lanes.contains(where: { $0.id == home.id }) {
|
|
return foreignItems.contains(focused)
|
|
}
|
|
return foreignItems.contains(home.id)
|
|
}
|
|
|
|
/// **The cursor**, as 10-accessibility.md's announcements mean it: the navigation head when it
|
|
/// is still in the selection, else a sole selected item, else nothing.
|
|
///
|
|
/// Nothing for a multi-item selection with no head deliberately — a vanishing-focus sentence
|
|
/// names *one* item ("Card 'Fix login' was deleted externally"), and picking one out of a
|
|
/// five-card selection by set order would name whichever the hash table happened to yield. That
|
|
/// case falls through to the digest, which describes all five honestly.
|
|
private var focusedItem: ItemID? {
|
|
let ids = transient.selection.ids
|
|
if let head = transient.selectionHead, ids.contains(head) { return head }
|
|
return ids.count == 1 ? ids.first : nil
|
|
}
|
|
|
|
private func startPendingReload() {
|
|
guard !reloadInFlight, let origin = pendingReload else { return }
|
|
pendingReload = nil
|
|
startReload(origin)
|
|
}
|
|
|
|
// MARK: - The lock's reconciliation rules
|
|
|
|
/// Brings the read-only lock into line with what this successful reload — and, on a reconciling
|
|
/// one, a fresh writability probe — actually proves.
|
|
///
|
|
/// **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. Only the probe can speak to it, and the probe runs on
|
|
/// **reconciling** reloads — wake, activation, a stream re-creation — because those are the
|
|
/// reloads that admit a blind window ("Writability re-probes on every reconciling reload").
|
|
///
|
|
/// ### The probe is symmetric (settled)
|
|
///
|
|
/// It clears *and* raises. "A rewritable remount or fixed permission clears the lock without
|
|
/// ceremony, and a volume gone read-only mid-session *raises* it at the next probe — banner up
|
|
/// front, not every gesture failing one at a time (the lock's own founding rationale)." Between
|
|
/// probes a write that hits the newly read-only volume fails as an ordinary one-shot; this is
|
|
/// the line that converts that condition into the standing lock.
|
|
///
|
|
/// A raise here is deliberately **not** routed through `enterUnwritableLock(_:)`: that method
|
|
/// speaks its own sentence, and this runs inside `land`, which posts exactly one announcement
|
|
/// per reload from the before/after pictures it already holds. Two voices for one lock is the
|
|
/// bug `announceLockChange` exists to avoid.
|
|
///
|
|
/// The sibling locks are settled *before* the probe, so a reconciling reload that clears a
|
|
/// vanished root on a volume that came back read-only ends with the honest lock rather than no
|
|
/// lock at all. And a standing `.unwritableLocation` whose cause *changed* — a permission-denied
|
|
/// folder whose volume was then remounted read-only — re-lands with the new cause, updating the
|
|
/// row's line rather than replacing the row (`BannerRow.id` is constant per condition).
|
|
private func reconcileLock(after origin: WatchOrigin) {
|
|
switch readOnlyLock {
|
|
case .bracketedReloadFailed, .vanishedRoot:
|
|
readOnlyLock = nil
|
|
case .unwritableLocation, nil:
|
|
break
|
|
}
|
|
|
|
guard origin == .reconciling else { return }
|
|
|
|
switch (readOnlyLock, WritabilityProbe.probe(rootURL)) {
|
|
case (.unwritableLocation, nil):
|
|
Self.logger.debug("writability re-probe passed — the unwritable-location lock clears")
|
|
readOnlyLock = nil
|
|
case let (.unwritableLocation, .some(cause)):
|
|
// Still unwritable. The assignment is not a no-op only when the *cause* moved.
|
|
readOnlyLock = .unwritableLocation(cause)
|
|
case let (nil, .some(cause)):
|
|
Self.logger.error("writability re-probe failed (\(cause.rawValue, privacy: .public)) — the read-only lock rises")
|
|
readOnlyLock = .unwritableLocation(cause)
|
|
case (nil, nil):
|
|
break
|
|
// Cleared above, so unreachable — spelled so a new lock reason is a compile error here
|
|
// rather than a silent fall-through past the probe.
|
|
case (.bracketedReloadFailed, _), (.vanishedRoot, _):
|
|
break
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
let before = readOnlyLock
|
|
readOnlyLock = .vanishedRoot
|
|
announceLockChange(from: before)
|
|
}
|
|
|
|
/// **The open-time writability probe** (02 § "An unwritable board location enters the read-only
|
|
/// lock at open") — `BoardStoreRegistry.acquire`'s call, and the only place the lock is raised
|
|
/// outside a reconciling reload.
|
|
///
|
|
/// A no-op on a writable board, which is the overwhelming case, and one `access(2)` plus one
|
|
/// volume resource value when it is not — cheap enough to sit unconditionally on the open path.
|
|
///
|
|
/// **The open still succeeds.** Nothing here refuses the board or throws: the lock's read
|
|
/// affordances stay live as always, because "inspecting an archived board on a DMG is a
|
|
/// legitimate errand, and viewing-first is the point". All that changes is that every mutating
|
|
/// entry point now consults a predicate that is already `true` before the window can be acted
|
|
/// on — the lock is up *before* the user's first gesture, which is the whole of "fail loudly,
|
|
/// specifically, once".
|
|
public func probeWritabilityAtOpen() {
|
|
guard let cause = WritabilityProbe.probe(rootURL) else { return }
|
|
enterUnwritableLock(cause)
|
|
}
|
|
|
|
/// Raises the unwritable-location read-only lock with the cause the probe found.
|
|
///
|
|
/// Public because the probe is not the only conceivable producer and because tests arm it
|
|
/// directly; `probeWritabilityAtOpen()` is the app's own path to it.
|
|
///
|
|
/// 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 (`reconcileLock(after:)` settles
|
|
/// the siblings first, then probes, precisely so that reload lands on the right answer).
|
|
public func enterUnwritableLock(_ cause: UnwritableCause) {
|
|
guard readOnlyLock == nil else { return }
|
|
Self.logger.error("board location is not writable (\(cause.rawValue, privacy: .public)) — entering the read-only lock")
|
|
readOnlyLock = .unwritableLocation(cause)
|
|
announceLockChange(from: nil)
|
|
}
|
|
|
|
/// Speaks a lock raised **outside** the reload path — the registry's vanished-root call and the
|
|
/// open flow's writability probe, neither of which is a reload and neither of which therefore
|
|
/// competes with anything for the debounce's one sentence.
|
|
///
|
|
/// 10-accessibility.md makes the live-reload-resilience banner an announced element, and these
|
|
/// are the two ways it can appear without a reload landing. Routed through the same
|
|
/// `BoardAnnouncer.ReloadFacts` ladder rather than posting directly so the sentence is composed
|
|
/// exactly once, in one place, from the banner's own headline: a lock the user hears described
|
|
/// one way and reads another is two locks as far as they can tell.
|
|
private func announceLockChange(from before: ReadOnlyLockReason?) {
|
|
var facts = BoardAnnouncer.ReloadFacts()
|
|
facts.lockBefore = before
|
|
facts.lockAfter = readOnlyLock
|
|
announce(BoardAnnouncer.speech(for: facts))
|
|
}
|
|
|
|
// 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() }
|
|
// **The receipt seam** (02-architecture.md ▸ Components ▸ EchoLedger). Binding the ledger
|
|
// here rather than passing it down is what keeps `BoardWriter` the stateless enum of statics
|
|
// the same bullet requires: the Writer's disk primitives drop receipts into whichever
|
|
// board's ledger is bound, and the binding is exactly this bracket — which is exactly the
|
|
// span in which a write is the app's. A Writer call outside one (another board's tree, a
|
|
// test) finds no ledger and records nothing.
|
|
return try EchoLedger.$current.withValue(echoes) {
|
|
// `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.
|
|
///
|
|
/// - Parameter completion: what to announce when the closing reload lands
|
|
/// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at
|
|
/// completion" — "Pulled 3 commits", "Switched to branch 'redesign'"). `nil`, the default, is
|
|
/// an operation whose completion is not worth speech, which is **every base-edition bracket
|
|
/// today**: base has no git operations, and the two app-initiated writes that do reach disk on
|
|
/// their own — the loose-file relocation and the legacy-tombstone migration — are ordinary
|
|
/// `performWrite` calls that already say what they did on the banner strip. The parameter is
|
|
/// the seam pro-m1 fills; see `wholesaleCompletion`.
|
|
///
|
|
/// - 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(announcing completion: String? = nil, _ 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. The
|
|
// completion phrase is armed with it, for the same reason and on the same exit paths: an
|
|
// operation that died partway still owes its closing reload, and 10 gives that reload one
|
|
// sentence whichever way it goes.
|
|
wholesaleReloadFloor = reloadGeneration + 1
|
|
wholesaleCompletion = completion
|
|
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) }
|
|
.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, prior: FieldValue<Int>, title: String?)] = 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, lane.width, lane.title.value)
|
|
}
|
|
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.
|
|
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
|
for write in writes {
|
|
try Self.setWidth(write.units, at: write.folder)
|
|
}
|
|
}
|
|
guard landed != nil else { return }
|
|
|
|
// resize → prior width (13-native-undo.md ▸ Rules). One step whatever the batch's size — the
|
|
// menu items step every selected lane in one gesture, and one gesture is one step.
|
|
//
|
|
// The validated field is `width`, read as the app reads it: a lane landing on one unit has
|
|
// **no key at all** (the remove-at-default rule above), which is a real after-value and the
|
|
// one `nil` here means. The redo's expectation is the prior as the inverse restores it —
|
|
// `prior.value`, which is `nil` for a missing *and* for a malformed prior, exactly matching
|
|
// `restoreWidth`'s own reading.
|
|
registerStep(
|
|
HistoryPhrase.name(.resize, kind: .lane, count: writes.count),
|
|
subject: writes.count == 1 ? writes[0].title : nil,
|
|
undoExpects: writes.map { .present($0.folder, .width($0.units == 1 ? nil : $0.units)) },
|
|
redoExpects: writes.map { .present($0.folder, .width($0.prior.value)) }
|
|
) { _ in
|
|
for write in writes {
|
|
try BoardWriter.updateIndex(inItemFolder: write.folder, operation: .resize(title: nil)) { document in
|
|
Self.restoreWidth(write.prior, in: &document)
|
|
}
|
|
}
|
|
} redo: { _ in
|
|
for write in writes {
|
|
try Self.setWidth(write.units, at: write.folder)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The width write itself, spelled once so the gesture and its redo cannot drift apart on the
|
|
/// remove-at-default rule.
|
|
private static func setWidth(_ units: Int, at folder: URL) throws(BoardWriteError) {
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .resize(title: nil)) { document in
|
|
if units == 1 {
|
|
document.remove(FrontmatterKeys.width)
|
|
} else {
|
|
document.set(FrontmatterKeys.width, to: .int(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 {
|
|
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 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.cards.contains { 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: [(
|
|
id: ItemID?,
|
|
folder: URL,
|
|
background: StyleChange,
|
|
icon: StyleChange,
|
|
priorBackground: FieldValue<String>,
|
|
priorIcon: FieldValue<String>
|
|
)] = 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 (
|
|
id: subject.id,
|
|
folder: subject.folder,
|
|
background: background,
|
|
icon: icon,
|
|
priorBackground: subject.background,
|
|
priorIcon: subject.icon
|
|
)
|
|
}
|
|
guard !edits.isEmpty else { return }
|
|
|
|
let landed: Void? = 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)
|
|
}
|
|
}
|
|
}
|
|
guard landed != nil else { return }
|
|
|
|
// restyle → prior style (13-native-undo.md ▸ Rules). **One step for the batch**, which is the
|
|
// same sentence as this method's one bracket: "choosing a well applies to the whole selection
|
|
// — one gesture, one commit", substrate swapped.
|
|
let kind: HistoryPhrase.Kind = switch styleLevel(of: target) {
|
|
case .board: .board
|
|
case .lane: .lane
|
|
case .card: .card
|
|
}
|
|
// **Per dimension, not per item**: a gesture that set only `background` validates only
|
|
// `background`, so a foreign `icon:` edit on the very same card leaves the step alone. That is
|
|
// the field-level predicate read at its narrowest, and it is free — `effective(_:against:)`
|
|
// has already narrowed each dimension to what this write actually changed.
|
|
let subject = edits.count == 1
|
|
? edits[0].id.flatMap { Self.boardItem($0, in: snapshot)?.title }
|
|
: nil
|
|
registerStep(
|
|
HistoryPhrase.name(.restyle, kind: kind, count: edits.count),
|
|
subject: subject,
|
|
undoExpects: edits.map {
|
|
.present($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon))
|
|
},
|
|
redoExpects: edits.map {
|
|
.present($0.folder, fields: Self.restoredStyleFields(
|
|
background: $0.background,
|
|
priorBackground: $0.priorBackground,
|
|
icon: $0.icon,
|
|
priorIcon: $0.priorIcon
|
|
))
|
|
}
|
|
) { _ in
|
|
for edit in edits {
|
|
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
|
|
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
|
|
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
|
|
}
|
|
}
|
|
} redo: { _ in
|
|
for edit in edits {
|
|
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
|
|
let created = try? performWrite { () throws(BoardWriteError) -> ItemID in
|
|
try BoardWriter.createLane(inBoard: root, title: nil)
|
|
}
|
|
guard let created else { return }
|
|
|
|
// create → remove the created folder (13-native-undo.md ▸ Rules). The bytes are read back
|
|
// here, while the folder still exists, because the inverse destroys it — see `CreatedItem`.
|
|
let folder = root.appendingPathComponent(created.rawValue, isDirectory: true)
|
|
guard let item = createdItem(at: folder, kind: .lane) else { return }
|
|
registerCreation([item], kind: .lane)
|
|
}
|
|
|
|
// 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 })
|
|
else {
|
|
transient.discardPlaceholder()
|
|
return nil
|
|
}
|
|
|
|
let laneFolder = rootURL.appendingPathComponent(placeholder.laneID.rawValue)
|
|
let visible = lane.cards
|
|
// `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)
|
|
|
|
// create → remove the created folder (13-native-undo.md ▸ Rules). The rank the pair above
|
|
// may have written is inside the captured bytes, so a redo puts the card back where the
|
|
// gesture put it, not merely at the bottom of the lane.
|
|
if let item = createdItem(at: laneFolder.appendingPathComponent(created.rawValue, isDirectory: true), kind: .card) {
|
|
registerCreation([item], kind: .card, subject: title)
|
|
}
|
|
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."
|
|
/// Entering the trash is a vanish from the board, and so is losing the lane you were in.
|
|
/// - **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.boardItem(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)
|
|
}
|
|
|
|
let landed: Void? = 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 Self.setTitle(newTitle, at: folder)
|
|
}
|
|
guard landed != nil else { return }
|
|
|
|
// rename → restore title (13-native-undo.md ▸ Rules). The prior title is the *typed* value,
|
|
// `nil` for an untitled item — so undoing a rename that gave an untitled card a name takes
|
|
// the `title` key away again rather than writing `title: ""`.
|
|
//
|
|
// The validated field is `title` and nothing else: an agent that restyles this very card
|
|
// between the rename and the ⌘Z has not touched what this step wrote, so the undo applies —
|
|
// "a foreign change to an unrelated item must not skip anything", read one level finer.
|
|
let priorTitle = target.title
|
|
registerStep(
|
|
HistoryPhrase.name(.rename, kind: target.cardID == nil ? .lane : .card),
|
|
subject: newTitle ?? priorTitle,
|
|
undoExpects: [.present(folder, .title(newTitle))],
|
|
redoExpects: [.present(folder, .title(priorTitle))]
|
|
) { _ in
|
|
try Self.setTitle(priorTitle, at: folder)
|
|
} redo: { _ in
|
|
try Self.setTitle(newTitle, at: folder)
|
|
}
|
|
}
|
|
|
|
/// The title write every rename shares — the item-level one and the board's — spelled once so
|
|
/// the empty-title rule (a missing key, never `title: ""`) cannot differ between a gesture and
|
|
/// its own undo.
|
|
private static func setTitle(_ title: String?, at folder: URL) throws(BoardWriteError) {
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
|
|
if let title {
|
|
document.set(FrontmatterKeys.title, to: .string(title))
|
|
} else {
|
|
document.remove(FrontmatterKeys.title)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where a **board** item lives and what it is currently called, or `nil` when the id names
|
|
/// nothing on the board.
|
|
///
|
|
/// **The board container, and only it.** A card that has been deleted is in `.trash/`, where it
|
|
/// does not open, cannot be renamed, takes no attachments and has no task boxes to tick
|
|
/// (03-board-ui.md § Trash's no-editing rule) — so every caller of this wants exactly the board
|
|
/// side, and a trash card answering `nil` is the vanished-target guard those gestures already
|
|
/// make. The tombstone era's ancestor walk is gone with the tombstones: presence in the lanes is
|
|
/// the whole question.
|
|
///
|
|
/// 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 boardItem(
|
|
_ id: ItemID,
|
|
in snapshot: BoardModel
|
|
) -> (laneID: ItemID, cardID: ItemID?, title: String?)? {
|
|
for lane in snapshot.lanes {
|
|
if lane.id == id {
|
|
return (laneID: lane.id, cardID: nil, title: lane.title.value)
|
|
}
|
|
if let card = lane.cards.first(where: { $0.id == id }) {
|
|
return (laneID: lane.id, cardID: card.id, title: card.title.value)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Task checkboxes
|
|
|
|
/// Ticks or unticks a Preview task-list checkbox — **the app's one write into a card's body**
|
|
/// (05-card-window.md ▸ Preview), and otherwise an entirely ordinary one: the same
|
|
/// `performWrite` bracket, the same banner on failure, the same one-way flow back through the
|
|
/// watcher. "A toggle is an ordinary user edit — the standard atomic write, auto-committed and
|
|
/// undoable on git boards."
|
|
///
|
|
/// `bodyOffset` is the UTF-8 byte offset the parse handed the renderer (`BodyTask
|
|
/// .markerOffset`) and `checked` is the state the user was looking at; both travel to
|
|
/// `BoardWriter.toggleTaskMarker`, which re-verifies them against the file it reads and refuses
|
|
/// rather than write blind. Nothing here inspects the body: the store never re-parses to
|
|
/// second-guess the click, because its own snapshot is exactly as stale as the render was.
|
|
///
|
|
/// **It registers no undo step.** 13-native-undo.md ▸ Rules' inventory names the body write it
|
|
/// makes undoable precisely — "Edit-session body save → restore prior body bytes" — and a Preview
|
|
/// checkbox is not one: it belongs to no session, has no flip to coalesce at, and 05 files it
|
|
/// under what is "undoable on git boards", which is the *other* substrate's answer. Registering it
|
|
/// here would be extending 13's inventory rather than implementing it.
|
|
///
|
|
/// **A checkbox in a card that has gone writes nothing** — the vanished-target guard every
|
|
/// gesture in this file makes, ancestor-walked through `boardItem`: the card window would be
|
|
/// dismissing itself in the same breath, and the reload that removed the card is the authority.
|
|
/// The read-only lock is `performWrite`'s refusal, which is also why the controls disable in
|
|
/// place on the Preview side rather than failing here (02-architecture.md § the lock's scope).
|
|
public func toggleTaskMarker(inCard cardID: ItemID, bodyOffset: Int, checked: Bool) {
|
|
guard let target = Self.boardItem(cardID, in: snapshot), let card = target.cardID else { return }
|
|
let folder = rootURL
|
|
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
|
|
.appendingPathComponent(card.rawValue, isDirectory: true)
|
|
|
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
|
try BoardWriter.toggleTaskMarker(inItemFolder: folder, bodyOffset: bodyOffset, checked: checked)
|
|
}
|
|
}
|
|
|
|
// MARK: - Card body
|
|
|
|
/// Saves a card window's Edit buffer — the debounced tick, the flush that leaves Edit, and the
|
|
/// flush that closes the window (05-card-window.md ▸ Edit).
|
|
///
|
|
/// An ordinary store write in every mechanical respect: one `performWrite` bracket, so the churn
|
|
/// rounds back as a single app-mediated reload (and, on git boards, sits inside the session's
|
|
/// one commit — see `CardBodyEditSession` for that seam); the banner posts itself on failure;
|
|
/// the snapshot is never touched here, because the watcher's reload is what brings the text
|
|
/// back.
|
|
///
|
|
/// **It reports rather than swallows**, which is the one way it differs from every other write
|
|
/// in this file. `toggleTaskMarker` and its neighbours are one-shot gestures whose failure the
|
|
/// banner fully describes, so they `try?` and move on. This one has a *buffer* behind it: the
|
|
/// caller has to know whether the text landed, because on success it may stop holding it dirty
|
|
/// and on failure it must keep holding it — the whole of "nothing is lost while the window stays
|
|
/// open" (02-architecture.md § Write-failure surfacing). Hence an outcome, not a `Void`.
|
|
///
|
|
/// **A trashed card is writable here, deliberately.** The folder is resolved by
|
|
/// `cardBodyTarget(_:in:)` — a walk that spans **both containers** — because 05-card-window.md ▸
|
|
/// Deletion & lifecycle requires exactly that: "a dirty Edit buffer flushes into the card's
|
|
/// folder at its new `.trash/` location before the window dismisses — a surgical body write, so
|
|
/// the keystrokes survive a later restore". The write replaces the body span and nothing else,
|
|
/// so the card is not otherwise disturbed on its way into the trash.
|
|
public func writeCardBody(inCard cardID: ItemID, body: String) -> CardBodyWriteOutcome {
|
|
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return .vanished }
|
|
let folder = target.folder(under: rootURL)
|
|
|
|
do {
|
|
// The closure's signature is spelled out because it returns a value — the inference wart
|
|
// `performWrite`'s doc comment records.
|
|
let wrote = try performWrite { () throws(BoardWriteError) -> Bool in
|
|
try BoardWriter.writeBody(inItemFolder: folder, body: body)
|
|
}
|
|
return wrote ? .written : .unchanged
|
|
} catch let refusal as BoardStoreWriteRefusal {
|
|
guard case let .readOnlyLocked(reason) = refusal else { return .unchanged }
|
|
return .suspended(reason)
|
|
} catch let error as BoardWriteError {
|
|
return .failed(error)
|
|
} catch {
|
|
// `performWrite`'s `throws` is untyped only because its two error types have not been
|
|
// unified yet (`BoardStoreWriteRefusal`); there is no third thing it can throw.
|
|
Self.logger.error("unexpected error saving a card body: \(String(describing: error), privacy: .public)")
|
|
return .unchanged
|
|
}
|
|
}
|
|
|
|
/// Registers **one Edit session** as one undo step — 13-native-undo.md ▸ Rules' coalescing
|
|
/// sentence, stated where the session ends rather than where the bytes land.
|
|
///
|
|
/// ### Why this is not registered in `writeCardBody`
|
|
///
|
|
/// Because a session is not a save. "An Edit session is one step, registered at the Edit→Preview
|
|
/// flip (the effective Save — 05-card-window.md)", and a session contains any number of debounced
|
|
/// saves: registering per write would put a step on the stack every ~700 ms of typing, and ⌘Z
|
|
/// would walk backwards through the user's keystrokes in seven-hundred-millisecond slices rather
|
|
/// than undoing the edit they made. So `CardBodyEditSession` remembers the bytes disk held when
|
|
/// the session's first save landed, and calls this once at the flip with that pair — the same
|
|
/// boundary pro-m1's auto-committer coalesces on, for the same reason.
|
|
///
|
|
/// ### The bytes are the whole state
|
|
///
|
|
/// `BoardWriter.writeBody` replaces the body span and nothing else, so a step built from two body
|
|
/// strings restores the prior body **byte for byte** — unknown keys, comments and key order above
|
|
/// the delimiter were never this write's to change. That is the one inverse in the app whose
|
|
/// fidelity is byte-level rather than field-level.
|
|
///
|
|
/// ### Its staleness predicate is the bytes, at the path the session wrote to
|
|
///
|
|
/// "Body steps compare bytes" (13 ▸ Rules), so the expectation is the whole body span as this
|
|
/// session left it — a foreign editor that changed one character of it skips the step rather than
|
|
/// throwing that character away. The **container rides in the path** (`HistoryStaleness`): a
|
|
/// session that ended because its card was moved to the trash registers against the trash folder
|
|
/// it actually flushed into, and a later restore moves the card out from under the step, which
|
|
/// the ordinary existence check then reads as the collision it is.
|
|
public func registerBodyEdit(inCard cardID: ItemID, priorBody: String, newBody: String) {
|
|
guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
|
|
let folder = target.folder(under: rootURL)
|
|
let title = Self.cardTitle(at: target, in: snapshot)
|
|
|
|
registerStep(
|
|
HistoryPhrase.name(.edit, kind: .card),
|
|
subject: title,
|
|
undoExpects: [.present(folder, .body(newBody))],
|
|
redoExpects: [.present(folder, .body(priorBody))]
|
|
) { _ in
|
|
_ = try BoardWriter.writeBody(inItemFolder: folder, body: priorBody)
|
|
} redo: { _ in
|
|
_ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
|
|
}
|
|
}
|
|
|
|
/// Which folder a card's body write lands in — **the one card walk that spans both containers**.
|
|
///
|
|
/// Every other resolution in this file goes through `boardItem`, whose board-side-only answer is
|
|
/// what keeps gestures off cards that have left the working set. This one deliberately does not:
|
|
/// the card window's dismissal flush has to reach a card that was moved into the trash *out from
|
|
/// under the buffer* (05 ▸ Deletion & lifecycle), and to `boardItem` that card is already gone. A
|
|
/// card whose folder is genuinely no longer in the tree — purged, or moved to another board —
|
|
/// still resolves to `nil`, which is the case 05 answers with "nowhere left to write".
|
|
nonisolated static func cardBodyTarget(_ id: ItemID, in snapshot: BoardModel) -> ItemPath? {
|
|
for lane in snapshot.lanes {
|
|
if lane.cards.contains(where: { $0.id == id }) { return .card(lane: lane.id, id: id) }
|
|
}
|
|
return snapshot.trash.contains { $0.id == id } ? .trashCard(id) : nil
|
|
}
|
|
|
|
/// A card's title at a resolved path, in either container — the skip banner's quoted subject.
|
|
nonisolated static func cardTitle(at path: ItemPath, in snapshot: BoardModel) -> String? {
|
|
switch path {
|
|
case let .card(lane, id):
|
|
snapshot.lanes.first { $0.id == lane }?.cards.first { $0.id == id }?.title.value
|
|
case let .trashCard(id):
|
|
snapshot.trash.first { $0.id == id }?.title.value
|
|
case let .lane(id):
|
|
snapshot.lanes.first { $0.id == id }?.title.value
|
|
}
|
|
}
|
|
|
|
// MARK: - Raw source
|
|
|
|
/// Reads a card's `index.md` as literal text, for the raw-source outlet's entry
|
|
/// (05-card-window.md ▸ Raw source outlet: "Entering source mode flushes any pending title/body
|
|
/// edits first, then reads the file fresh from disk").
|
|
///
|
|
/// **Never from the snapshot**, which is what the design's "fresh" means and what the store is
|
|
/// least able to offer: a `BoardModel` holds a parsed `FrontmatterDocument`, and re-emitting it
|
|
/// would be a rendering of the file rather than the file. It also lags the reload, so a pull or
|
|
/// an agent write that landed a moment ago would be invisible to the one surface that promises to
|
|
/// show what is actually there.
|
|
///
|
|
/// **Ancestor-walked liveness** (`boardItem`), unlike `writeCardBody`'s deliberately liveness-blind
|
|
/// walk: there is nothing to *rescue* here — a tombstoned card's window is dismissing itself, and
|
|
/// opening its whole `index.md` in an editor whose Apply would undelete it is exactly what 05 ▸
|
|
/// Deletion & lifecycle forbids ("An open raw-source buffer discards instead: its Apply writes
|
|
/// the *whole* pre-tombstone `index.md` and would silently undelete the card").
|
|
///
|
|
/// No `performWrite` bracket and no banner: this is a read, and its one failure — a file that is
|
|
/// not UTF-8, or is gone between the snapshot and the read — is the card window's alert to raise,
|
|
/// where it can say "so source mode did not open" rather than joining a strip of write failures.
|
|
public func readCardSource(inCard cardID: ItemID) -> RawSourceReadOutcome {
|
|
guard let target = Self.boardItem(cardID, in: snapshot), let card = target.cardID else { return .vanished }
|
|
let folder = rootURL
|
|
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
|
|
.appendingPathComponent(card.rawValue, isDirectory: true)
|
|
|
|
do {
|
|
return .read(try BoardWriter.readRawSource(ofCard: folder))
|
|
} catch {
|
|
return .failed(error)
|
|
}
|
|
}
|
|
|
|
/// Applies a raw-source buffer: **validate, then write the bytes verbatim** (05-card-window.md ▸
|
|
/// Raw source outlet).
|
|
///
|
|
/// ### Validation runs before the bracket, on purpose
|
|
///
|
|
/// A proposal that would not load is not a failed write — it is a write that never started. Doing
|
|
/// it here means an invalid Apply opens no watcher bracket, posts no banner, and touches nothing;
|
|
/// the typed `BoardLoadError` travels back so the window's alert can show the loader's own detail
|
|
/// ("detailed alert on error, stays in source mode"). `BoardWriter.writeRawSource` validates the
|
|
/// same bytes through the same function again as its own guarantee — the two are one call to
|
|
/// `BoardLoader.validateCardIndex`, not two rules that could drift.
|
|
///
|
|
/// ### Everything else is an ordinary store write
|
|
///
|
|
/// One `performWrite` bracket, so the echo comes back as a single app-mediated reload that
|
|
/// refreshes every window on the board; the banner posts itself on a real failure; the snapshot is
|
|
/// never touched here. The read-only lock refuses it like any other write — Apply is a mutation,
|
|
/// however literal — and the buffer's owner reads `.suspended` as "hold the text", the standing
|
|
/// lock row being the message.
|
|
///
|
|
/// A pull landing mid-session is not consulted at all: "Apply stays last-writer-wins" (05, citing
|
|
/// 07-sync-collab.md), the same posture the Edit buffer takes.
|
|
///
|
|
/// **It registers no undo step**, `toggleTaskMarker`'s reason: 13-native-undo.md ▸ Rules makes the
|
|
/// *Edit session's* body save undoable, and Apply is not one — it is a whole-file replacement of
|
|
/// bytes the user typed themselves, with the raw buffer still on screen as its own record of what
|
|
/// they were.
|
|
public func applyCardSource(inCard cardID: ItemID, text: String) -> RawSourceApplyOutcome {
|
|
guard let target = Self.boardItem(cardID, in: snapshot), let card = target.cardID else { return .vanished }
|
|
let folder = rootURL
|
|
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
|
|
.appendingPathComponent(card.rawValue, isDirectory: true)
|
|
|
|
do throws(BoardLoadError) {
|
|
_ = try BoardLoader.validateCardIndex(Data(text.utf8), path: BoardLoader.indexFileName)
|
|
} catch {
|
|
return .invalid(error)
|
|
}
|
|
|
|
do {
|
|
// The closure's signature is spelled out because it returns a value — the inference wart
|
|
// `performWrite`'s doc comment records.
|
|
let wrote = try performWrite { () throws(BoardWriteError) -> Bool in
|
|
try BoardWriter.writeRawSource(inCard: folder, text: text)
|
|
}
|
|
return wrote ? .applied : .unchanged
|
|
} catch let refusal as BoardStoreWriteRefusal {
|
|
guard case let .readOnlyLocked(reason) = refusal else { return .unchanged }
|
|
return .suspended(reason)
|
|
} catch let error as BoardWriteError {
|
|
return .failed(error)
|
|
} catch {
|
|
Self.logger.error("unexpected error applying raw source: \(String(describing: error), privacy: .public)")
|
|
return .unchanged
|
|
}
|
|
}
|
|
|
|
// 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
|
|
let priorTitle = snapshot.title.value
|
|
let landed: Void? = 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 Self.setTitle(newTitle, at: folder)
|
|
}
|
|
guard landed != nil else { return }
|
|
|
|
// rename → restore title, at the one level with no item to aim at. The board root is never
|
|
// tombstoned however its frontmatter reads (a board-level `deleted:` is a tolerated load
|
|
// warning), so `.live` here means exactly "the root is still readable".
|
|
registerStep(
|
|
HistoryPhrase.name(.rename, kind: .board),
|
|
subject: newTitle ?? priorTitle,
|
|
undoExpects: [.present(folder, .title(newTitle))],
|
|
redoExpects: [.present(folder, .title(priorTitle))]
|
|
) { _ in
|
|
try Self.setTitle(priorTitle, at: folder)
|
|
} redo: { _ in
|
|
try Self.setTitle(newTitle, at: folder)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
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)
|
|
// The rank the lane held before the write and the one it lands on — both read out of the
|
|
// bracket below, because a renumber that fires inside it moves the *prior* value too: the
|
|
// dragged lane is among the renumbered children, so its pre-gesture `order` would no longer
|
|
// place it where it was. What an inverse must restore is the rank the file held immediately
|
|
// before its own rewrite, which is exactly what this captures either way.
|
|
var priorOrder = lanes[from].order
|
|
var newOrder: Double?
|
|
let landed: Void? = 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)
|
|
let renumbered = Ranks.renumbered(count: lanes.count)
|
|
priorOrder = renumbered[from]
|
|
var compacted = renumbered
|
|
compacted.remove(at: from)
|
|
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
|
|
}
|
|
guard let rank else { return }
|
|
newOrder = rank
|
|
|
|
_ = try BoardWriter.moveItem(
|
|
at: folder,
|
|
toParent: root,
|
|
sourceBoardRoot: root,
|
|
destinationBoardRoot: root,
|
|
order: rank
|
|
)
|
|
}
|
|
guard landed != nil, let newOrder else { return }
|
|
|
|
// reorder → restore original `order` (13-native-undo.md ▸ Rules). A lane drag never changes
|
|
// parent — the board root is the only one there is — so 06's vocabulary word for it is
|
|
// Reorder, not Move.
|
|
let restored = priorOrder
|
|
registerStep(
|
|
HistoryPhrase.name(.reorder, kind: .lane),
|
|
subject: lanes[from].title.value,
|
|
undoExpects: [.present(folder, .order(newOrder))],
|
|
redoExpects: [.present(folder, .order(restored))]
|
|
) { _ in
|
|
try Self.setOrder(restored, at: folder)
|
|
} redo: { _ in
|
|
try Self.setOrder(newOrder, at: folder)
|
|
}
|
|
}
|
|
|
|
/// The bare rank rewrite an inverse reorder performs — `moveItem`'s same-parent degenerate path
|
|
/// with the URL arithmetic taken out, since an inverse always names the folder directly.
|
|
private static func setOrder(_ order: Double, at folder: URL) throws(BoardWriteError) {
|
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .reorder(title: nil)) { document in
|
|
document.set(FrontmatterKeys.order, to: .double(order))
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
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
|
|
// `moveLane`'s capture, per member — see its note on why the prior rank is read out of the
|
|
// bracket rather than off the snapshot.
|
|
var priorOrders = members.map(\.order)
|
|
var rewrites: [(folder: URL, order: Double)] = []
|
|
let landed: Void? = 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 renumbered = Array(zip(lanes, Ranks.renumbered(count: lanes.count)))
|
|
priorOrders = renumbered.filter { ids.contains($0.0.id) }.map(\.1)
|
|
let compacted = renumbered.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.id.rawValue, isDirectory: true)
|
|
rewrites.append((folder: folder, order: rank))
|
|
_ = try BoardWriter.moveItem(
|
|
at: folder,
|
|
toParent: root,
|
|
sourceBoardRoot: root,
|
|
destinationBoardRoot: root,
|
|
order: rank
|
|
)
|
|
}
|
|
}
|
|
guard landed != nil, !rewrites.isEmpty else { return }
|
|
|
|
// reorder → restore original `order`, one step for the whole run: "one `performWrite` bracket
|
|
// per gesture whatever the set's size" is the same sentence as one gesture, one undo step.
|
|
let inverse = Array(zip(rewrites.map(\.folder), priorOrders))
|
|
let forward = rewrites
|
|
registerStep(
|
|
HistoryPhrase.name(.reorder, kind: .lane, count: forward.count),
|
|
subject: members.count == 1 ? members[0].title.value : nil,
|
|
undoExpects: forward.map { .present($0.folder, .order($0.order)) },
|
|
redoExpects: inverse.map { .present($0.0, .order($0.1)) }
|
|
) { _ in
|
|
for (folder, order) in inverse {
|
|
try Self.setOrder(order, at: folder)
|
|
}
|
|
} redo: { _ in
|
|
for write in forward {
|
|
try Self.setOrder(write.order, at: write.folder)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: **where it is now** — a lane,
|
|
/// or the board's trash.
|
|
private struct DraggedCard {
|
|
let id: ItemID
|
|
/// The card's current home, which is also where an undo puts it back.
|
|
let path: ItemPath
|
|
let order: Double
|
|
/// What it is called, for the skip banner a stale step would raise — read here because the
|
|
/// snapshot this resolves against is the pre-write one, which is where a title still is.
|
|
let title: String?
|
|
|
|
/// The parent folder an inverse move returns it to.
|
|
func parent(under root: URL) -> URL {
|
|
switch path {
|
|
case let .card(lane, _): ItemPath.lane(lane).folder(under: root)
|
|
case .trashCard: BoardWriter.trashFolder(inBoard: root)
|
|
case .lane: root
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `ids` narrowed to cards the snapshot holds and sorted into **flatten order** — "lane `order`
|
|
/// first, then card `order`" (`SelectionGrammar.boardCards`), which is what "drop inserts
|
|
/// contiguously in preserved relative order" means and the only order a `Set` cannot supply.
|
|
///
|
|
/// **Both containers, because a drag out of the trash is an ordinary move** (03-board-ui.md §
|
|
/// Trash, resettled 2026-07-28: "Restoring is an ordinary move out … there is no restore-specific
|
|
/// machinery and no Put Back"). A drag membership set is homogeneous by container, so exactly one
|
|
/// of the two branches below ever contributes; asking both is what lets `moveCards` and
|
|
/// `copyCards` serve the restore without a second code path able to disagree with them.
|
|
///
|
|
/// Members that vanished 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 members: [DraggedCard] = []
|
|
for lane in snapshot.lanes {
|
|
for card in lane.cards where ids.contains(card.id) {
|
|
members.append(DraggedCard(
|
|
id: card.id,
|
|
path: .card(lane: lane.id, id: card.id),
|
|
order: card.order,
|
|
title: card.title.value
|
|
))
|
|
}
|
|
}
|
|
guard members.isEmpty else { return members }
|
|
for card in snapshot.trash where ids.contains(card.id) {
|
|
members.append(DraggedCard(
|
|
id: card.id,
|
|
path: .trashCard(card.id),
|
|
order: card.order,
|
|
title: card.title.value
|
|
))
|
|
}
|
|
return members
|
|
}
|
|
|
|
/// 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 }) else { return }
|
|
let members = draggedCards(ids)
|
|
guard !members.isEmpty else { return }
|
|
|
|
let rendered = destination.cards
|
|
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
|
|
// — or in the trash — makes the two lists differ by construction, so this covers the
|
|
// cross-container case too.
|
|
guard DropSlotMath.applied(rendered.map(\.id), moving: memberIDs, to: target) != rendered.map(\.id)
|
|
else { return }
|
|
|
|
let root = rootURL
|
|
let laneFolder = ItemPath.lane(laneID).folder(under: root)
|
|
|
|
// The pre-write home of every member, per 13's "move → move back (original lane, original
|
|
// `order`)" — and, for a card coming out of the trash, back into `.trash/` at the rank it
|
|
// was filed under. A renumber inside the bracket rewrites the destination lane's own cards,
|
|
// so a member that was already there has its captured rank refreshed — `moveLane`'s note.
|
|
var origins = Dictionary(uniqueKeysWithValues: members.map { ($0.id, (path: $0.path, order: $0.order)) })
|
|
var arrivals: [(id: ItemID, order: Double)] = []
|
|
let landed: Void? = 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
|
|
// 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 renumbered = Array(zip(rendered, Ranks.renumbered(count: rendered.count)))
|
|
for (card, rank) in renumbered where ids.contains(card.id) {
|
|
origins[card.id] = (path: .card(lane: laneID, id: card.id), order: rank)
|
|
}
|
|
let compacted = renumbered.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) {
|
|
arrivals.append((id: member.id, order: rank))
|
|
_ = try BoardWriter.moveItem(
|
|
at: member.path.folder(under: root),
|
|
toParent: laneFolder,
|
|
sourceBoardRoot: root,
|
|
destinationBoardRoot: root,
|
|
order: rank
|
|
)
|
|
}
|
|
}
|
|
guard landed != nil, !arrivals.isEmpty else { return }
|
|
|
|
// move → move back (original container, original `order`); a drop that never left its lane is
|
|
// 06's Reorder rather than Move, which is the same distinction the commit vocabulary draws —
|
|
// and a card arriving from the trash always counts as a Move, because it crossed containers.
|
|
let inverse: [(from: URL, toParent: URL, order: Double)] = arrivals.compactMap { arrival in
|
|
guard let origin = origins[arrival.id] else { return nil }
|
|
return (
|
|
from: laneFolder.appendingPathComponent(arrival.id.rawValue, isDirectory: true),
|
|
toParent: Self.parentFolder(of: origin.path, under: root),
|
|
order: origin.order
|
|
)
|
|
}
|
|
let forward: [(from: URL, order: Double)] = arrivals.compactMap { arrival in
|
|
guard let origin = origins[arrival.id] else { return nil }
|
|
return (from: origin.path.folder(under: root), order: arrival.order)
|
|
}
|
|
let crossed = members.contains { member in
|
|
if case let .card(lane, _) = member.path { return lane != laneID }
|
|
return true
|
|
}
|
|
// The two lists are index-aligned mirror images — `inverse[i].from` is where the card is now
|
|
// and `forward[i].from` is where it was — so the expectations read as one swap: **the undo
|
|
// wants the card at its destination holding the rank the drop gave it; the redo wants it back
|
|
// at its origin holding the rank it left.** The destination *path* is both the lane check and
|
|
// the container check: a card a foreign writer moved elsewhere — into the trash included —
|
|
// leaves nothing there to validate (`HistoryStaleness`).
|
|
registerStep(
|
|
HistoryPhrase.name(crossed ? .move : .reorder, kind: .card, count: arrivals.count),
|
|
subject: members.count == 1 ? members[0].title : nil,
|
|
undoExpects: zip(inverse, forward).map { .present($0.from, .order($1.order)) },
|
|
redoExpects: zip(inverse, forward).map { .present($1.from, .order($0.order)) }
|
|
) { _ in
|
|
for step in inverse {
|
|
_ = try BoardWriter.moveItem(
|
|
at: step.from,
|
|
toParent: step.toParent,
|
|
sourceBoardRoot: root,
|
|
destinationBoardRoot: root,
|
|
order: step.order
|
|
)
|
|
}
|
|
} redo: { _ in
|
|
for step in forward {
|
|
_ = try BoardWriter.moveItem(
|
|
at: step.from,
|
|
toParent: laneFolder,
|
|
sourceBoardRoot: root,
|
|
destinationBoardRoot: root,
|
|
order: step.order
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The parent folder an item at `path` sits in — the destination an inverse move returns it to.
|
|
nonisolated static func parentFolder(of path: ItemPath, under root: URL) -> URL {
|
|
switch path {
|
|
case let .card(lane, _): ItemPath.lane(lane).folder(under: root)
|
|
case .trashCard: BoardWriter.trashFolder(inBoard: root)
|
|
case .lane: root
|
|
}
|
|
}
|
|
|
|
/// 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 }) else { return }
|
|
let members = draggedCards(ids)
|
|
guard !members.isEmpty else { return }
|
|
|
|
let rendered = destination.cards
|
|
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 = ItemPath.lane(laneID).folder(under: root)
|
|
|
|
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) {
|
|
_ = try BoardWriter.copyItem(
|
|
at: member.path.folder(under: root),
|
|
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.
|
|
//
|
|
// **None of these register an undo step, and the reason is 13's own two sentences.** Its inverse
|
|
// inventory names nine operations and an arrival is not among them; and "undo is board-local" —
|
|
// one stack per board — while a cross-board move's inverse would have to write into the *source*
|
|
// board, whose stack knows nothing about it and whose window may not even be open. The clipboard's
|
|
// half is the same shape one remove further: a paste's inverse needs the staged tree to still be
|
|
// there, which is exactly the staging lifecycle 13 defers with the attachment operations. Within a
|
|
// board, `copyCards`' ⌥-drag is left out with them: its Writer operation is `.copy`, not a create,
|
|
// and the three arrival paths are one gesture family that should gain undo together or not at all.
|
|
//
|
|
// `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,
|
|
normalizingLooseFiles: false
|
|
)
|
|
}
|
|
|
|
/// **The clipboard's card arrival** — `receiveCards` with the one axis 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`) and `.move` is the armed cut's — "the ⌘-drag
|
|
/// move path — identity travels", which is also the keyboard restore when the cut was made in the
|
|
/// trash (▸ The trash: "cut in the trash, paste into a lane is the keyboard-native restore, an
|
|
/// ordinary folder move").
|
|
///
|
|
/// **The trash's old copy-out rule is gone with the tombstone it stripped** (resettled
|
|
/// 2026-07-28): a trashed card carries no `deleted:` key, so a card copied out of the trash is
|
|
/// an ordinary copy of an ordinary card and there is nothing to clear at materialization.
|
|
///
|
|
/// `normalizingLooseFiles` is the remaining axis: **"a paste is an import boundary, so
|
|
/// normalization applies"** (04-interactions.md ▸ Clipboard, settled 2026-07-28 —
|
|
/// 01-storage-format.md's loose-file rule). Loose files the staged snapshot carries beside a
|
|
/// card's `index.md` land in the pasted card's `attachments/`, Finder-renamed on collision, so
|
|
/// "nothing the snapshot preserved is dropped on arrival" *and* nothing arrives out of place.
|
|
///
|
|
/// It has **no default**, here and on `receiveLanes`, so every arrival path states which side of
|
|
/// the import boundary it is on rather than inheriting an answer. The clipboard passes `true`
|
|
/// (both operations: 04 says "a paste is an import boundary" unqualified, and an armed cut's
|
|
/// move is a paste); the drag passes `false` and leaves its arrivals to the destination board's
|
|
/// own carve-out, which relocates on the next reload with the notice a user-initiated paste has
|
|
/// no need of.
|
|
public func receiveCards(
|
|
_ sources: [ItemSource],
|
|
operation: TransferOperation,
|
|
toLane laneID: ItemID,
|
|
at index: Int,
|
|
normalizingLooseFiles: Bool
|
|
) {
|
|
receive(
|
|
sources,
|
|
operation: operation,
|
|
toLane: laneID,
|
|
at: index,
|
|
normalizingLooseFiles: normalizingLooseFiles
|
|
)
|
|
}
|
|
|
|
private func receive(
|
|
_ sources: [ItemSource],
|
|
operation: TransferOperation,
|
|
toLane laneID: ItemID,
|
|
at index: Int,
|
|
normalizingLooseFiles: Bool
|
|
) {
|
|
guard !sources.isEmpty,
|
|
let destination = snapshot.lanes.first(where: { $0.id == laneID })
|
|
else { return }
|
|
|
|
let rendered = destination.cards
|
|
let target = min(max(0, index), rendered.count)
|
|
let root = rootURL
|
|
let laneFolder = ItemPath.lane(laneID).folder(under: root)
|
|
|
|
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 }
|
|
// Inside the same bracket, so the card lands normalized in one round trip rather
|
|
// than appearing loose for a reload and being tidied afterwards.
|
|
guard normalizingLooseFiles else { continue }
|
|
try BoardWriter.normalizeLooseFiles(
|
|
inCard: 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 lanes.
|
|
///
|
|
/// **The two operations no longer differ** (04-interactions.md ▸ Drag and drop, resettled
|
|
/// 2026-07-28): "A lane carries exactly its cards — the trash is board-level (`.trash/`), so
|
|
/// there is nothing lane-nested to strip or carry: copy and ⌘-drag move alike transfer the lane's
|
|
/// folder as it is; the old tombstone-stripping rule is retired with the tombstone model." A
|
|
/// copy therefore mints fresh GUIDs and a move carries the identity, and that is the whole of the
|
|
/// difference.
|
|
///
|
|
/// 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,
|
|
normalizingLooseFiles: false
|
|
)
|
|
}
|
|
|
|
/// **The clipboard's lane arrival** — `receiveLanes` with the staging-less fallback folded in
|
|
/// (04-interactions.md ▸ Clipboard).
|
|
///
|
|
/// The two operations keep their drag semantics exactly, because 04 says they are the same
|
|
/// semantics: "a pasted *copy* takes fresh GUIDs throughout; a cut-paste is the ⌘-drag move —
|
|
/// the folder moves whole (nothing lane-nested to strip or carry — the trash is board-level)".
|
|
///
|
|
/// `normalizingLooseFiles` is the import boundary's, exactly as on `receiveCards` and with the
|
|
/// same no-default rule; at lane level it reaches each arriving lane's **cards**, which is the
|
|
/// only level the carve-out has (a lane's own loose files keep the verbatim posture).
|
|
public func receiveLanes(
|
|
_ sources: [ItemSource],
|
|
operation: TransferOperation,
|
|
at stripIndex: Int,
|
|
normalizingLooseFiles: Bool
|
|
) {
|
|
guard !sources.isEmpty else { return }
|
|
|
|
let root = rootURL
|
|
let rendered = snapshot.lanes
|
|
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 }
|
|
|
|
guard normalizingLooseFiles else { continue }
|
|
try BoardWriter.normalizeLooseFiles(
|
|
inLane: root.appendingPathComponent(arrived.rawValue, isDirectory: true)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Finder file drops
|
|
|
|
// **The attachment half registers no undo step** (13-native-undo.md ▸ Out of scope, ratified
|
|
// 2026-07-27): "attachment add/remove registers **no undo step** in v1", because remove →
|
|
// re-add needs the removed file to survive somewhere and that staging area is a design pass of
|
|
// its own. Add → remove would be a clean inverse on its own, but half a pair is worse than none:
|
|
// ⌘Z would undo attaching and refuse to undo detaching, which is not a rule anyone could learn.
|
|
// The *card-creating* half below is an ordinary create and does register one.
|
|
//
|
|
// 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.
|
|
///
|
|
/// **The board container, and only it** (`boardItem`): a card that has been deleted is in
|
|
/// `.trash/`, 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 trash 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.boardItem(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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Removing an attachment
|
|
|
|
/// Moves one of a card's attachments to the **system** Trash — the card window attachment row's
|
|
/// Remove, its ⌫ twin, and nothing else (05-card-window.md ▸ Attachments).
|
|
///
|
|
/// **An ordinary bracketed write, which is the whole point of it being here** rather than a
|
|
/// `FileManager` call in the view: it mutates the card's folder, so the churn has to round back
|
|
/// as one *app-mediated* reload (the echo the watcher would otherwise read as a foreign edit),
|
|
/// it has to refuse under the read-only lock like every other mutation (`performWrite`'s gate),
|
|
/// and its failures have to reach the banner strip like every other write's. On git boards it
|
|
/// is also one commit, for free, for the same reason.
|
|
///
|
|
/// The guards are `importAttachments`' exactly, and its inverse in every way: **the board
|
|
/// container and only it** (`boardItem`), so a trashed card is as unreachable as a deleted one
|
|
/// and its attachments are not removable from a window that is dismissing itself in the same
|
|
/// breath; a lane id is refused because attachments belong to cards. Which *file* may go is
|
|
/// `BoardWriter.removeAttachment`'s listing check, and a name that is no longer there is a
|
|
/// silent no-op rather than a failure — the reload is the authority on what the card has.
|
|
public func removeAttachment(named name: String, fromCard cardID: ItemID) {
|
|
guard !name.isEmpty,
|
|
let item = Self.boardItem(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.removeAttachment(named: name, fromCard: folder)
|
|
}
|
|
}
|
|
|
|
// MARK: - The loose-file carve-out
|
|
|
|
/// Moves every loose file the last applied snapshot found beside a card's `index.md` into that
|
|
/// card's `attachments/`, and posts one notice naming what moved — the **act** half of
|
|
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28,
|
|
/// "Lanework-owns-the-board"; the loader's `looseCardFiles` is the notice half).
|
|
///
|
|
/// **It registers no undo step**, and unlike its neighbours that is not a deferral: nobody asked
|
|
/// for it. The relocation is the app tidying its own house on a reload, not a gesture — there is
|
|
/// no ⌘Z that should follow it, and putting one on the stack would let the next ⌘Z undo something
|
|
/// the user never did. (It is `renumberVisibleChildren`'s posture: bookkeeping composes no event,
|
|
/// 06-history-undo.md ▸ Commit messages.)
|
|
///
|
|
/// **It is an ordinary app write and nothing more.** One `performWrite` bracket over the whole
|
|
/// board's worth of relocation, so the churn rounds back as a single app-mediated reload and (on
|
|
/// git boards) a single commit — the style batch's rule, applied to a batch the app started
|
|
/// itself. The snapshot is not touched here any more than it is anywhere else: the files move,
|
|
/// the watcher notices, the reload lands.
|
|
///
|
|
/// ### The read-only lock defers it, it does not cancel it
|
|
///
|
|
/// "The relocation … waits out any read-only lock — strays stay tolerated until it clears." A
|
|
/// locked board returns here having written nothing **and having remembered nothing**, so the
|
|
/// next attempt is a fresh one. The arming seam is `land(_:generation:origin:)`: every lock
|
|
/// clears on a successful reload and nowhere else, and this runs at the end of every successful
|
|
/// reload, after `reconcileLock(after:)` — so the reload that lifts the lock is the reload that
|
|
/// performs the relocation, with no timer, no queue, and no second state to keep in step. The
|
|
/// same ordering covers the other direction, now that the probe is symmetric: a reconciling
|
|
/// reload that *raises* the unwritable-location lock raises it before this runs.
|
|
///
|
|
/// ### It cannot hot-loop
|
|
///
|
|
/// The relocation's own reload re-walks the tree, which is the loop the guard exists for. After
|
|
/// a success the walk finds nothing loose, `looseCardFiles` empties, and the memo below is
|
|
/// cleared — the ordinary resting state. After a *failure* the walk finds the same files again,
|
|
/// and an unguarded call would fail again, forever, at the speed of a directory walk. So an
|
|
/// attempt is made only when the loose-file set **differs from the last one attempted**: one
|
|
/// failure, one banner row, then silence until the picture on disk actually changes (a file
|
|
/// added, removed, or partially moved by the failed attempt itself — each of which is a
|
|
/// different set and so a fresh attempt).
|
|
///
|
|
/// The failure is the banner's already: `performWrite` posts every `BoardWriteError` before it
|
|
/// rethrows, and the rethrow is swallowed here like every other gesture with nothing else to do
|
|
/// about it. Files moved before the failure stay moved, and the notice names exactly those.
|
|
public func relocateLooseCardFiles() {
|
|
let work = looseCardFiles
|
|
guard !work.isEmpty else {
|
|
// The resting state, and the memo's reset: a board with nothing loose has nothing to
|
|
// remember having tried.
|
|
attemptedRelocation = []
|
|
return
|
|
}
|
|
// Deferred, not abandoned — and deliberately *before* the memo is written, so the attempt
|
|
// this lock refused is not the attempt the guard below remembers.
|
|
guard readOnlyLock == nil else {
|
|
Self.logger.debug("loose-file relocation deferred — the board is read-only")
|
|
return
|
|
}
|
|
let signature = Self.relocationSignature(of: work)
|
|
guard signature != attemptedRelocation else { return }
|
|
attemptedRelocation = signature
|
|
|
|
let root = rootURL
|
|
var relocated: [BannerCenter.Relocation] = []
|
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
|
for card in work {
|
|
let folder = root
|
|
.appendingPathComponent(card.laneID.rawValue, isDirectory: true)
|
|
.appendingPathComponent(card.cardID.rawValue, isDirectory: true)
|
|
let moved = try BoardWriter.relocateLooseFiles(card.fileNames, inCard: folder)
|
|
// A card whose files all vanished under the write contributes no line: the Writer
|
|
// skipped them because they are gone, and nothing was moved to report.
|
|
guard !moved.isEmpty else { continue }
|
|
relocated.append(BannerCenter.Relocation(
|
|
title: card.title,
|
|
fileNames: moved.map { $0.sourceURL.lastPathComponent }
|
|
))
|
|
}
|
|
}
|
|
banners.postRelocatedLooseFiles(relocated)
|
|
}
|
|
|
|
/// The loose-file picture as a comparable value: one entry per file, keyed by where it sits.
|
|
///
|
|
/// A `Set` rather than the array itself because the *identity* of the work is what matters, not
|
|
/// the order the walk happened to meet it in — and because two loads of an unchanged tree must
|
|
/// compare equal even if a lane's folder-name ordering shifted underneath them.
|
|
nonisolated static func relocationSignature(of work: [LooseCardFiles]) -> Set<String> {
|
|
var signature: Set<String> = []
|
|
for card in work {
|
|
for name in card.fileNames {
|
|
signature.insert("\(card.laneID.rawValue)/\(card.cardID.rawValue)/\(name)")
|
|
}
|
|
}
|
|
return signature
|
|
}
|
|
|
|
/// 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-into-a-lane half.
|
|
///
|
|
/// **`index` is the drop position, not the lane's end** (04-interactions.md ▸ Drag and drop,
|
|
/// settled 2026-07-28): "created cards land at the drop position — resolved through the same
|
|
/// card-grid zones an ordinary card drag uses … drops are positional everywhere, and
|
|
/// append-at-bottom stays the creation *trio*'s rule, not the drop's". The gesture resolves it
|
|
/// through `FileDropZones.landing`; the rank arithmetic below is `moveCards`', so a run landing
|
|
/// between two siblings takes exactly the ranks a card drop there would have produced.
|
|
///
|
|
/// **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).
|
|
///
|
|
/// **A Finder file drop is a user-initiated creation, so it clears the search**
|
|
/// (04-interactions.md § Search, stated by mechanism: "⌘N, Return-creation, the header button,
|
|
/// empty-space double-click, paste, and Finder file drops alike"). Cleared at the gesture, in
|
|
/// front of the write, exactly as the placeholder's begin clears it in front of the typing —
|
|
/// `TransientBoardState.noteUserCreation()` is the rule's one home, and the *attach* half of the
|
|
/// same gesture (`importAttachments`) deliberately does not call it, because a drop on a card
|
|
/// creates nothing that could be born invisible.
|
|
///
|
|
/// **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 })
|
|
else { return }
|
|
|
|
transient.noteUserCreation()
|
|
|
|
let rendered = lane.cards
|
|
let target = min(max(0, index), rendered.count)
|
|
let root = rootURL
|
|
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
|
|
|
var created: [(folder: URL, source: URL)] = []
|
|
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
|
|
}
|
|
created.append((folder: folder, source: url))
|
|
}
|
|
}
|
|
|
|
// create → remove the created folder (13-native-undo.md ▸ Rules), one step for the drop
|
|
// whatever its file count. The redo re-imports from the same source URLs the gesture used —
|
|
// the one create in the app whose replay needs more than the card's own bytes. Collected
|
|
// from what actually landed rather than from `urls`, so a batch that failed halfway still
|
|
// hands ⌘Z exactly the cards it left behind.
|
|
let items = created.compactMap { createdItem(at: $0.folder, kind: .card, attachments: [$0.source]) }
|
|
registerCreation(
|
|
items,
|
|
kind: .card,
|
|
subject: created.count == 1 ? Self.cardTitle(forFile: created[0].source) : nil
|
|
)
|
|
}
|
|
|
|
/// 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 trash-side
|
|
/// selection ("⌥⌘↑/⌥⌘↓ are inert on trash cards — the trash's order is its arrival order, not a
|
|
/// workspace to arrange"), 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.container == .board,
|
|
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 })
|
|
else { return nil }
|
|
|
|
let rendered = lane.cards.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
|
|
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) })
|
|
|
|
// Every rank this gesture rewrites, with the value it replaced — the permutation's own
|
|
// inverse. It is read out of the bracket because the ladder may be the *renumbered* one:
|
|
// after a compaction the card at display position `origin` holds `ladder[origin]`, which is
|
|
// what its own rewrite overwrites and therefore what an undo has to put back.
|
|
var rewrites: [(folder: URL, from: Double, to: Double)] = []
|
|
let landed: Void? = 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]
|
|
let folder = laneFolder.appendingPathComponent(id.rawValue, isDirectory: true)
|
|
rewrites.append((folder: folder, from: ladder[origin], to: rank))
|
|
try BoardWriter.updateIndex(
|
|
inItemFolder: folder,
|
|
// `.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))
|
|
}
|
|
}
|
|
}
|
|
guard landed != nil, !rewrites.isEmpty else { return }
|
|
|
|
// reorder → restore original `order` (13-native-undo.md ▸ Rules). The step is named for the
|
|
// *gesture's* subject — the cards the user was moving — not for every sibling the permutation
|
|
// displaced, which is the same rule 06 applies to a commit subject.
|
|
//
|
|
// Every rewritten rank is validated, the displaced siblings' included: they are what this
|
|
// permutation wrote, so they are what it must find unchanged — the step is *named* for the
|
|
// gesture's subject and *validated* over its whole write.
|
|
let steps = rewrites
|
|
let moved = selection.ids
|
|
registerStep(
|
|
HistoryPhrase.name(.reorder, kind: .card, count: moved.count),
|
|
subject: moved.count == 1 ? moved.first.flatMap { Self.boardItem($0, in: snapshot)?.title } : nil,
|
|
undoExpects: steps.map { .present($0.folder, .order($0.to)) },
|
|
redoExpects: steps.map { .present($0.folder, .order($0.from)) }
|
|
) { _ in
|
|
for step in steps {
|
|
try Self.setOrder(step.from, at: step.folder)
|
|
}
|
|
} redo: { _ in
|
|
for step in steps {
|
|
try Self.setOrder(step.to, at: step.folder)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 a card on this board destroys the only copy of it — and
|
|
/// therefore whether a permanent delete stands an alert between one keystroke and unrecoverable
|
|
/// deletion (03-board-ui.md § Trash, "Both confirm 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 they act immediately (delete-never-
|
|
// forgets)" (06-history-undo.md). 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 }
|
|
|
|
/// **File ▸ Delete ⌘⌫ and its plain-⌫ grammar twin — staged by place** (04-interactions.md ▸
|
|
/// The map, resettled 2026-07-28: "one Delete vocabulary, staged by place").
|
|
///
|
|
/// The selection's container is the whole of the staging, and it is asked exactly once, here:
|
|
/// a board selection moves into `.trash/` (or, for lanes, deletes physically), a trash selection
|
|
/// deletes **permanently**. That is why Put Back's ⌘⌫ twin could retire — there is one Delete
|
|
/// item and one predicate, and which write it performs is a fact about where the user was
|
|
/// working, not about which of two menu rows AppKit happened to enable.
|
|
///
|
|
/// **The confirmation is not here.** Whether the permanent branch's loss is real is
|
|
/// `purgeIsUnrecoverable`'s question and the alert is the window's (`TrashConfirmations`); a
|
|
/// store method that put up its own dialog could not be driven from a test.
|
|
public func deleteSelection() {
|
|
switch selection.container {
|
|
case .board: delete(selection.ids)
|
|
case .trash: deleteTrashCards(selection.ids)
|
|
}
|
|
}
|
|
|
|
/// Deletes every **board** item in `ids` — cards into `.trash/`, lanes outright — in one bracket.
|
|
///
|
|
/// **Two writes, because they are two acts** (03-board-ui.md § Trash): "deleting a card moves its
|
|
/// folder into `<board-root>/.trash/`", while "Cards only. Lanes are never trashed — deleting a
|
|
/// lane deletes it, folder and contents, physically. The net is undo, not the trash."
|
|
///
|
|
/// **A set naming both is not a gesture this app can produce** — the selection is cards XOR lanes
|
|
/// (04-interactions.md § Selection) — so the partition below never actually splits, and when a
|
|
/// caller hands one anyway the lanes win: a lane delete takes its cards with it, and running the
|
|
/// trash move as well would put a second step on the undo stack for one keystroke.
|
|
///
|
|
/// **Ids that name nothing are silently skipped**, not refused: the paths are resolved against
|
|
/// the snapshot, so a selection the next reload will drop writes nothing. An empty resolution
|
|
/// never opens a 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 delete 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).
|
|
public func delete(_ ids: Set<ItemID>) {
|
|
let paths = ItemPath.resolve(ids, in: .board, snapshot: snapshot)
|
|
guard !paths.isEmpty else { return }
|
|
// The successor is drawn from what the container 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: .board,
|
|
snapshot: snapshot,
|
|
filter: searchFilter
|
|
)
|
|
|
|
let lanes = paths.compactMap { path -> ItemID? in
|
|
guard case let .lane(id) = path else { return nil }
|
|
return id
|
|
}
|
|
let landed = lanes.isEmpty
|
|
? moveToTrash(paths.compactMap(Self.cardMove(of:)))
|
|
: removeLanes(lanes)
|
|
guard landed else { return }
|
|
|
|
if let successor {
|
|
select([successor], in: .board, anchor: successor, head: successor)
|
|
} else {
|
|
clearSelection()
|
|
}
|
|
}
|
|
|
|
/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash): "the drag is the pointer's delete
|
|
/// gesture — release moves the dragged card(s) into `.trash/`".
|
|
///
|
|
/// *Exactly* the ⌫ delete is a claim about the disk, and `moveToTrash(_:)` is what makes it
|
|
/// structural rather than a matter of two call sites staying in step: one write op, one bracket,
|
|
/// one set of ranks and stamps, so a card deleted by drop and a card deleted by keystroke are
|
|
/// byte-indistinguishable afterwards.
|
|
///
|
|
/// ### The one thing it does not share is the successor
|
|
///
|
|
/// ⌫ moves the selection to the deleted card'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
|
|
/// board-side set ejects members that cross into the trash, as the vanish it is
|
|
/// (02-architecture.md's reload-survival rule).
|
|
public func deleteByDrag(cardIDs: [ItemID]) {
|
|
_ = moveToTrash(ItemPath.resolve(Set(cardIDs), in: .board, snapshot: snapshot).compactMap(Self.cardMove(of:)))
|
|
}
|
|
|
|
/// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — moves the card
|
|
/// to the trash … the window then dismisses itself").
|
|
///
|
|
/// The write is `moveToTrash(_:)`, so a card deleted from its own window is byte-indistinguishable
|
|
/// from one deleted with ⌫ on the board or dropped on the trash column. What differs is the same
|
|
/// thing that differs for the drag, and for its reason: **it says nothing about the selection.**
|
|
///
|
|
/// **It does not dismiss the window either**, and must not: the window's dismissal is a *fate*
|
|
/// re-derived from every snapshot (`CardWindowHost.cardWindowFate`), so the move this writes
|
|
/// comes back through the watcher and the fate walk takes the window down — the same path an
|
|
/// agent's or another window's delete takes. A second dismissal from here would be a second rule
|
|
/// able to disagree with the first.
|
|
public func deleteCard(_ id: ItemID) {
|
|
_ = moveToTrash(ItemPath.resolve([id], in: .board, snapshot: snapshot).compactMap(Self.cardMove(of:)))
|
|
}
|
|
|
|
/// One card about to be moved into the trash: where it is now, and what an undo has to put back.
|
|
private struct TrashMove {
|
|
let id: ItemID
|
|
let laneID: ItemID
|
|
/// The rank it holds in its lane — the position an undo returns it to (13's "move → move
|
|
/// back (original lane, original `order`)").
|
|
let order: Double
|
|
let title: String?
|
|
}
|
|
|
|
/// A resolved board path as a card move, or `nil` for a lane — the one place the partition is
|
|
/// spelled, so no caller re-derives it.
|
|
private static func cardMove(of path: ItemPath) -> (lane: ItemID, card: ItemID)? {
|
|
guard case let .card(lane, id) = path else { return nil }
|
|
return (lane, id)
|
|
}
|
|
|
|
/// **The delete write itself: a physical move into `<root>/.trash/`, at a freshly minted top
|
|
/// rank** — one `performWrite` bracket whatever the set's size and whichever gesture asked.
|
|
///
|
|
/// Spelled once so ⌫, drop-on-trash and the card window's button cannot drift apart on disk;
|
|
/// everything that differs between them is about the *selection*, and lives in the callers.
|
|
///
|
|
/// **The rank is the store's to mint** (03-board-ui.md § Trash: "every arrival lands at the
|
|
/// trash's topmost position, minting an `order` rank above the current top"). That is a question
|
|
/// about the snapshot, which the stateless Writer does not have — so `Ranks.insertAtHead` runs
|
|
/// here over `snapshot.trash`, and a multi-card delete threads the minted rank back through the
|
|
/// running list so each card in the run lands above the one before it. Newest-first therefore
|
|
/// falls out of ordinary ranks, with no timestamp sort anywhere.
|
|
///
|
|
/// - Returns: whether the write landed, so a caller can decide what to do with the selection.
|
|
@discardableResult
|
|
private func moveToTrash(_ cards: [(lane: ItemID, card: ItemID)]) -> Bool {
|
|
let moves: [TrashMove] = cards.compactMap { entry in
|
|
guard let lane = snapshot.lanes.first(where: { $0.id == entry.lane }),
|
|
let card = lane.cards.first(where: { $0.id == entry.card })
|
|
else { return nil }
|
|
return TrashMove(id: card.id, laneID: lane.id, order: card.order, title: card.title.value)
|
|
}
|
|
guard !moves.isEmpty else { return false }
|
|
|
|
let root = rootURL
|
|
// The ranks, minted against the trash as it stands and threaded forward: each arrival is
|
|
// above the previous one, so a three-card ⌫ reads newest-first in the column exactly as three
|
|
// separate deletes would.
|
|
var ladder = snapshot.trash.map(\.order)
|
|
var ranks: [Double] = []
|
|
for _ in moves {
|
|
let rank = Ranks.insertAtHead(ofVisible: ladder)
|
|
ranks.append(rank)
|
|
ladder.insert(rank, at: 0)
|
|
}
|
|
|
|
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
|
for (move, rank) in zip(moves, ranks) {
|
|
try BoardWriter.deleteCardToTrash(
|
|
at: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root),
|
|
inBoard: root,
|
|
order: rank
|
|
)
|
|
}
|
|
}
|
|
guard landed != nil else { return false }
|
|
|
|
// delete → **move back out of `.trash/`** (13-native-undo.md ▸ Rules, ▸ Interaction with the
|
|
// trash: "a card delete is a move into `.trash/`, so its undo is the ordinary inverse move,
|
|
// returning the card to its source lane and rank").
|
|
//
|
|
// The redo replays the *forward* write with its own captured rank, exactly as every other
|
|
// redo in this file replays the values its gesture wrote — so a redone delete lands the card
|
|
// back where the undo took it from, rather than at whatever the top of the trash has become
|
|
// in the meantime.
|
|
//
|
|
// **The expectations are one swap, and the container rides in the path** (`HistoryStaleness`):
|
|
// the undo wants the card in the trash holding the rank the delete gave it; the redo wants it
|
|
// back in its lane holding the rank it left. A foreign restore empties the trash path and the
|
|
// undo skips; a foreign re-delete empties the lane path and the redo skips.
|
|
let steps = zip(moves, ranks).map { move, rank in
|
|
(
|
|
trashed: ItemPath.trashCard(move.id).folder(under: root),
|
|
origin: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root),
|
|
laneFolder: ItemPath.lane(move.laneID).folder(under: root),
|
|
priorOrder: move.order,
|
|
trashRank: rank
|
|
)
|
|
}
|
|
registerStep(
|
|
HistoryPhrase.name(.delete, kind: .card, count: steps.count),
|
|
subject: moves.count == 1 ? moves[0].title : nil,
|
|
undoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) },
|
|
redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) }
|
|
) { _ in
|
|
for step in steps {
|
|
_ = try BoardWriter.moveItem(
|
|
at: step.trashed,
|
|
toParent: step.laneFolder,
|
|
sourceBoardRoot: root,
|
|
destinationBoardRoot: root,
|
|
order: step.priorOrder
|
|
)
|
|
}
|
|
} redo: { _ in
|
|
for step in steps {
|
|
try BoardWriter.deleteCardToTrash(at: step.origin, inBoard: root, order: step.trashRank)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// **Deleting a lane is physical** — the folder and its contents go (03-board-ui.md § Trash:
|
|
/// "Cards only. Lanes are never trashed … The net is undo, not the trash").
|
|
///
|
|
/// **Capture before you remove.** The undo replays the lane's bytes, which only works if the step
|
|
/// is holding them: `captureSubtree` reads the whole tree — nested cards, their `attachments/`,
|
|
/// every stray, symlinks as links, POSIX modes — inside the same bracket as the removal, so
|
|
/// nothing can change between the two. The capture is the reason this is a *destructive* delete
|
|
/// with a real inverse rather than an unrecoverable one.
|
|
///
|
|
/// **In-session only, and that is the accepted net** (13-native-undo.md ▸ Rules ▸ session-only
|
|
/// persistence): the bytes live on the stack, so closing the board loses them. Git boards keep
|
|
/// the lane reachable forever (06-history-undo.md's delete-never-forgets) — a Pro difference,
|
|
/// stated honestly.
|
|
///
|
|
/// A capture that fails takes the whole bracket down and nothing is removed: better a delete that
|
|
/// visibly did not happen than one whose undo could not.
|
|
@discardableResult
|
|
private func removeLanes(_ ids: [ItemID]) -> Bool {
|
|
let lanes = ids.compactMap { id in snapshot.lanes.first { $0.id == id } }
|
|
guard !lanes.isEmpty else { return false }
|
|
|
|
let root = rootURL
|
|
let folders = lanes.map { ItemPath.lane($0.id).folder(under: root) }
|
|
var captures: [SubtreeSnapshot] = []
|
|
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
|
for folder in folders {
|
|
captures.append(try BoardWriter.captureSubtree(at: folder, operation: .delete(title: nil)))
|
|
try BoardWriter.removeLane(at: folder)
|
|
}
|
|
}
|
|
guard landed != nil, captures.count == folders.count else { return false }
|
|
|
|
// lane delete → **recreate the folder from the registered inverse** (13 ▸ Rules). Values, not
|
|
// references: the capture is a `SubtreeSnapshot` of bytes taken before the removal, so the
|
|
// step means the same thing any number of reloads later.
|
|
//
|
|
// Its predicate is existence and nothing else — the undo wants the paths still empty (a
|
|
// recreate refuses to clobber, so a lane somebody re-made at that id is not this step's to
|
|
// overwrite), the redo wants them back.
|
|
let steps = Array(zip(folders, captures))
|
|
registerStep(
|
|
HistoryPhrase.name(.delete, kind: .lane, count: steps.count),
|
|
subject: lanes.count == 1 ? lanes[0].title.value : nil,
|
|
undoExpects: steps.map { .absent($0.0) },
|
|
redoExpects: steps.map { .present($0.0) }
|
|
) { _ in
|
|
for (folder, capture) in steps {
|
|
try BoardWriter.recreateSubtree(at: folder, from: capture, operation: .createLane)
|
|
}
|
|
} redo: { _ in
|
|
// Reversed, so a multi-lane delete unwinds in the mirror of the order it was made in.
|
|
for (folder, _) in steps.reversed() {
|
|
try BoardWriter.removeLane(at: folder)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// **The trash's own Delete — permanent** (03-board-ui.md § Trash: "on a trash card, Delete
|
|
/// (⌫/⌘⌫) is permanent; in the trash it removes the folder").
|
|
///
|
|
/// Its own method rather than a flag on `delete(_:)` because it is a different act with a
|
|
/// different safety story: it registers **no undo step**, and `purgeIsUnrecoverable` stays `true`
|
|
/// — 13-native-undo.md ▸ Rules settles this by name ("Permanently delete (Delete Immediately,
|
|
/// Empty Trash) … the confirm *is* the safety"). A stack entry here would be a promise the
|
|
/// filesystem cannot keep.
|
|
///
|
|
/// **The confirmation is the window's** (`TrashConfirmations`), for `deleteSelection`'s reason —
|
|
/// and it is why this seam is explicit: the alert has to be able to name what this will purge
|
|
/// before it runs.
|
|
///
|
|
/// The selection moves to the successor sibling **within the trash**: the permanent delete is as
|
|
/// deliberate a gesture as the move-to-trash, so repeated ⌫ walks down the column exactly as it
|
|
/// walks down a lane (04-interactions.md ▸ The map).
|
|
public func deleteTrashCards(_ ids: Set<ItemID>) {
|
|
let paths = ItemPath.resolve(ids, in: .trash, snapshot: snapshot)
|
|
guard !paths.isEmpty else { return }
|
|
let successor = SelectionGrammar.successor(
|
|
afterDeleting: ids,
|
|
in: .trash,
|
|
snapshot: snapshot,
|
|
filter: searchFilter
|
|
)
|
|
let root = rootURL
|
|
|
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
|
for path in paths {
|
|
try BoardWriter.purgeTrashCard(at: path.folder(under: root), inBoard: root)
|
|
}
|
|
}
|
|
|
|
if let successor {
|
|
select([successor], in: .trash, anchor: successor, head: successor)
|
|
} else {
|
|
clearSelection()
|
|
}
|
|
}
|
|
|
|
/// **Delete Immediately ⌥⌘⌫ — skips the trash from anywhere** (03-board-ui.md § Trash;
|
|
/// 11-command-nexus.md: "Board window, card selection — skips the trash from anywhere").
|
|
///
|
|
/// The one method whose targets can be in either container, and the reason is the command's own
|
|
/// wording: from a lane it bypasses the trash the ordinary delete would have used, and from the
|
|
/// trash it is the permanent delete the card is already one keystroke from. Cards only — a lane's
|
|
/// delete is physical already, so there is nothing for "skip the trash" to mean on one.
|
|
///
|
|
/// **It registers no undo step**, `deleteTrashCards`' ruling and its wording.
|
|
///
|
|
/// The selection is cleared rather than walked to a successor: unlike ⌫, this is the command a
|
|
/// confirmation stands in front of, and what follows it is reading the board rather than pressing
|
|
/// the key again.
|
|
public func deleteImmediately(_ ids: Set<ItemID>) {
|
|
let container = selection.container
|
|
let paths = ItemPath.resolve(ids, in: container, snapshot: snapshot).filter { !$0.isLane }
|
|
guard !paths.isEmpty else { return }
|
|
let root = rootURL
|
|
|
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
|
for path in paths {
|
|
switch path {
|
|
case .trashCard:
|
|
try BoardWriter.purgeTrashCard(at: path.folder(under: root), inBoard: root)
|
|
default:
|
|
try BoardWriter.purgeItem(at: path.folder(under: root))
|
|
}
|
|
}
|
|
}
|
|
clearSelection()
|
|
}
|
|
|
|
/// **Empty Trash… ⇧⌘⌫** — purges every card in `<root>/.trash/`, in one bracket.
|
|
///
|
|
/// Not undoable, `deleteTrashCards`' ruling — this is the other half of 13's "Permanently delete".
|
|
///
|
|
/// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the writer walks
|
|
/// the folder itself, never a 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, and strays a hand-editor left in the container are
|
|
/// preserved verbatim rather than swept up with the cards (`BoardWriter.emptyTrash`).
|
|
public func emptyTrash() {
|
|
guard !snapshot.trash.isEmpty else { return }
|
|
let root = rootURL
|
|
|
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
|
try BoardWriter.emptyTrash(inBoard: root)
|
|
}
|
|
if selection.container == .trash {
|
|
clearSelection()
|
|
}
|
|
}
|
|
|
|
// MARK: - The legacy tombstone migration
|
|
|
|
/// Migrates every legacy `deleted:` key the last applied snapshot found, and posts one notice —
|
|
/// the **act** half of 01-storage-format.md § Deletion's migration rule ("Legacy `deleted:` keys
|
|
/// migrate on load-and-write, never destroy"; the loader's `legacyTombstones` is the notice half).
|
|
///
|
|
/// **`relocateLooseCardFiles()`'s twin in every mechanical respect**, deliberately: same tail hook
|
|
/// on a successful reload, same lock deferral, same attempted-set loop guard, same one bracket
|
|
/// for the whole board, same warning-tone loss row. Two migrations arriving in one release with
|
|
/// two different schedulings would be two things to keep honest.
|
|
///
|
|
/// ### The two acts
|
|
///
|
|
/// - **A card relocates into `.trash/`** with the key removed (`BoardWriter.migrateTombstonedCard`).
|
|
/// - **A lane returns live** with the key stripped and nothing moved
|
|
/// (`BoardWriter.migrateTombstonedLane`) — "resurrection is the safe direction, nothing is
|
|
/// destroyed by migration". A tombstoned lane's own cards come back with it; any of them
|
|
/// carrying their own key migrate on their own account, as ordinary tombstoned cards, in the
|
|
/// same pass.
|
|
///
|
|
/// ### The order among migrating cards is `deleted:`-ascending, deliberately
|
|
///
|
|
/// Every arrival mints a rank above the current top, so the *last* card migrated ends up topmost.
|
|
/// Migrating oldest-first therefore reproduces the newest-first column the tombstone model's
|
|
/// timestamp sort used to render — the same board, read the same way, with ordinary ranks doing
|
|
/// the work. A card whose stamp is missing or unparseable sorts as **oldest** (the retired sort's
|
|
/// own rule: "a corrupt stamp must not outrank fresh deletions"), and ties fall to the loader's
|
|
/// walk order — lane `order`, then card `order` — which is the deterministic tie-break the whole
|
|
/// corpus already uses. `legacyTombstones` carries no timestamp of its own, so the stamps are
|
|
/// read out of the snapshot the same walk produced.
|
|
///
|
|
/// ### The read-only lock defers it, it does not cancel it
|
|
///
|
|
/// Exactly the relocation's posture, and stated there: a locked board returns having written
|
|
/// nothing **and having remembered nothing**, so the reload that lifts the lock is the reload
|
|
/// that performs the migration.
|
|
///
|
|
/// ### It cannot hot-loop
|
|
///
|
|
/// The migration's own write triggers a reload, which re-walks the tree — the loop the guard
|
|
/// exists for. After a success the walk finds no keys, `legacyTombstones` empties, and the memo
|
|
/// clears. After a *failure* it finds the same keys again, and an unguarded call would fail
|
|
/// forever at the speed of a directory walk; so an attempt is made only when the tombstone set
|
|
/// **differs from the last one attempted**.
|
|
public func migrateLegacyTombstones() {
|
|
let work = legacyTombstones
|
|
guard !work.isEmpty else {
|
|
attemptedTombstoneMigration = []
|
|
return
|
|
}
|
|
guard readOnlyLock == nil else {
|
|
Self.logger.debug("legacy tombstone migration deferred — the board is read-only")
|
|
return
|
|
}
|
|
let signature = Self.migrationSignature(of: work)
|
|
guard signature != attemptedTombstoneMigration else { return }
|
|
attemptedTombstoneMigration = signature
|
|
|
|
let root = rootURL
|
|
let cards = Self.migrationOrder(of: work, in: snapshot)
|
|
let lanes = work.filter { $0.kind == .lane }
|
|
|
|
var movedCards: [String?] = []
|
|
var returnedLanes: [String?] = []
|
|
// The ranks are minted exactly as a delete's are — head of the trash, threaded forward — so a
|
|
// migrated card is indistinguishable on disk from one the user deletes today.
|
|
var ladder = snapshot.trash.map(\.order)
|
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
|
for card in cards {
|
|
guard let cardID = card.cardID else { continue }
|
|
let rank = Ranks.insertAtHead(ofVisible: ladder)
|
|
try BoardWriter.migrateTombstonedCard(
|
|
at: ItemPath.card(lane: card.laneID, id: cardID).folder(under: root),
|
|
inBoard: root,
|
|
order: rank
|
|
)
|
|
ladder.insert(rank, at: 0)
|
|
movedCards.append(card.title)
|
|
}
|
|
for lane in lanes {
|
|
try BoardWriter.migrateTombstonedLane(at: ItemPath.lane(lane.laneID).folder(under: root))
|
|
returnedLanes.append(lane.title)
|
|
}
|
|
}
|
|
banners.postMigratedTombstones(cards: movedCards, lanes: returnedLanes)
|
|
}
|
|
|
|
/// The tombstone picture as a comparable value — `relocationSignature`'s shape, for its reason:
|
|
/// the *identity* of the work is what matters, not the order the walk happened to meet it in.
|
|
nonisolated static func migrationSignature(of work: [LegacyTombstone]) -> Set<String> {
|
|
Set(work.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "")" })
|
|
}
|
|
|
|
/// The `.card` tombstones in the order they should be filed into the trash — oldest `deleted:`
|
|
/// first, so the newest ends up on top (see `migrateLegacyTombstones`).
|
|
///
|
|
/// `sorted(by:)` is not stable in the standard library, so the walk position is folded into the
|
|
/// key rather than relied on: an unparseable or missing stamp takes `Date.distantPast` and ties
|
|
/// break on the index the loader met the card at.
|
|
nonisolated static func migrationOrder(
|
|
of work: [LegacyTombstone],
|
|
in snapshot: BoardModel
|
|
) -> [LegacyTombstone] {
|
|
var stamps: [ItemID: Date] = [:]
|
|
for lane in snapshot.lanes {
|
|
for card in lane.cards {
|
|
if let deleted = card.deleted.value { stamps[card.id] = deleted }
|
|
}
|
|
}
|
|
return work
|
|
.enumerated()
|
|
.filter { $0.element.kind == .card }
|
|
.sorted { lhs, rhs in
|
|
let left = lhs.element.cardID.flatMap { stamps[$0] } ?? .distantPast
|
|
let right = rhs.element.cardID.flatMap { stamps[$0] } ?? .distantPast
|
|
return left == right ? lhs.offset < rhs.offset : left < right
|
|
}
|
|
.map(\.element)
|
|
}
|
|
|
|
// MARK: - The agent guide
|
|
|
|
/// Brings the board root's `CLAUDE.md` up to the current guide version, or leaves it exactly as
|
|
/// it is — the whole of 08-agent-integration.md ▸ The agent guide's scheduling. The rule itself
|
|
/// is `AgentGuide.decide(_:)`, a pure function; this method is the I/O and the policy around it.
|
|
///
|
|
/// **Run on every successful reload**, beside the relocation and the tombstone migration, and
|
|
/// once more at open (`BoardStoreRegistry.acquire`, which fires it after the watcher and the
|
|
/// brackets exist, for the reason stated there). That makes the guide *self-healing* rather than
|
|
/// merely written-once: a foreign deletion, a downgrade to an older guide, a board restored from
|
|
/// a template carrying a stale one — each heals on the next reload, without a single new signal.
|
|
/// It also pre-wires the Pro-era bounce 06-history-undo.md acknowledges by name, where undoing
|
|
/// an "Update agent guide (vN)" commit restores an older guide that the app immediately
|
|
/// re-upgrades.
|
|
///
|
|
/// **The steady state is a read and a comparison** — one `lstat`, one small file read, one
|
|
/// first-line parse — and no write at all. Nothing here touches the snapshot: the bytes land, the
|
|
/// watcher notices, the reload applies, exactly like every other app write.
|
|
///
|
|
/// ### The read-only board is skipped, never banner-ed
|
|
///
|
|
/// Two gates, because two different things can be true. `performWrite` would refuse under the
|
|
/// read-only lock on its own, but that refusal is a thrown error and this is not a gesture — so
|
|
/// the lock is checked first, the relocation's own deferral idiom. That gate is now the one that
|
|
/// actually fires on an unwritable board: `BoardStoreRegistry.acquire` probes writability
|
|
/// *before* it calls this method, so 02-architecture.md's "the open-time agent-guide write is
|
|
/// skipped-with-log, the `CLAUDE.user.md`-taken precedent" is honored by the lock being up
|
|
/// rather than by this method noticing on its own.
|
|
///
|
|
/// The `access(2)` check below stays anyway, as the second line of defense: it is the only gate
|
|
/// covering the window *between* probes — a volume remounted read-only mid-session raises the
|
|
/// lock at the next reconciling reload, and a reload landing in that window would otherwise
|
|
/// reach the Writer, fail, and post a banner about a file the user never asked for. Both skips
|
|
/// are logged and neither is ever surfaced.
|
|
///
|
|
/// ### It cannot hot-loop
|
|
///
|
|
/// `performWrite`'s bracket schedules a reload whether or not the write succeeded, and this runs
|
|
/// on every reload — so a *failing* guide write would retry forever at the speed of the debounce,
|
|
/// posting a banner row each time. The guard is the relocation's exactly: an attempt is made only
|
|
/// when the board root's picture **differs from the one last acted on**. One failure, one row,
|
|
/// then silence until something on disk actually changes. The same memo is what keeps the two
|
|
/// skip cases from repeating their log line on every reload of an unchanged board.
|
|
public func refreshAgentGuide() {
|
|
let state = AgentGuide.inspect(atBoardRoot: rootURL)
|
|
let decision = AgentGuide.decide(state)
|
|
guard decision != .leaveAlone else {
|
|
// The resting state, and the memo's reset: a board whose guide is current has nothing to
|
|
// remember having tried.
|
|
attemptedGuideRefresh = nil
|
|
return
|
|
}
|
|
// Deferred, not abandoned — and deliberately *before* the memo is written, so the refresh a
|
|
// lock refused is not the one the guard below remembers.
|
|
guard readOnlyLock == nil else {
|
|
Self.logger.debug("agent-guide refresh deferred — the board is read-only")
|
|
return
|
|
}
|
|
guard FileManager.default.isWritableFile(atPath: rootURL.path) else {
|
|
Self.logger.debug("agent-guide refresh skipped — the board's location is not writable")
|
|
return
|
|
}
|
|
guard state != attemptedGuideRefresh else { return }
|
|
// Armed before anything is attempted, so a write that throws leaves it set — the memo's
|
|
// whole job is to remember pictures this store has already failed or refused to act on.
|
|
attemptedGuideRefresh = state
|
|
|
|
switch decision {
|
|
case .leaveAlone:
|
|
break // Ruled out above; the switch stays exhaustive so a new decision is a compile error.
|
|
case .skipUntouchable:
|
|
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not an ordinary file")
|
|
case .skipUserFilenameTaken:
|
|
// The ruling's own outcome (08 ▸ Ownership): a user-authored CLAUDE.md that cannot be
|
|
// rescued keeps its name, and the guide simply does not exist on this board.
|
|
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not the app's and CLAUDE.user.md is taken")
|
|
case .write, .displaceThenWrite:
|
|
let root = rootURL
|
|
let displace = decision == .displaceThenWrite
|
|
do {
|
|
// One bracket over the rescue move *and* the write: two files change, one
|
|
// app-mediated reload lands, and (in the Pro edition) one honestly-attributed commit
|
|
// records it.
|
|
try performWrite { () throws(BoardWriteError) -> Void in
|
|
try AgentGuide.install(atBoardRoot: root, displacingUserContent: displace)
|
|
}
|
|
// **Cleared on success, and this is load-bearing rather than tidy**: the memo keys on
|
|
// the picture that provoked the write, and a foreign deletion restores that exact
|
|
// picture ("missing"). A memo left standing would make the deletion the one thing the
|
|
// self-heal could not heal.
|
|
attemptedGuideRefresh = nil
|
|
} catch {
|
|
// Already the banner's — `performWrite` posts every `BoardWriteError` before it
|
|
// rethrows — and there is nothing else a courtesy write can do about a failure. The
|
|
// memo, left armed above, is what keeps it from being posted again every reload.
|
|
Self.logger.error("agent-guide write failed: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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>, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil) {
|
|
transient.select(ids, in: container, 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,
|
|
in: outcome.selection.container,
|
|
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 in the trash, and it still names a card — the exact conditions under which
|
|
/// "all" could mean anything but the board (04 ▸ The map, resettled 2026-07-28: "with the trash
|
|
/// visible and a non-empty trash selection, Select All selects all visible trash cards; in every
|
|
/// other state, all visible live cards — the container boundary decides which 'all' is meant").
|
|
/// A trash selection naming nothing (a foreign restore, a purge) falls through to the board
|
|
/// rather than selecting the trash wholesale on a guess. There is no kind clause any more:
|
|
/// lanes are never trashed, so every trash row is a card.
|
|
///
|
|
/// 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.container == .trash, !selection.isEmpty,
|
|
SelectionGrammar.kind(of: selection, in: snapshot) != nil {
|
|
apply(Set(SelectionGrammar.trashCards(in: snapshot, filter: filter)), in: .trash)
|
|
return
|
|
}
|
|
apply(Set(SelectionGrammar.boardCards(in: snapshot, filter: filter)), in: .board)
|
|
}
|
|
|
|
/// 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>, in container: ItemContainer) {
|
|
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, in: container, 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 lane selects itself;
|
|
/// cards select their lane, but only when they all share one (a cross-lane selection has no
|
|
/// single home to remember). A trash selection names no lane at all, which is exactly 04's "a
|
|
/// trash selection never anchors creation".
|
|
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 {
|
|
let names = ids.contains(lane.id) || lane.cards.contains { 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()
|
|
}
|
|
}
|
|
}
|