Surface write failures — banners, locks, one modal
The banner surface as one vocabulary (Kanban/LiveStore/BannerCenter, Kanban/UI/BannerStripView): a pure precedence rule — in-progress pinned above the collapse (ratified mid-build), lock > breakage > one-shot write failures > commit+attachment, signposts last — with all user-facing phrasing owned here via exhaustive switches over the closed WriteOperation enum; free-form English survives only in diagnostics. performWrite posts its failures before rethrowing, so no one-shot can bypass the strip; refusals under lock post nothing. The lock vocabulary completes: vanishedRoot and unwritableLocation join bracketedReloadFailed, each with its own clearing rule (unwritable clears only on a reconciling reload's writability re-probe). The registry now owns root recovery: bookmark re-resolution absorbs renames transparently, a dead root locks read-only and re-arms FSEvents on the gone path so the root's return round-trips back through rootChanged, re-minting and re-keying on the way. DirtyBufferGuard is the one modal moment, retry / save a copy / discard, no fourth button. 36 new tests; full suite 333 tests in 62 suites green. Five findings filed on the Redesign board. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -0,0 +1,530 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
// MARK: - Tone
|
||||||
|
|
||||||
|
/// The three kinds a banner row can be (02-architecture.md § The banner surface, "Tones, not
|
||||||
|
/// components"): one layout, one accessibility path, three colorings. The card window's
|
||||||
|
/// remote-change signpost (07-sync-collab.md) is this same component in `.info` — visually calm,
|
||||||
|
/// no error color — which is precisely why the distinction is a *tone* on a shared row rather
|
||||||
|
/// than three view types that would drift apart.
|
||||||
|
public enum BannerTone: Sendable, Equatable {
|
||||||
|
case error
|
||||||
|
case warning
|
||||||
|
case info
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Row payloads
|
||||||
|
|
||||||
|
/// A one-shot write failure: it happened once, it is over, and it waits to be read
|
||||||
|
/// (02-architecture.md § The banner surface, "One-shots dismiss, conditions heal").
|
||||||
|
///
|
||||||
|
/// **No timeout, ever** — "an error never evaporates unread". The row leaves the strip only when
|
||||||
|
/// the user dismisses it, which is why it carries an id of its own rather than being identified by
|
||||||
|
/// its content: two identical failures a minute apart are two rows, and dismissing one must not
|
||||||
|
/// take the other with it.
|
||||||
|
public struct OneShotBanner: Identifiable, Sendable, Equatable {
|
||||||
|
public let id: UUID
|
||||||
|
public let error: BoardWriteError
|
||||||
|
/// When the failure happened — the sort key for "newest first within a class".
|
||||||
|
public let occurredAt: Date
|
||||||
|
|
||||||
|
public init(id: UUID = UUID(), error: BoardWriteError, occurredAt: Date = Date()) {
|
||||||
|
self.id = id
|
||||||
|
self.error = error
|
||||||
|
self.occurredAt = occurredAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The standing condition "changes aren't being recorded to history" (02-architecture.md §
|
||||||
|
/// Write-failure surfacing, "Auto-commit failures beyond `index.lock` contention").
|
||||||
|
///
|
||||||
|
/// `reason` is **diagnostic English, not the banner's verb** — the free-form tail the phrasing
|
||||||
|
/// rules allow ("disk full", "the repository is corrupt"). The user-facing sentence is
|
||||||
|
/// `BannerCenter.headline(for:)`'s, like every other row's.
|
||||||
|
///
|
||||||
|
/// `since` exists because this is a condition, not an event: it started at some point and has been
|
||||||
|
/// true ever since. Nothing renders it today; it is what a later "suspended for 4 minutes" reading
|
||||||
|
/// would be built from, and recording it costs a `Date`.
|
||||||
|
public struct HistorySuspension: Sendable, Equatable {
|
||||||
|
public let reason: String
|
||||||
|
public let since: Date
|
||||||
|
|
||||||
|
public init(reason: String, since: Date = Date()) {
|
||||||
|
self.reason = reason
|
||||||
|
self.since = since
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A calm notice that something happened elsewhere — the info tone's *passive* half.
|
||||||
|
///
|
||||||
|
/// Its one producer-to-be is the card window's **remote-change signpost** (07-sync-collab.md): a
|
||||||
|
/// pull landed commits touching the very card being edited, the dirty buffer stays put and wins,
|
||||||
|
/// and the window says so without a modal or a merge UI. 02-architecture.md calls that "this same
|
||||||
|
/// component in the info tone", which is exactly what this is.
|
||||||
|
///
|
||||||
|
/// **Ranks last and may collapse** (settled, § The banner surface): "calm by design, nothing gated
|
||||||
|
/// on seeing them instantly" — the opposite end of the strip from an in-progress row, whose spinner
|
||||||
|
/// may never hide.
|
||||||
|
///
|
||||||
|
/// Dismissable, like a one-shot and unlike a condition: it reports something that already happened,
|
||||||
|
/// so there is nothing for it to heal into.
|
||||||
|
public struct InfoSignpost: Identifiable, Sendable, Equatable {
|
||||||
|
public let id: UUID
|
||||||
|
public let message: String
|
||||||
|
public let occurredAt: Date
|
||||||
|
|
||||||
|
public init(id: UUID = UUID(), message: String, occurredAt: Date = Date()) {
|
||||||
|
self.id = id
|
||||||
|
self.message = message
|
||||||
|
self.occurredAt = occurredAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Work in flight, shown as an info row with a spinner (02-architecture.md § The banner surface,
|
||||||
|
/// "In-progress operations are info rows"): bracketed git operations ("Pulling…", "Switching to
|
||||||
|
/// 'main'…") and long non-git work (big-board Duplicate, template instantiation, large attachment
|
||||||
|
/// imports).
|
||||||
|
///
|
||||||
|
/// `label` is the caller's, deliberately: unlike a failure — whose vocabulary is the closed
|
||||||
|
/// `WriteOperation` enum precisely so the banner can own every word — an in-progress row names an
|
||||||
|
/// operation the banner layer has no enum for, and inventing one would mean a new git verb could
|
||||||
|
/// not describe itself without touching this file. The tradeoff is stated rather than hidden.
|
||||||
|
///
|
||||||
|
/// **Cancel appears on safe copies only** (settled): copy-shaped work — attachment imports,
|
||||||
|
/// Duplicate, template instantiation — carries `cancel`, meaning "remove the partial copy, nothing
|
||||||
|
/// lost". Git brackets pass `nil`: they are seconds long, and aborting a rebase mid-flight is a
|
||||||
|
/// repair job, not a cancel.
|
||||||
|
public struct InProgressOperation: Identifiable, Sendable {
|
||||||
|
public let id: UUID
|
||||||
|
public let label: String
|
||||||
|
public var isCancelable: Bool { cancel != nil }
|
||||||
|
/// `@MainActor @Sendable` rather than the plainer `@MainActor () -> Void` the sketch carried:
|
||||||
|
/// this value is stored in a `Sendable` struct, and a non-`Sendable` function type would make
|
||||||
|
/// that conformance a lie under strict concurrency. The isolation is unchanged — cancelling
|
||||||
|
/// runs on the main actor, where the operation's own state lives.
|
||||||
|
public let cancel: (@MainActor @Sendable () -> Void)?
|
||||||
|
|
||||||
|
public init(id: UUID = UUID(), label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) {
|
||||||
|
self.id = id
|
||||||
|
self.label = label
|
||||||
|
self.cancel = cancel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rows
|
||||||
|
|
||||||
|
/// One row in a window's banner strip.
|
||||||
|
///
|
||||||
|
/// The six cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
|
||||||
|
/// divide into three lifecycles that the view renders differently and that the ordering rule
|
||||||
|
/// treats as classes:
|
||||||
|
///
|
||||||
|
/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `historySuspended`. They describe
|
||||||
|
/// ongoing state and carry no dismiss control — "an error never evaporates unread" has a twin,
|
||||||
|
/// "a condition is never dismissed while it is still true". Each leaves when the thing it
|
||||||
|
/// describes stops being true.
|
||||||
|
/// - **One-shots dismiss**: `oneShot` and `signpost`. Each reports something that already happened,
|
||||||
|
/// so only the user can clear it.
|
||||||
|
/// - **In-progress rows complete or fail**: `inProgress`. Completion clears the row; failure swaps
|
||||||
|
/// it for a one-shot (`BannerCenter.endOperation(_:)` + `post(_:)`).
|
||||||
|
///
|
||||||
|
/// **`Identifiable` by a synthetic string id**, not by content: the condition rows are singletons
|
||||||
|
/// per window (there is one lock, one breakage, one history suspension), so their ids are constant
|
||||||
|
/// and a changing *reason* updates the row rather than replacing it — no view churn, no lost
|
||||||
|
/// animation, and no diffing surprise when a lock's cause changes underneath a standing row.
|
||||||
|
public enum BannerRow: Identifiable, Sendable {
|
||||||
|
/// The board refuses writes. Condition, error tone. Producers: the failed bracketed reload
|
||||||
|
/// (built), the vanished root (this milestone), the open-time writability probe (m4).
|
||||||
|
case readOnlyLock(ReadOnlyLockReason)
|
||||||
|
/// A reload failed and the last good snapshot is still on screen. Condition, error tone.
|
||||||
|
case reloadBreakage(BoardLoadError)
|
||||||
|
/// A write that did not happen. Dismissable, error tone.
|
||||||
|
case oneShot(OneShotBanner)
|
||||||
|
/// History has stopped advancing. Condition, warning tone — the files are safe, only the undo
|
||||||
|
/// trail is degraded, which is a warning rather than an error. (m7's committer drives it.)
|
||||||
|
case historySuspended(HistorySuspension)
|
||||||
|
/// Work in flight. Info tone, spinner, **pinned above everything** and exempt from the collapse.
|
||||||
|
/// (m5's copy-shaped work and m7's git brackets drive it.)
|
||||||
|
case inProgress(InProgressOperation)
|
||||||
|
/// A calm notice — the passive half of the info tone, ranking last of all. (m6's card window
|
||||||
|
/// drives it, as 07-sync-collab.md's remote-change signpost.)
|
||||||
|
case signpost(InfoSignpost)
|
||||||
|
|
||||||
|
public var id: String {
|
||||||
|
switch self {
|
||||||
|
case .readOnlyLock: "read-only-lock"
|
||||||
|
case .reloadBreakage: "reload-breakage"
|
||||||
|
case let .oneShot(banner): "one-shot:\(banner.id.uuidString)"
|
||||||
|
case .historySuspended: "history-suspension"
|
||||||
|
case let .inProgress(operation): "operation:\(operation.id.uuidString)"
|
||||||
|
case let .signpost(signpost): "signpost:\(signpost.id.uuidString)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var tone: BannerTone {
|
||||||
|
switch self {
|
||||||
|
case .readOnlyLock, .reloadBreakage, .oneShot: .error
|
||||||
|
case .historySuspended: .warning
|
||||||
|
case .inProgress, .signpost: .info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this row is pinned above the strip's collapse — true for in-progress rows and
|
||||||
|
/// nothing else (settled, 02-architecture.md § The banner surface: "a spinner may never hide
|
||||||
|
/// behind '+N more'"). The view reads this rather than re-deriving the case.
|
||||||
|
public var isPinned: Bool {
|
||||||
|
if case .inProgress = self { true } else { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user-facing line. Every word of it comes from `BannerCenter`'s phrasing statics except
|
||||||
|
/// an in-progress row's, whose label is its caller's (see `InProgressOperation`).
|
||||||
|
public var headline: String {
|
||||||
|
switch self {
|
||||||
|
case let .readOnlyLock(reason): BannerCenter.headline(for: reason)
|
||||||
|
case let .reloadBreakage(error): BannerCenter.headline(for: error)
|
||||||
|
case let .oneShot(banner): BannerCenter.headline(for: banner.error)
|
||||||
|
case let .historySuspended(suspension): BannerCenter.headline(for: suspension)
|
||||||
|
case let .inProgress(operation): operation.label
|
||||||
|
case let .signpost(signpost): signpost.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the rows reporting something that already happened carry a dismiss control — one-shot
|
||||||
|
/// failures and signposts. Conditions stand until they heal; in-progress rows complete, fail, or
|
||||||
|
/// are cancelled — neither is something a user can wave away.
|
||||||
|
public var dismissID: UUID? {
|
||||||
|
switch self {
|
||||||
|
case let .oneShot(banner): banner.id
|
||||||
|
case let .signpost(signpost): signpost.id
|
||||||
|
case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BannerCenter
|
||||||
|
|
||||||
|
/// The model behind one window's banner strip: the rows a window owns outright, plus the ordering
|
||||||
|
/// and phrasing rules the whole app renders through (02-architecture.md § The banner surface).
|
||||||
|
///
|
||||||
|
/// ### What lives here and what does not
|
||||||
|
///
|
||||||
|
/// A center holds the state nothing else does: dismissable one-shot write failures, the history
|
||||||
|
/// suspension, in-progress operations, and passive signposts. It deliberately does **not** hold the
|
||||||
|
/// read-only lock or the reload breakage — those are `BoardStore`'s truths, and copying them here
|
||||||
|
/// would create a second place for them to be stale. `BoardStore.bannerRows` composes both halves
|
||||||
|
/// through `rows(lock:breakage:oneShots:suspension:operations:signposts:)`, which is a *pure
|
||||||
|
/// function* precisely so the precedence rule can be tested without a store, a window, or a
|
||||||
|
/// filesystem.
|
||||||
|
///
|
||||||
|
/// ### Phrasing lives here too
|
||||||
|
///
|
||||||
|
/// "The banner owns all user-facing phrasing and localization from that vocabulary" (02 §
|
||||||
|
/// Write-failure surfacing). `BoardWriteError` carries a closed `WriteOperation` enum and a
|
||||||
|
/// diagnostic `reason`; `headline(for:)` switches over that enum **exhaustively, with no
|
||||||
|
/// `default`**, so a Writer operation added without a sentence to say about it is a compile-time
|
||||||
|
/// hole rather than a silent fallback. The same rule covers locks, breakage, and the history
|
||||||
|
/// suspension: their user-facing lines are here, not on the error types.
|
||||||
|
///
|
||||||
|
/// ### One center per window, not per board
|
||||||
|
///
|
||||||
|
/// The strip is "hosted by the window of origin": a card window's own save and attachment failures
|
||||||
|
/// belong to that window's center, and re-home to the board window's when it closes (m6's job).
|
||||||
|
/// `BoardStore` owns *the board window's* center, which is why it is a stored `let` there rather
|
||||||
|
/// than something injected — a board window's strip has exactly one lifetime, the store's.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class BannerCenter {
|
||||||
|
|
||||||
|
// MARK: State
|
||||||
|
|
||||||
|
/// Newest first, which is the order the class renders in. Kept sorted on insertion rather than
|
||||||
|
/// at render time so that two failures sharing a timestamp — plausible inside one run loop
|
||||||
|
/// turn — still order deterministically.
|
||||||
|
public private(set) var oneShots: [OneShotBanner] = []
|
||||||
|
|
||||||
|
/// The standing "history isn't advancing" condition, or `nil` when commits are landing.
|
||||||
|
public private(set) var historySuspension: HistorySuspension?
|
||||||
|
|
||||||
|
/// Work in flight, newest first for the same reason `oneShots` is.
|
||||||
|
public private(set) var operations: [InProgressOperation] = []
|
||||||
|
|
||||||
|
/// Passive notices, newest first. Empty until m6's card window starts posting the
|
||||||
|
/// remote-change signpost.
|
||||||
|
public private(set) var signposts: [InfoSignpost] = []
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
// MARK: One-shots
|
||||||
|
|
||||||
|
/// Records a write failure. **Every failed write lands here before it reaches its caller** —
|
||||||
|
/// `BoardStore.performWrite` posts before it rethrows — because the banner is how the one-way
|
||||||
|
/// flow stays honest: the action visibly did not happen, and the strip is the only place that
|
||||||
|
/// says why (02 § Write-failure surfacing).
|
||||||
|
public func post(_ error: BoardWriteError) {
|
||||||
|
oneShots.insert(OneShotBanner(error: error), at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posts a passive notice — m6's remote-change signpost and whatever joins it. Newest first,
|
||||||
|
/// like the one-shots it shares a lifecycle with.
|
||||||
|
public func postSignpost(_ message: String) {
|
||||||
|
signposts.insert(InfoSignpost(message: message), at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a dismissable row: a one-shot failure or a signpost. **An id that names an
|
||||||
|
/// in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel" are
|
||||||
|
/// different promises and a row that offers one must never quietly do the other.
|
||||||
|
public func dismiss(_ id: UUID) {
|
||||||
|
oneShots.removeAll { $0.id == id }
|
||||||
|
signposts.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every dismissable row — one-shots and signposts alike. The strip's own "clear all"
|
||||||
|
/// affordance later; today it is what a window uses when it re-homes its rows elsewhere (m6).
|
||||||
|
public func dismissAllDismissableRows() {
|
||||||
|
oneShots.removeAll()
|
||||||
|
signposts.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: History suspension
|
||||||
|
|
||||||
|
/// Raises (or refreshes) the "changes aren't being recorded to history" condition — m7's
|
||||||
|
/// committer calls this when a commit fails past `index.lock` contention (06-history-undo.md
|
||||||
|
/// covers the lock itself, which is deliberately *not* a banner).
|
||||||
|
///
|
||||||
|
/// A second call while already suspended keeps the original `since` and takes the newer
|
||||||
|
/// `reason`: the condition never stopped being true, so restarting its clock would misreport
|
||||||
|
/// how long history has been stalled, while the newest diagnosis is the useful one.
|
||||||
|
public func suspendHistory(reason: String) {
|
||||||
|
historySuspension = HistorySuspension(reason: reason, since: historySuspension?.since ?? Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears it, on the first successful commit. Idempotent — clearing a condition that is not
|
||||||
|
/// standing is not an error, it is the ordinary shape of "commit succeeded".
|
||||||
|
public func clearHistorySuspension() {
|
||||||
|
historySuspension = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: In-progress operations
|
||||||
|
|
||||||
|
/// Starts an info row with a spinner and hands back its id.
|
||||||
|
///
|
||||||
|
/// - Parameter cancel: non-`nil` only for copy-shaped work, where cancelling means "remove the
|
||||||
|
/// partial copy, nothing lost" (02, settled). Git brackets pass `nil`.
|
||||||
|
@discardableResult
|
||||||
|
public func beginOperation(label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) -> UUID {
|
||||||
|
let operation = InProgressOperation(label: label, cancel: cancel)
|
||||||
|
operations.insert(operation, at: 0)
|
||||||
|
return operation.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends one — the row leaves the strip.
|
||||||
|
///
|
||||||
|
/// **Completion and failure both come through here.** There is no `failOperation`: a failure is
|
||||||
|
/// `endOperation(id)` followed by `post(error)`, which is exactly 02's "failure swaps it for
|
||||||
|
/// the error row" expressed as two calls that already exist. Ending an unknown id is a no-op;
|
||||||
|
/// a close racing a completion is ordinary, not a bug.
|
||||||
|
public func endOperation(_ id: UUID) {
|
||||||
|
operations.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ordering
|
||||||
|
|
||||||
|
/// The strip's rows, precedence-ordered — **the whole of the ordering rule, in one pure
|
||||||
|
/// function** (02 § The banner surface, "Concurrent conditions stack").
|
||||||
|
///
|
||||||
|
/// The precedence, settled: **in-progress rows (pinned)** > read-only lock > reload breakage >
|
||||||
|
/// one-shot write failures > commit and attachment failures > **passive info rows**. Three
|
||||||
|
/// readings of it are worth stating because the code depends on them:
|
||||||
|
///
|
||||||
|
/// - **The two info classes sit at opposite ends of the strip.** An in-progress row is pinned
|
||||||
|
/// above everything: it is the only explanation the strip offers for a bracket's write lock
|
||||||
|
/// and for a close or quit deferring teardown, and a copy row's Cancel has to stay reachable
|
||||||
|
/// — "a spinner may never hide behind '+N more'". A passive signpost ranks last and may
|
||||||
|
/// collapse: calm by design, nothing gated on seeing it instantly. Same tone, opposite
|
||||||
|
/// urgency.
|
||||||
|
/// - **An attachment failure is a one-shot**, not a separate kind — its `WriteOperation` is
|
||||||
|
/// `.importAttachment`. It ranks in the commit-failure class rather than with its fellow
|
||||||
|
/// one-shots, so a fresh failed import sits below an older failed move. That is the design's
|
||||||
|
/// ordering read literally, and it is defensible: a failed import is the least destructive of
|
||||||
|
/// the failures (the drop was accepted, the partial copy was removed), so it yields its place
|
||||||
|
/// to failures that stopped the user's actual work.
|
||||||
|
/// - **Within the commit-and-attachment class the standing suspension leads**, then the
|
||||||
|
/// attachment one-shots newest first. A deliberate reading of "newest first within a class":
|
||||||
|
/// a condition and a one-shot are not comparable by recency in any way a user would read as
|
||||||
|
/// order — the condition's `since` is when it *started* being true, not when it happened —
|
||||||
|
/// so they are ordered by kind, and recency orders only the one-shots among themselves.
|
||||||
|
///
|
||||||
|
/// `signposts` carries a default because its producer is m6's card window and nothing posts one
|
||||||
|
/// today; every other class has a live producer and is spelled out at every call site.
|
||||||
|
public nonisolated static func rows(
|
||||||
|
lock: ReadOnlyLockReason?,
|
||||||
|
breakage: BoardLoadError?,
|
||||||
|
oneShots: [OneShotBanner],
|
||||||
|
suspension: HistorySuspension?,
|
||||||
|
operations: [InProgressOperation],
|
||||||
|
signposts: [InfoSignpost] = []
|
||||||
|
) -> [BannerRow] {
|
||||||
|
var rows: [BannerRow] = []
|
||||||
|
|
||||||
|
rows.append(contentsOf: operations.map(BannerRow.inProgress))
|
||||||
|
|
||||||
|
if let lock {
|
||||||
|
rows.append(.readOnlyLock(lock))
|
||||||
|
}
|
||||||
|
if let breakage {
|
||||||
|
rows.append(.reloadBreakage(breakage))
|
||||||
|
}
|
||||||
|
|
||||||
|
let ordered = newestFirst(oneShots)
|
||||||
|
rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))
|
||||||
|
|
||||||
|
if let suspension {
|
||||||
|
rows.append(.historySuspended(suspension))
|
||||||
|
}
|
||||||
|
rows.append(contentsOf: ordered.lazy.filter(\.isAttachmentImport).map(BannerRow.oneShot))
|
||||||
|
|
||||||
|
rows.append(contentsOf: signposts.map(BannerRow.signpost))
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest first, and **stable**: `sorted(by:)` is not, and two failures posted in the same run
|
||||||
|
/// loop turn can share a `Date` to the microsecond. Ties fall back to the input order, which
|
||||||
|
/// `post(_:)` already maintains newest-first — so a tie renders in the order it was posted
|
||||||
|
/// rather than in whatever order the sort happened to leave.
|
||||||
|
private nonisolated static func newestFirst(_ banners: [OneShotBanner]) -> [OneShotBanner] {
|
||||||
|
banners
|
||||||
|
.enumerated()
|
||||||
|
.sorted { lhs, rhs in
|
||||||
|
lhs.element.occurredAt == rhs.element.occurredAt
|
||||||
|
? lhs.offset < rhs.offset
|
||||||
|
: lhs.element.occurredAt > rhs.element.occurredAt
|
||||||
|
}
|
||||||
|
.map(\.element)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Phrasing
|
||||||
|
|
||||||
|
/// The user-facing line for a failed write: what the app could not do, then why.
|
||||||
|
///
|
||||||
|
/// "Couldn't move 'Fix login' — disk full" is the design's own example and the shape every
|
||||||
|
/// case takes: an action clause the banner owns, an em dash, and the diagnostic cause from the
|
||||||
|
/// error's `reason` — the one place free-form English is allowed to survive.
|
||||||
|
public nonisolated static func headline(for error: BoardWriteError) -> String {
|
||||||
|
let action = actionPhrase(for: error.operation)
|
||||||
|
let cause = causePhrase(for: error.reason)
|
||||||
|
return cause.isEmpty ? action : "\(action) — \(cause)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Exhaustive by construction — no `default`.** A `WriteOperation` case added without a
|
||||||
|
/// sentence here fails to compile, which is the settled contract ("a new Writer operation
|
||||||
|
/// without a banner rendering is a compile-time hole, not a silent default").
|
||||||
|
///
|
||||||
|
/// Titles are quoted where the operation carries one and the phrasing stays graceful where it
|
||||||
|
/// does not: the enum knows an item's title, never its *kind*, so an untitled failure says
|
||||||
|
/// "the item" rather than guessing "card" and being wrong about a lane. The trash verbs are
|
||||||
|
/// Finder's, matching the commands the user pressed (03-board-ui.md § Trash): Move to Trash,
|
||||||
|
/// Put Back, Delete Immediately.
|
||||||
|
private nonisolated static func actionPhrase(for operation: WriteOperation) -> String {
|
||||||
|
switch operation {
|
||||||
|
case .createBoard:
|
||||||
|
"Couldn't create the board"
|
||||||
|
case .createLane:
|
||||||
|
"Couldn't create a lane"
|
||||||
|
case .createCard:
|
||||||
|
"Couldn't create a card"
|
||||||
|
case let .move(title):
|
||||||
|
if let title { "Couldn't move '\(title)'" } else { "Couldn't move the item" }
|
||||||
|
case let .reorder(title):
|
||||||
|
if let title { "Couldn't reorder '\(title)'" } else { "Couldn't reorder the item" }
|
||||||
|
case let .copy(title):
|
||||||
|
if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" }
|
||||||
|
case let .delete(title):
|
||||||
|
if let title { "Couldn't move '\(title)' to the trash" } else { "Couldn't move the item to the trash" }
|
||||||
|
case let .restore(title):
|
||||||
|
if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" }
|
||||||
|
case let .purge(title):
|
||||||
|
if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" }
|
||||||
|
case let .style(title):
|
||||||
|
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
|
||||||
|
case let .importAttachment(filename):
|
||||||
|
"Couldn't import '\(filename)'"
|
||||||
|
case .listAttachments:
|
||||||
|
"Couldn't read this card's attachments"
|
||||||
|
case .renumberChildren:
|
||||||
|
"Couldn't renumber cards"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cause tail. `reason`'s own English is diagnostic and already reads as a phrase ("disk
|
||||||
|
/// full", "no such file or directory"); the uneditable-frontmatter case is the one that needs
|
||||||
|
/// translating, because its `description` is a fragment written for a developer.
|
||||||
|
private nonisolated static func causePhrase(for reason: BoardWriteError.Reason) -> String {
|
||||||
|
let text = switch reason {
|
||||||
|
case let .unreadable(message):
|
||||||
|
message
|
||||||
|
case let .uneditableFrontmatter(shape):
|
||||||
|
"this file's frontmatter can't be edited in place (\(shape.description))"
|
||||||
|
case let .io(message):
|
||||||
|
message
|
||||||
|
}
|
||||||
|
return trimmed(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The read-only lock's line. Each cause says the same two things — what is wrong, and that
|
||||||
|
/// what is on screen is still the last good view — because the lock's whole promise is that
|
||||||
|
/// nothing was lost: reading, selecting, searching and copying out all stay live (02 § "The
|
||||||
|
/// lock's scope").
|
||||||
|
public nonisolated static func headline(for lock: ReadOnlyLockReason) -> String {
|
||||||
|
switch lock {
|
||||||
|
case .bracketedReloadFailed:
|
||||||
|
"This board couldn't be re-read after the last operation — showing the last good view, read-only"
|
||||||
|
case .vanishedRoot:
|
||||||
|
"This board's folder is gone — showing the last good view, read-only"
|
||||||
|
case .unwritableLocation:
|
||||||
|
"This board's location can't be written to — showing the last good view, read-only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload breakage: fail-fast's specifics (the offending path and what is wrong with it), plus
|
||||||
|
/// the reassurance that the board is still the board.
|
||||||
|
///
|
||||||
|
/// The path is root-relative as `BoardLoadError` reports it, and `"."` — the root's own
|
||||||
|
/// `index.md` — is spelled as "This board" rather than shown as a lone dot.
|
||||||
|
public nonisolated static func headline(for breakage: BoardLoadError) -> String {
|
||||||
|
let reason = trimmed(breakage.reason.description)
|
||||||
|
let subject = breakage.path == "." || breakage.path.isEmpty
|
||||||
|
? "This board isn't loading"
|
||||||
|
: "'\(breakage.path)' isn't loading"
|
||||||
|
return "\(subject): \(reason) — showing the last good view"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The suspended-history line. It names the *consequence* the user cares about — undo and the
|
||||||
|
/// flush-before-overwrite guarantee are degraded — rather than the git mechanics, and carries
|
||||||
|
/// the diagnosis as its tail.
|
||||||
|
public nonisolated static func headline(for suspension: HistorySuspension) -> String {
|
||||||
|
let reason = trimmed(suspension.reason)
|
||||||
|
let line = "Changes aren't being recorded to history"
|
||||||
|
return reason.isEmpty ? line : "\(line) — \(reason)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims whitespace and a trailing period: these fragments are tails inside a longer line, and
|
||||||
|
/// a stray full stop mid-sentence is the tell of machine-assembled text.
|
||||||
|
private nonisolated static func trimmed(_ text: String) -> String {
|
||||||
|
var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
while value.hasSuffix(".") {
|
||||||
|
value.removeLast()
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension OneShotBanner {
|
||||||
|
/// Whether this failure is an attachment import — the one `WriteOperation` whose one-shot
|
||||||
|
/// ranks in the last precedence class rather than with the other one-shots.
|
||||||
|
var isAttachmentImport: Bool {
|
||||||
|
if case .importAttachment = error.operation { true } else { false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,21 +4,47 @@ import os
|
|||||||
|
|
||||||
// MARK: - Vocabulary
|
// MARK: - Vocabulary
|
||||||
|
|
||||||
/// Why a board is refusing writes — the read-only lock's cause, and the whole of its vocabulary
|
/// Why a board is refusing writes — the read-only lock's cause, and now the whole of the
|
||||||
/// today.
|
/// vocabulary 02-architecture.md names.
|
||||||
///
|
///
|
||||||
/// 02-architecture.md names three ways a board enters the lock and only the first is built here.
|
/// The three cases share one *scope* (§ "The lock's scope") — every mutating command disabled
|
||||||
/// `vanishedRoot` (§ Write-failure surfacing — a root that is gone, after bookmark re-resolution
|
/// across every window sharing the store, drops refused, ⌘-drag moves degraded to copies, editor
|
||||||
/// found nothing) and `unwritableLocation` (§ — a read-only volume or a permission-denied folder,
|
/// buffers kept but their debounced saves suspended — and differ only in cause and in **what
|
||||||
/// probed at open) arrive with the cards that implement them. The banner turns a case of this into
|
/// clears them**, which is the one thing this enum's cases are actually asked about (see
|
||||||
/// user-facing phrasing (§ The banner surface); the store only owns the truth of it.
|
/// `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 {
|
public enum ReadOnlyLockReason: Sendable, Equatable {
|
||||||
/// A reload that followed a bracketed wholesale operation failed, so the last-good snapshot on
|
/// 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
|
/// 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
|
/// 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
|
/// reload succeeds (02-architecture.md § Live-reload resilience, "A failed reload after a
|
||||||
/// bracketed operation locks the board read-only").
|
/// 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
|
case bracketedReloadFailed
|
||||||
|
|
||||||
|
/// The board's root is gone and its bookmark re-resolution found nothing: the volume unmounted,
|
||||||
|
/// or the folder was deleted in Finder while the board was open (02-architecture.md §
|
||||||
|
/// Write-failure surfacing, "A vanished board root locks the board read-only"). Every write
|
||||||
|
/// would land nowhere, so the last-good snapshot stays on screen, read-only.
|
||||||
|
///
|
||||||
|
/// **Clears on the next successful reload, whatever its origin**: a reload can only succeed if
|
||||||
|
/// the root is back, so success *is* the return signal. Pending dirty buffers then save
|
||||||
|
/// normally.
|
||||||
|
case vanishedRoot
|
||||||
|
|
||||||
|
/// The board opened somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a
|
||||||
|
/// read-only share) or a permission-denied folder (02-architecture.md § Write-failure
|
||||||
|
/// surfacing, "An unwritable board location enters the read-only lock at open"). Failing
|
||||||
|
/// loudly, specifically, *once* beats letting every gesture fail one at a time.
|
||||||
|
///
|
||||||
|
/// **Clears only on a successful *reconciling* reload whose writability re-probe passes** —
|
||||||
|
/// unlike its two siblings, whose cause a successful reload disproves by itself. A board on a
|
||||||
|
/// read-only DMG reloads perfectly all day long; only the probe (§ "Writability re-probes on
|
||||||
|
/// every reconciling reload" — wake, activation) can tell that the permission or the mount
|
||||||
|
/// actually changed.
|
||||||
|
case unwritableLocation
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The refusal `BoardStore.performWrite` throws when the board is locked read-only.
|
/// The refusal `BoardStore.performWrite` throws when the board is locked read-only.
|
||||||
@@ -185,11 +211,50 @@ public final class BoardStore {
|
|||||||
/// The board's selection, re-resolved against every snapshot this store applies.
|
/// The board's selection, re-resolved against every snapshot this store applies.
|
||||||
public private(set) var selection: Selection
|
public private(set) var selection: Selection
|
||||||
|
|
||||||
/// The board root this store was opened on. Constant for now: absorbing a rename by
|
/// Where the board is **now**. Follows the folder: a rename or a move absorbed through
|
||||||
/// re-resolving the security-scoped bookmark (02-architecture.md § Write-failure surfacing) is
|
/// `relocate(to:)` updates it, so every URL derived from it — the Writer's paths, card-window
|
||||||
/// the registry's job and a later card's, and it will arrive together with `.rootChanged`
|
/// keys, Reveal in Finder — re-derives at the new location (02-architecture.md §
|
||||||
/// handling.
|
/// Write-failure surfacing, "A renamed or moved board root follows its file identity").
|
||||||
public let rootURL: URL
|
///
|
||||||
|
/// Observed, deliberately: the window title's folder-name fallback reads this, and a rename in
|
||||||
|
/// Finder should be visible in the title bar without anything else being told.
|
||||||
|
///
|
||||||
|
/// `snapshot.rootURL` is the root the *last successful reload* walked, and therefore lags this
|
||||||
|
/// by exactly one reload during an absorption. That is not a second source of truth: the
|
||||||
|
/// relocation is always followed by the watcher reattach whose reconciling reload rebuilds the
|
||||||
|
/// snapshot at the new root, after which the two agree again.
|
||||||
|
public private(set) var rootURL: URL
|
||||||
|
|
||||||
|
// MARK: Banners
|
||||||
|
|
||||||
|
/// The board window's banner strip, as a model (02-architecture.md § The banner surface).
|
||||||
|
///
|
||||||
|
/// **Owned, not injected**, and the reason is the hosting rule: the strip is "hosted by the
|
||||||
|
/// window of origin", and a board window's strip has exactly one lifetime — this store's. A
|
||||||
|
/// card window (m6) gets its *own* center for its own save, attachment, and raw-source Apply
|
||||||
|
/// failures, and re-homes those rows here when it closes; injecting a shared center would
|
||||||
|
/// erase precisely that distinction.
|
||||||
|
///
|
||||||
|
/// It holds only what nothing else does — one-shot write failures, the history suspension,
|
||||||
|
/// in-progress operations, passive signposts. The lock and the reload breakage stay this
|
||||||
|
/// store's own state and are composed in at render time by `bannerRows`.
|
||||||
|
public let banners = BannerCenter()
|
||||||
|
|
||||||
|
/// The rows the board window's strip renders, in precedence order.
|
||||||
|
///
|
||||||
|
/// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and
|
||||||
|
/// `banners` holds the rest, so a stored array would be a third copy waiting to go stale. The
|
||||||
|
/// ordering rule itself lives in `BannerCenter.rows(...)`, which is pure and tested on its own.
|
||||||
|
public var bannerRows: [BannerRow] {
|
||||||
|
BannerCenter.rows(
|
||||||
|
lock: readOnlyLock,
|
||||||
|
breakage: reloadFailure,
|
||||||
|
oneShots: banners.oneShots,
|
||||||
|
suspension: banners.historySuspension,
|
||||||
|
operations: banners.operations,
|
||||||
|
signposts: banners.signposts
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Wiring
|
// MARK: Wiring
|
||||||
|
|
||||||
@@ -200,6 +265,18 @@ public final class BoardStore {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
public var watcherBrackets: (begin: @MainActor () -> Void, end: @MainActor () -> Void)?
|
public var watcherBrackets: (begin: @MainActor () -> Void, end: @MainActor () -> Void)?
|
||||||
|
|
||||||
|
/// What to do when the watched root changes identity — injected for the same reason the
|
||||||
|
/// brackets are: the response needs the board's **security-scoped bookmark**, and this store
|
||||||
|
/// does not own one (the registry does, along with the watcher that must be re-attached and the
|
||||||
|
/// last-known path that arms the return detection). A store that reached for a bookmark it did
|
||||||
|
/// not hold could not be built or tested without a registry.
|
||||||
|
///
|
||||||
|
/// `nil` keeps the documented no-op: the last-good snapshot stays on screen, which is what
|
||||||
|
/// every other failure path here does, and which is exactly the shape unit tests and any
|
||||||
|
/// storeless use want. `BoardStoreRegistry` wires it to its own recovery loop.
|
||||||
|
@ObservationIgnored
|
||||||
|
public var rootChangeDelegate: (@MainActor () -> Void)?
|
||||||
|
|
||||||
// MARK: Reload machinery
|
// MARK: Reload machinery
|
||||||
|
|
||||||
/// Monotonic id of the most recently *started* reload — and therefore also the number of tree
|
/// Monotonic id of the most recently *started* reload — and therefore also the number of tree
|
||||||
@@ -295,16 +372,22 @@ public final class BoardStore {
|
|||||||
requestReload(origin)
|
requestReload(origin)
|
||||||
|
|
||||||
case .rootChanged:
|
case .rootChanged:
|
||||||
// Stubbed on purpose, and the stub is the whole of the honest answer today: the settled
|
// Delegated, never guessed at. The settled response is to re-resolve the board's
|
||||||
// response is to re-resolve the board's security-scoped bookmark and either
|
// security-scoped bookmark and either `reattach(to:)` the watcher at the new location —
|
||||||
// `reattach(to:)` the watcher at the new location — a rename absorbed with no banner and
|
// a rename absorbed with no banner and no lock — or enter the vanished-root read-only
|
||||||
// no lock — or enter the vanished-root read-only lock (02-architecture.md §
|
// lock (02-architecture.md § Write-failure surfacing). Both halves need a bookmark this
|
||||||
// Write-failure surfacing). Both halves need the registry's bookmark, which this store
|
// store does not own, and a guess here would be a *wrong* guess: treating a rename as a
|
||||||
// deliberately does not own yet, and a guess here would be a *wrong* guess: treating a
|
// vanish would lock a board that is merely somewhere else.
|
||||||
// rename as a vanish would lock a board that is merely somewhere else. The card that
|
//
|
||||||
// brings the bookmark replaces this case; until then a root change simply leaves the
|
// No reload is started either way. The delegate's two outcomes both end in one —
|
||||||
// last-good snapshot on screen, which is the same thing every failure path does.
|
// `reattach(to:)`'s reconciling reload at the re-resolved root, or the vanished-root
|
||||||
Self.logger.debug("rootChanged ignored — bookmark re-resolution is not built yet")
|
// 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,26 +433,26 @@ public final class BoardStore {
|
|||||||
outcome = .failure(error)
|
outcome = .failure(error)
|
||||||
}
|
}
|
||||||
await barrier?()
|
await barrier?()
|
||||||
await self?.apply(outcome, generation: generation)
|
await self?.apply(outcome, generation: generation, origin: origin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lands one walk's result and starts whatever it uncovered.
|
/// Lands one walk's result and starts whatever it uncovered.
|
||||||
private func apply(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int) {
|
private func apply(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
|
||||||
reloadInFlight = false
|
reloadInFlight = false
|
||||||
|
|
||||||
// The stale-apply guard. Serialization means this should not trigger today, but "only the
|
// 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
|
// 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.
|
// change are written against, so it is enforced rather than assumed.
|
||||||
if generation == reloadGeneration {
|
if generation == reloadGeneration {
|
||||||
land(outcome, generation: generation)
|
land(outcome, generation: generation, origin: origin)
|
||||||
}
|
}
|
||||||
|
|
||||||
startPendingReload()
|
startPendingReload()
|
||||||
resumeQuiescenceWaitersIfQuiet()
|
resumeQuiescenceWaitersIfQuiet()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func land(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int) {
|
private func land(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
|
||||||
// Consumed here, before the branch, because *both* outcomes end the expectation: a wholesale
|
// 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
|
// operation gets exactly one reload to prove itself, and a second failure after it is
|
||||||
// ordinary per-file breakage again.
|
// ordinary per-file breakage again.
|
||||||
@@ -385,10 +468,10 @@ public final class BoardStore {
|
|||||||
case let .success(result):
|
case let .success(result):
|
||||||
snapshot = result.model
|
snapshot = result.model
|
||||||
loadWarnings = result.warnings
|
loadWarnings = result.warnings
|
||||||
// One success clears both conditions — transient breakage self-heals and a locked board
|
// Breakage always heals on a success — it *is* the claim "the last reload failed", and
|
||||||
// unlocks without the user doing anything but fixing the file.
|
// this one did not.
|
||||||
reloadFailure = nil
|
reloadFailure = nil
|
||||||
readOnlyLock = nil
|
clearLockIfDisproved(by: origin)
|
||||||
selection = selection.resolved(against: result.model)
|
selection = selection.resolved(against: result.model)
|
||||||
|
|
||||||
case let .failure(error):
|
case let .failure(error):
|
||||||
@@ -396,7 +479,13 @@ public final class BoardStore {
|
|||||||
// replaces a good snapshot, and a selection over a snapshot that did not change has
|
// replaces a good snapshot, and a selection over a snapshot that did not change has
|
||||||
// nothing to re-resolve against.
|
// nothing to re-resolve against.
|
||||||
reloadFailure = error
|
reloadFailure = error
|
||||||
if endsWholesaleOperation {
|
// `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
|
readOnlyLock = .bracketedReloadFailed
|
||||||
}
|
}
|
||||||
Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
|
Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
|
||||||
@@ -409,6 +498,88 @@ public final class BoardStore {
|
|||||||
startReload(origin)
|
startReload(origin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The lock's clearing rules
|
||||||
|
|
||||||
|
/// Clears the read-only lock if this successful reload actually disproved its cause.
|
||||||
|
///
|
||||||
|
/// **Reason-specific, because the causes are not alike** (02-architecture.md § Write-failure
|
||||||
|
/// surfacing):
|
||||||
|
///
|
||||||
|
/// - `.bracketedReloadFailed` and `.vanishedRoot` are *disproved by the success itself*. The
|
||||||
|
/// first says "the tree could not be re-read after a wholesale change" and the second says
|
||||||
|
/// "the root is gone" — a completed tree walk at the root contradicts both, whatever origin
|
||||||
|
/// asked for it, so any success clears them.
|
||||||
|
/// - `.unwritableLocation` is not. A board on a read-only DMG reloads flawlessly forever;
|
||||||
|
/// loading proves nothing about writing. It clears only when a **reconciling** reload — wake,
|
||||||
|
/// activation, a stream re-creation — re-probes writability and finds it changed ("Writability
|
||||||
|
/// re-probes on every reconciling reload, so a fixed permission or rewritable remount clears
|
||||||
|
/// the lock without ceremony").
|
||||||
|
///
|
||||||
|
/// `FileManager.isWritableFile(atPath:)` is `access(2)` on the root directory: a real-uid
|
||||||
|
/// permission question asked of the filesystem, which is what makes it answer correctly for
|
||||||
|
/// both halves of the case — a read-only *mount* and a permission-denied *folder*.
|
||||||
|
///
|
||||||
|
/// Deliberately **one-way**: a reconciling reload that finds the root unwritable does not
|
||||||
|
/// *raise* the lock. Arming it is the open flow's job (`enterUnwritableLock()`), and inferring
|
||||||
|
/// a lock from a probe here would be a policy decision this milestone was not asked to make.
|
||||||
|
private func clearLockIfDisproved(by origin: WatchOrigin) {
|
||||||
|
switch readOnlyLock {
|
||||||
|
case nil:
|
||||||
|
break
|
||||||
|
case .bracketedReloadFailed, .vanishedRoot:
|
||||||
|
readOnlyLock = nil
|
||||||
|
case .unwritableLocation:
|
||||||
|
guard origin == .reconciling, FileManager.default.isWritableFile(atPath: rootURL.path) else { return }
|
||||||
|
Self.logger.debug("writability re-probe passed — the unwritable-location lock clears")
|
||||||
|
readOnlyLock = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Root identity and explicit locks
|
||||||
|
|
||||||
|
/// Points this store at the board's new location, absorbing a rename or a move.
|
||||||
|
///
|
||||||
|
/// Called by the registry when a `.rootChanged` re-resolved the board's bookmark somewhere else
|
||||||
|
/// (02-architecture.md § Write-failure surfacing, "A renamed or moved board root follows its
|
||||||
|
/// file identity"): the board the app has open is the *file*, not the path string, so this is
|
||||||
|
/// bookkeeping rather than an event — **no banner, no lock, nothing was ever wrong**.
|
||||||
|
///
|
||||||
|
/// It deliberately starts **no reload**. The caller follows this with the watcher's
|
||||||
|
/// `reattach(to:)`, whose reconciling reload is the one that rebuilds the snapshot at the new
|
||||||
|
/// root; a reload fired from here would be a second walk racing that one for no gain. Until it
|
||||||
|
/// lands, `rootURL` is the new location and `snapshot.rootURL` is still the old — see
|
||||||
|
/// `rootURL`'s note.
|
||||||
|
public func relocate(to newRoot: URL) {
|
||||||
|
guard newRoot != rootURL else { return }
|
||||||
|
Self.logger.debug("board root relocated; Writer URLs now derive from the new location")
|
||||||
|
rootURL = newRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raises the vanished-root read-only lock — the registry's call, after bookmark re-resolution
|
||||||
|
/// found nothing and the last-known path is not there either.
|
||||||
|
///
|
||||||
|
/// Overwrites whatever lock was standing: a root that is gone is the most current and most
|
||||||
|
/// specific truth about why writes are refused, and its clearing rule (a successful reload,
|
||||||
|
/// which can only happen if the root came back) is strictly the safer one to be holding.
|
||||||
|
public func enterVanishedRootLock() {
|
||||||
|
Self.logger.error("board root vanished — entering the read-only lock")
|
||||||
|
readOnlyLock = .vanishedRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raises the unwritable-location read-only lock — the open flow's call, after probing the
|
||||||
|
/// root's writability (02 § "An unwritable board location enters the read-only lock at open").
|
||||||
|
/// Public now so the vocabulary and its clearing rule ship together; m4's open flow is the
|
||||||
|
/// producer.
|
||||||
|
///
|
||||||
|
/// Does **not** overwrite a standing lock: a board that is already locked for a vanished root
|
||||||
|
/// or a failed bracketed reload has a cause that outranks "and it is also read-only", and both
|
||||||
|
/// of those clear on a success that would then re-probe anyway.
|
||||||
|
public func enterUnwritableLock() {
|
||||||
|
guard readOnlyLock == nil else { return }
|
||||||
|
Self.logger.error("board location is not writable — entering the read-only lock")
|
||||||
|
readOnlyLock = .unwritableLocation
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Write gate
|
// MARK: - Write gate
|
||||||
|
|
||||||
/// Runs a synchronous Writer operation inside the watcher bracket, so the churn it produces
|
/// Runs a synchronous Writer operation inside the watcher bracket, so the churn it produces
|
||||||
@@ -418,6 +589,13 @@ public final class BoardStore {
|
|||||||
/// disk; the watcher notices; the reload applies. That indirection is the one-way flow, and it is
|
/// 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.
|
/// 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
|
/// - 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.
|
/// *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
|
/// Otherwise rethrows whatever `operation` threw, which is a `BoardWriteError`. The untyped
|
||||||
@@ -438,7 +616,14 @@ public final class BoardStore {
|
|||||||
// `defer`, not a trailing call: a Writer operation that fails partway has still touched disk,
|
// `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.
|
// and an unbalanced bracket would leave the watcher suspended for the rest of the session.
|
||||||
defer { watcherBrackets?.end() }
|
defer { watcherBrackets?.end() }
|
||||||
|
// `do throws(BoardWriteError)`: without the annotation the `catch` widens to `any Error` and
|
||||||
|
// the Writer's typed error is lost on the way to the banner.
|
||||||
|
do throws(BoardWriteError) {
|
||||||
return try operation()
|
return try operation()
|
||||||
|
} catch {
|
||||||
|
banners.post(error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs an operation that rewrites the tree **wholesale** — pull-rebase, branch switch, undo
|
/// Runs an operation that rewrites the tree **wholesale** — pull-rebase, branch switch, undo
|
||||||
@@ -474,7 +659,18 @@ public final class BoardStore {
|
|||||||
wholesaleReloadFloor = reloadGeneration + 1
|
wholesaleReloadFloor = reloadGeneration + 1
|
||||||
watcherBrackets?.end()
|
watcherBrackets?.end()
|
||||||
}
|
}
|
||||||
|
do {
|
||||||
try operation()
|
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: - Selection
|
// MARK: - Selection
|
||||||
|
|||||||
@@ -78,12 +78,28 @@ struct FileIdentity: Hashable {
|
|||||||
@MainActor
|
@MainActor
|
||||||
public final class BoardStoreRegistry {
|
public final class BoardStoreRegistry {
|
||||||
|
|
||||||
/// One open board: the shared store, the watcher feeding it, and how many windows are holding
|
/// One open board: the shared store, the watcher feeding it, how many windows are holding them
|
||||||
/// them open.
|
/// open — and the two facts the root-recovery loop needs.
|
||||||
private struct Entry {
|
private struct Entry {
|
||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
let watcher: FolderWatcher
|
let watcher: FolderWatcher
|
||||||
var referenceCount: Int
|
var referenceCount: Int
|
||||||
|
|
||||||
|
/// The board's **identity**, minted at acquire (02-architecture.md § Write-failure
|
||||||
|
/// surfacing: "the registry's security-scoped bookmark is the identity, mid-session as much
|
||||||
|
/// as across opens"). Re-minted on every absorbed rename, so it always names the folder
|
||||||
|
/// where the board is now rather than where it was opened.
|
||||||
|
///
|
||||||
|
/// Optional because bookmark creation can fail outright — vanishingly unlikely for a folder
|
||||||
|
/// just walked, and survivable: a board with no bookmark simply falls through to the
|
||||||
|
/// path-reappearance half of the recovery loop, which is the same path a *dead* bookmark
|
||||||
|
/// takes.
|
||||||
|
var bookmark: Data?
|
||||||
|
|
||||||
|
/// Where the board was last known to live. The vanished-root case re-attaches the watcher
|
||||||
|
/// here — FSEvents is content to watch a path that does not exist and reports its
|
||||||
|
/// *creation* as a root change, which is exactly the return detection the lock needs.
|
||||||
|
var lastKnownRoot: URL
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keyed by `FileIdentity`, so a board acquired through a renamed or moved path finds the entry
|
/// Keyed by `FileIdentity`, so a board acquired through a renamed or moved path finds the entry
|
||||||
@@ -143,19 +159,32 @@ public final class BoardStoreRegistry {
|
|||||||
// become no-ops the moment `release` drops the watcher. Neither closure can form a cycle
|
// become no-ops the moment `release` drops the watcher. Neither closure can form a cycle
|
||||||
// with the object it calls into.
|
// with the object it calls into.
|
||||||
let watcher = FolderWatcher(root: rootURL) { [weak store] event in
|
let watcher = FolderWatcher(root: rootURL) { [weak store] event in
|
||||||
// `.rootChanged` rides through like any other event. The store stubs it today; the
|
// `.rootChanged` rides through like any other event; the store hands it straight back
|
||||||
// settled response — re-resolve the board's security-scoped bookmark and either
|
// here through `rootChangeDelegate`, below.
|
||||||
// `reattach(to:)` at the new location or enter the vanished-root read-only lock
|
|
||||||
// (02-architecture.md § Write-failure surfacing) — needs `BoardRegistry`'s bookmark and
|
|
||||||
// belongs to the card that brings the two together. This registry is the natural place
|
|
||||||
// for that seam, since it is the only object holding the store, the watcher, and (by
|
|
||||||
// then) the record at once.
|
|
||||||
store?.handleWatcherEvent(event)
|
store?.handleWatcherEvent(event)
|
||||||
}
|
}
|
||||||
store.watcherBrackets = (
|
store.watcherBrackets = (
|
||||||
begin: { [weak watcher] in watcher?.beginBracket() },
|
begin: { [weak watcher] in watcher?.beginBracket() },
|
||||||
end: { [weak watcher] in watcher?.endBracket() }
|
end: { [weak watcher] in watcher?.endBracket() }
|
||||||
)
|
)
|
||||||
|
// The recovery loop's entry point. It captures the **store**, not the entry's key: an
|
||||||
|
// absorbed rename can re-key the entry (a delete-and-recreate at the same path changes file
|
||||||
|
// identity), and a closure holding a stale key would quietly stop finding its own board on
|
||||||
|
// the second root change. Object identity cannot go stale.
|
||||||
|
store.rootChangeDelegate = { [weak self, weak store] in
|
||||||
|
guard let self, let store else { return }
|
||||||
|
recoverFromRootChange(of: store)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bookmark is minted here — at acquire, once — because that is the moment the app
|
||||||
|
// demonstrably has access to this folder (it just walked it). A bookmark minted later,
|
||||||
|
// after the root has already gone missing, would be exactly the one that cannot be made.
|
||||||
|
let bookmark = BoardRegistry.makeBookmark(for: rootURL)?.data
|
||||||
|
if bookmark == nil {
|
||||||
|
// Not a failure to open: the board loads, renders, and writes. Only the *rename*
|
||||||
|
// half of root recovery is lost, and the path-reappearance half still works.
|
||||||
|
Self.logger.error("no bookmark could be minted for this board; a mid-session move will not be absorbed")
|
||||||
|
}
|
||||||
|
|
||||||
// Default debounce and latency: the numbers are settled in 02-architecture.md § Components
|
// Default debounce and latency: the numbers are settled in 02-architecture.md § Components
|
||||||
// (200 ms trailing over 50 ms FSEvents latency) and live in `FolderWatcher`'s defaults.
|
// (200 ms trailing over 50 ms FSEvents latency) and live in `FolderWatcher`'s defaults.
|
||||||
@@ -168,7 +197,13 @@ public final class BoardStoreRegistry {
|
|||||||
Self.logger.error("watcher stream failed to start; live reload is degraded for this board")
|
Self.logger.error("watcher stream failed to start; live reload is degraded for this board")
|
||||||
}
|
}
|
||||||
|
|
||||||
entries[identity] = Entry(store: store, watcher: watcher, referenceCount: 1)
|
entries[identity] = Entry(
|
||||||
|
store: store,
|
||||||
|
watcher: watcher,
|
||||||
|
referenceCount: 1,
|
||||||
|
bookmark: bookmark,
|
||||||
|
lastKnownRoot: rootURL
|
||||||
|
)
|
||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,11 +234,117 @@ public final class BoardStoreRegistry {
|
|||||||
entry.watcher.stop()
|
entry.watcher.stop()
|
||||||
// Explicit rather than left to the weak captures: after this the store may still be alive in
|
// Explicit rather than left to the weak captures: after this the store may still be alive in
|
||||||
// whatever is closing, and brackets that quietly did nothing would be a lie about a watcher
|
// whatever is closing, and brackets that quietly did nothing would be a lie about a watcher
|
||||||
// that is gone.
|
// that is gone. The root-change delegate goes for the same reason — a released store has no
|
||||||
|
// entry to recover, and leaving the closure attached would invite it to look for one.
|
||||||
entry.store.watcherBrackets = nil
|
entry.store.watcherBrackets = nil
|
||||||
|
entry.store.rootChangeDelegate = nil
|
||||||
entries[identity] = nil
|
entries[identity] = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Root recovery
|
||||||
|
|
||||||
|
/// The whole response to a root-gone signal, in the one object that can give it: the store to
|
||||||
|
/// relocate or lock, the watcher to re-attach, and the bookmark that decides which
|
||||||
|
/// (02-architecture.md § Write-failure surfacing, "A renamed or moved board root follows its
|
||||||
|
/// file identity" and "A vanished board root locks the board read-only").
|
||||||
|
///
|
||||||
|
/// ### Three outcomes, tried in order
|
||||||
|
///
|
||||||
|
/// 1. **The bookmark resolves to a folder that is really there** — a rename or a move on the
|
||||||
|
/// same volume. Absorbed transparently: relocate, re-attach, re-mint. **No banner, no lock,
|
||||||
|
/// nothing was ever wrong.**
|
||||||
|
/// 2. **The bookmark is dead, but the last-known path exists again** — a Finder undo, a
|
||||||
|
/// remount, a folder recreated where the board used to be, arriving as the *creation* of a
|
||||||
|
/// watched path. Treated as the root returning: the bookmark is re-minted from the path and
|
||||||
|
/// the same absorption runs, and the reconciling reload's success is what clears the
|
||||||
|
/// vanished-root lock through the store's own rule.
|
||||||
|
/// 3. **Neither** — the root is truly vanished. The lock goes up and the watcher re-attaches at
|
||||||
|
/// the last-known path anyway: FSEvents happily watches a path that does not exist and
|
||||||
|
/// reports its creation as another `.rootChanged` (pinned in `FolderWatcherTests`), which is
|
||||||
|
/// what arms outcome 2 for later. The re-attach's reconciling reload fails against the
|
||||||
|
/// missing root, which is correct and harmless — a failed reload never replaces a good
|
||||||
|
/// snapshot, and the lock is already standing.
|
||||||
|
///
|
||||||
|
/// ### Why outcome 1 checks the filesystem
|
||||||
|
///
|
||||||
|
/// Bookmark resolution is not a liveness test. `BoardRegistryTests` says as much where it
|
||||||
|
/// refuses to build a dead-bookmark case by deleting a folder ("'ought to' is the filesystem's
|
||||||
|
/// opinion"): a bookmark can resolve by its recorded *path* to something that is no longer
|
||||||
|
/// there. Absorbing that would relocate the board onto a ghost and leave it with a reload
|
||||||
|
/// failure instead of the honest vanished-root lock, so a resolution that does not exist on
|
||||||
|
/// disk falls through to outcomes 2 and 3.
|
||||||
|
///
|
||||||
|
/// ### Re-entrancy
|
||||||
|
///
|
||||||
|
/// Every outcome ends in a fresh stream, and every future root change lands right back here.
|
||||||
|
/// That is the design, not an accident: outcome 3 arms outcome 2, and a board that is renamed
|
||||||
|
/// twice is absorbed twice. Nothing here holds state between calls beyond the entry itself, and
|
||||||
|
/// the entry is looked up by store identity on each pass, so a re-key mid-loop cannot strand a
|
||||||
|
/// later signal.
|
||||||
|
private func recoverFromRootChange(of store: BoardStore) {
|
||||||
|
guard let identity = entries.first(where: { $0.value.store === store })?.key,
|
||||||
|
let entry = entries[identity] else {
|
||||||
|
Self.logger.debug("root change for a store that is no longer registered — ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. The rename-absorption path.
|
||||||
|
if let bookmark = entry.bookmark,
|
||||||
|
let resolution = BoardRegistry.resolve(bookmark),
|
||||||
|
BoardRegistry.withScopedAccess(to: resolution.url, { FileManager.default.fileExists(atPath: $0.path) }) {
|
||||||
|
Self.logger.debug("root change resolved through the bookmark — absorbing the move")
|
||||||
|
absorb(newRoot: resolution.url, at: identity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The root returning at its last-known path — Finder undo, remount, recreation.
|
||||||
|
if FileManager.default.fileExists(atPath: entry.lastKnownRoot.path) {
|
||||||
|
Self.logger.debug("root reappeared at its last-known path — absorbing the return")
|
||||||
|
absorb(newRoot: entry.lastKnownRoot, at: identity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Truly vanished.
|
||||||
|
store.enterVanishedRootLock()
|
||||||
|
entry.watcher.reattach(to: entry.lastKnownRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves an entry's board to `newRoot`: the store's URLs, the watcher's stream, the entry's
|
||||||
|
/// bookmark and last-known path, and — where it changed — the entry's key.
|
||||||
|
///
|
||||||
|
/// **Order matters.** `relocate(to:)` first, so the reconciling reload that `reattach(to:)`
|
||||||
|
/// schedules walks the new root and lands a snapshot that agrees with the store's `rootURL`.
|
||||||
|
/// Re-attaching first would leave a window in which the reload's tree and the Writer's URLs
|
||||||
|
/// disagreed about where the board is.
|
||||||
|
///
|
||||||
|
/// **Re-keying.** A rename keeps the folder's file identity, so the key is usually unchanged
|
||||||
|
/// and this is a no-op. A board deleted and recreated at the same path is a *different inode*
|
||||||
|
/// under the same name, and its entry has to move to the new identity or the next
|
||||||
|
/// `acquire`/`liveStore(for:)` would miss it and open a second store over the board already on
|
||||||
|
/// screen — the exact bug the identity keying exists to prevent. An identity that cannot be
|
||||||
|
/// read leaves the key alone: a stale key still finds the entry by store identity, where
|
||||||
|
/// dropping the entry would strand a live watcher.
|
||||||
|
private func absorb(newRoot: URL, at identity: FileIdentity) {
|
||||||
|
guard var entry = entries[identity] else { return }
|
||||||
|
|
||||||
|
entry.store.relocate(to: newRoot)
|
||||||
|
entry.watcher.reattach(to: newRoot)
|
||||||
|
entry.lastKnownRoot = newRoot
|
||||||
|
// Re-minted from where the board is now: a bookmark tracks moves, but a *stale* one is only
|
||||||
|
// good for a while, and this is the moment the app has both access and certainty.
|
||||||
|
if let refreshed = BoardRegistry.withScopedAccess(to: newRoot, { BoardRegistry.makeBookmark(for: $0) }) {
|
||||||
|
entry.bookmark = refreshed.data
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let currentIdentity = FileIdentity(of: newRoot), currentIdentity != identity else {
|
||||||
|
entries[identity] = entry
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries[identity] = nil
|
||||||
|
entries[currentIdentity] = entry
|
||||||
|
Self.logger.debug("board re-keyed: the folder at this path is a different file than it was")
|
||||||
|
}
|
||||||
|
|
||||||
/// The live store for a board, if any window has it open — **without** taking a reference.
|
/// The live store for a board, if any window has it open — **without** taking a reference.
|
||||||
///
|
///
|
||||||
/// This is the "is this board already open?" question: focusing an existing window rather than
|
/// This is the "is this board already open?" question: focusing an existing window rather than
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// The **one modal moment on the write-failure path** (02-architecture.md § Write-failure
|
||||||
|
/// surfacing), as a mechanism: closing a window — or the board, or quitting — with a dirty buffer
|
||||||
|
/// that cannot be written.
|
||||||
|
///
|
||||||
|
/// ### Why this one case is allowed to be modal
|
||||||
|
///
|
||||||
|
/// Everything else on the write path is non-modal by construction, because everything else is on
|
||||||
|
/// disk: a failed move visibly did not happen, a failed save leaves the keystrokes in the buffer
|
||||||
|
/// and the banner standing while the debounced save retries on its own cadence. Nothing is lost
|
||||||
|
/// while the window stays open. **Closing is the moment that stops being true** — the buffer is the
|
||||||
|
/// only place that text exists, and the window is about to go away. So the close stops and asks:
|
||||||
|
/// retry, save a copy elsewhere, or discard. Failing silently here would be the one place this
|
||||||
|
/// design loses a user's work.
|
||||||
|
///
|
||||||
|
/// ### What this type is, and is not
|
||||||
|
///
|
||||||
|
/// It is the *state machine* for that moment and nothing else: it does not own the buffer, does not
|
||||||
|
/// know what a card is, and does not present anything. Two closures supply everything specific —
|
||||||
|
/// `attemptSave` flushes the dirty buffer to its real home (the debounced save's target) and
|
||||||
|
/// `writeCopy` writes the buffer's current text wherever the user pointed. Presentation is
|
||||||
|
/// `View.dirtyBufferAlert(_:copyDestination:)` in `Kanban/UI/`.
|
||||||
|
///
|
||||||
|
/// ### Who will call it
|
||||||
|
///
|
||||||
|
/// - **m6's editor sessions** — the card window's body Edit buffer and its raw-source buffer, each
|
||||||
|
/// closing with a flush (05-card-window.md: "Leaving Edit flushes the debounce ... window
|
||||||
|
/// close").
|
||||||
|
/// - **m4's board-close flush** — closing a board window flushes pending debounced work before the
|
||||||
|
/// store tears down (02 § Windows, "Close flushes"), and a flush that cannot land is this moment
|
||||||
|
/// arriving at the board level.
|
||||||
|
///
|
||||||
|
/// Both are the same shape, which is why the mechanism is generic and arrives before either
|
||||||
|
/// caller: a buffer, a save that can fail, and a close that must not proceed until the text is
|
||||||
|
/// somewhere.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class DirtyBufferGuard {
|
||||||
|
|
||||||
|
/// Whether a close is currently held up.
|
||||||
|
///
|
||||||
|
/// Deliberately two cases. There is no `.saving` or `.retrying`: `attemptSave` is synchronous
|
||||||
|
/// (it is a file write, and the buffer is already in memory), so there is no interval during
|
||||||
|
/// which a third state could be observed — inventing one would only invite a spinner over a
|
||||||
|
/// moment that does not exist.
|
||||||
|
public enum Phase: Equatable {
|
||||||
|
/// Nothing is holding the close: either the buffer is safe, or none was ever dirty.
|
||||||
|
case idle
|
||||||
|
/// The save failed. The modal must present, carrying this error's phrasing, and the close
|
||||||
|
/// must not proceed until one of `retry()`, `saveCopy(to:)`, or `discard()` returns it to
|
||||||
|
/// `.idle`.
|
||||||
|
case blocked(BoardWriteError)
|
||||||
|
}
|
||||||
|
|
||||||
|
public private(set) var phase: Phase = .idle
|
||||||
|
|
||||||
|
/// Flushes the dirty buffer to its real home — the same write the debounced save performs.
|
||||||
|
private let attemptSave: @MainActor () throws(BoardWriteError) -> Void
|
||||||
|
|
||||||
|
/// Writes the buffer's current text to an arbitrary user-picked URL — "save a copy elsewhere".
|
||||||
|
///
|
||||||
|
/// Untyped `throws` on purpose: this writes outside the board, to a destination the user chose
|
||||||
|
/// through a save panel, so its failures are `Foundation`'s (`Data.write`, `NSError` from the
|
||||||
|
/// panel's URL) rather than the Writer's closed vocabulary. Forcing them into `BoardWriteError`
|
||||||
|
/// would mean inventing a `WriteOperation` case for a file that is not part of any board.
|
||||||
|
private let writeCopy: @MainActor (URL) throws -> Void
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "dirty-buffer")
|
||||||
|
|
||||||
|
public init(
|
||||||
|
attemptSave: @escaping @MainActor () throws(BoardWriteError) -> Void,
|
||||||
|
writeCopy: @escaping @MainActor (URL) throws -> Void
|
||||||
|
) {
|
||||||
|
self.attemptSave = attemptSave
|
||||||
|
self.writeCopy = writeCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The close flow's gate: tries the save and answers whether the close may proceed.
|
||||||
|
///
|
||||||
|
/// - Returns: `true` when the buffer landed and the window may close. `false` when it did not —
|
||||||
|
/// `phase` is `.blocked` and the alert must present.
|
||||||
|
///
|
||||||
|
/// The caller decides *whether* to call this: a clean buffer has nothing to flush, and asking
|
||||||
|
/// this type about it would mean teaching it what "dirty" means for text it does not own.
|
||||||
|
public func beginClose() -> Bool {
|
||||||
|
attempt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-attempts the save from the alert's "Try Again". Same act as `beginClose()`, named for the
|
||||||
|
/// button that calls it — a retry after the user freed some disk or reconnected a volume is the
|
||||||
|
/// case this whole moment exists to make possible.
|
||||||
|
///
|
||||||
|
/// - Returns: `true` when the buffer landed and the close may resume.
|
||||||
|
@discardableResult
|
||||||
|
public func retry() -> Bool {
|
||||||
|
attempt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the buffer's text to a destination the user picked, and unblocks on success.
|
||||||
|
///
|
||||||
|
/// **The text is safe elsewhere, so the close may proceed** — the buffer's real home is still
|
||||||
|
/// unwritten, and that is the trade the user just made knowingly. A failure rethrows and leaves
|
||||||
|
/// the phase blocked: the modal stays up, because the text is still nowhere but memory.
|
||||||
|
public func saveCopy(to url: URL) throws {
|
||||||
|
try writeCopy(url)
|
||||||
|
Self.logger.debug("dirty buffer saved as a copy; the close may proceed")
|
||||||
|
phase = .idle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user chose to lose the text. Unblocks unconditionally — this is the one branch with
|
||||||
|
/// nothing to verify, and second-guessing an explicit discard would be its own kind of
|
||||||
|
/// dishonesty.
|
||||||
|
public func discard() {
|
||||||
|
Self.logger.debug("dirty buffer discarded at the user's request")
|
||||||
|
phase = .idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attempt() -> Bool {
|
||||||
|
do throws(BoardWriteError) {
|
||||||
|
try attemptSave()
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("dirty buffer could not be saved: \(error.description, privacy: .public)")
|
||||||
|
phase = .blocked(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
phase = .idle
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The banner strip: one window's standing conditions, unread failures, and work in flight, as a
|
||||||
|
/// stack of rows (02-architecture.md § The banner surface).
|
||||||
|
///
|
||||||
|
/// ### Tones, not components
|
||||||
|
///
|
||||||
|
/// There is one row layout and three colorings. An error, a warning, and an info row differ in
|
||||||
|
/// symbol and tint and in nothing else — which is what lets the card window's remote-change
|
||||||
|
/// signpost (07-sync-collab.md) be "this same component in the info tone" rather than a second
|
||||||
|
/// thing that drifts. The per-kind affordances hang off the row's data, not off separate views: a
|
||||||
|
/// dismiss control appears exactly where `BannerRow.dismissID` is non-`nil`, a spinner and its
|
||||||
|
/// optional Cancel exactly where the row is `.inProgress`.
|
||||||
|
///
|
||||||
|
/// ### The collapse rule
|
||||||
|
///
|
||||||
|
/// "Beyond three rows the remainder collapse behind a '+N more' disclosure" — because the strip is
|
||||||
|
/// window furniture above the board, and a board that has gone badly wrong must not disappear
|
||||||
|
/// under its own error messages. Precedence ordering is what makes three the right number to show:
|
||||||
|
/// `BannerCenter.rows(...)` has already put the lock, the breakage, and the newest unread failure
|
||||||
|
/// at the top, so the collapsed remainder is always the least urgent tail.
|
||||||
|
///
|
||||||
|
/// **In-progress rows are exempt, and do not count toward the budget** (settled, 02): they are the
|
||||||
|
/// strip's only explanation for a bracket's write lock and for a close or quit deferring teardown,
|
||||||
|
/// and a copy row's Cancel has to stay reachable — "a spinner may never hide behind '+N more'".
|
||||||
|
/// They are safe to pin because there are few at once and each clears itself. So the strip is
|
||||||
|
/// always *every* pinned row, then at most three of the rest.
|
||||||
|
///
|
||||||
|
/// ### Deliberately self-contained
|
||||||
|
///
|
||||||
|
/// It takes rows and two callbacks; it reaches for no store and no environment. The board window
|
||||||
|
/// (m4) hosts it over the lane area, the card window (m6) hosts its own, and the re-homing rule —
|
||||||
|
/// a card window's condition moving to the board window's strip when it closes — is a question of
|
||||||
|
/// which `BannerCenter` a row came from, never of this view.
|
||||||
|
public struct BannerStripView: View {
|
||||||
|
|
||||||
|
private let rows: [BannerRow]
|
||||||
|
private let onDismiss: (UUID) -> Void
|
||||||
|
|
||||||
|
/// How many rows show before the disclosure takes over.
|
||||||
|
private static let collapseThreshold = 3
|
||||||
|
|
||||||
|
@State private var isExpanded = false
|
||||||
|
|
||||||
|
public init(rows: [BannerRow], onDismiss: @escaping (UUID) -> Void) {
|
||||||
|
self.rows = rows
|
||||||
|
self.onDismiss = onDismiss
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
if !rows.isEmpty {
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
ForEach(pinnedRows) { row in
|
||||||
|
BannerRowView(row: row, onDismiss: onDismiss)
|
||||||
|
}
|
||||||
|
ForEach(visibleCollapsibleRows) { row in
|
||||||
|
BannerRowView(row: row, onDismiss: onDismiss)
|
||||||
|
}
|
||||||
|
if hiddenCount > 0 {
|
||||||
|
disclosure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(.quaternary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rows that always show, in precedence order — in-progress rows, which `rows(...)` has
|
||||||
|
/// already placed at the head, so rendering them first preserves that order rather than
|
||||||
|
/// imposing a second one.
|
||||||
|
private var pinnedRows: [BannerRow] {
|
||||||
|
rows.filter(\.isPinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var collapsibleRows: [BannerRow] {
|
||||||
|
rows.filter { !$0.isPinned }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything collapsible when expanded or short enough, the first three otherwise. `prefix`
|
||||||
|
/// and not a filter — precedence order is the whole point, so what shows is always a prefix of
|
||||||
|
/// the ordered list.
|
||||||
|
private var visibleCollapsibleRows: [BannerRow] {
|
||||||
|
isExpanded || collapsibleRows.count <= Self.collapseThreshold
|
||||||
|
? collapsibleRows
|
||||||
|
: Array(collapsibleRows.prefix(Self.collapseThreshold))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hiddenCount: Int {
|
||||||
|
max(0, collapsibleRows.count - Self.collapseThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var disclosure: some View {
|
||||||
|
Button {
|
||||||
|
isExpanded.toggle()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||||
|
.imageScale(.small)
|
||||||
|
Text(isExpanded ? "Show fewer" : "+\(hiddenCount) more")
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.background(.background.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - One row
|
||||||
|
|
||||||
|
/// A single banner row. Everything kind-specific is a branch on the row's data; the layout is one
|
||||||
|
/// `HStack` for all five cases.
|
||||||
|
private struct BannerRowView: View {
|
||||||
|
|
||||||
|
let row: BannerRow
|
||||||
|
let onDismiss: (UUID) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
leading
|
||||||
|
|
||||||
|
Text(row.headline)
|
||||||
|
.font(.callout)
|
||||||
|
// Wrapping, never truncating: the cause tail ("— disk full") is the half that says
|
||||||
|
// what to do about it, and a strip that hid it would be decoration.
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
trailingControls
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(row.tone.fill)
|
||||||
|
// One element per row, tone included: a VoiceOver user must hear *that* this is an error
|
||||||
|
// before hearing what the error is, and colour cannot carry that. The dismiss and Cancel
|
||||||
|
// buttons survive as custom actions of the combined element rather than as separate stops
|
||||||
|
// (10-accessibility.md; the announce-on-appear path arrives with that milestone).
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel(Text("\(row.tone.accessibilityPrefix): \(row.headline)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var leading: some View {
|
||||||
|
if case .inProgress = row {
|
||||||
|
// The spinner replaces the symbol rather than joining it: an in-progress row's state
|
||||||
|
// *is* "still going", and two glyphs saying so would be noise.
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Image(systemName: row.tone.symbolName)
|
||||||
|
.foregroundStyle(row.tone.accent)
|
||||||
|
.imageScale(.medium)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var trailingControls: some View {
|
||||||
|
if case let .inProgress(operation) = row, let cancel = operation.cancel {
|
||||||
|
// Cancel appears on safe copies only (02, settled): it means "remove the partial copy,
|
||||||
|
// nothing lost". Git brackets pass no closure and therefore get no button.
|
||||||
|
Button("Cancel", action: cancel)
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let dismissID = row.dismissID {
|
||||||
|
Button {
|
||||||
|
onDismiss(dismissID)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.imageScale(.small)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Dismiss")
|
||||||
|
.help("Dismiss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tone rendering
|
||||||
|
|
||||||
|
private extension BannerTone {
|
||||||
|
var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .error: "exclamationmark.triangle.fill"
|
||||||
|
case .warning: "exclamationmark.circle.fill"
|
||||||
|
case .info: "info.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var accent: Color {
|
||||||
|
switch self {
|
||||||
|
case .error: .red
|
||||||
|
case .warning: .orange
|
||||||
|
case .info: .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wash, not a slab: the strip sits above the board and must read as furniture rather than as
|
||||||
|
/// a second window. Info is deliberately the calmest of the three — "visually calm, no error
|
||||||
|
/// colour" is the settled description of the signpost that shares this tone.
|
||||||
|
var fill: some ShapeStyle {
|
||||||
|
switch self {
|
||||||
|
case .error: AnyShapeStyle(Color.red.opacity(0.12))
|
||||||
|
case .warning: AnyShapeStyle(Color.orange.opacity(0.12))
|
||||||
|
case .info: AnyShapeStyle(.background.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What VoiceOver says before the headline. "Status" rather than "Info" because that is the
|
||||||
|
/// word the platform uses for a non-alarming state announcement.
|
||||||
|
var accessibilityPrefix: String {
|
||||||
|
switch self {
|
||||||
|
case .error: "Error"
|
||||||
|
case .warning: "Warning"
|
||||||
|
case .info: "Status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews
|
||||||
|
|
||||||
|
private func previewError(
|
||||||
|
_ operation: WriteOperation,
|
||||||
|
_ reason: BoardWriteError.Reason = .io(message: "the disk is full")
|
||||||
|
) -> BoardWriteError {
|
||||||
|
BoardWriteError(operation: operation, path: "/Users/x/Boards/Work/lane/index.md", reason: reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Single error") {
|
||||||
|
BannerStripView(
|
||||||
|
rows: [.oneShot(OneShotBanner(error: previewError(.move(title: "Fix login"))))],
|
||||||
|
onDismiss: { _ in }
|
||||||
|
)
|
||||||
|
.frame(width: 520)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Stacked tones") {
|
||||||
|
BannerStripView(
|
||||||
|
rows: [
|
||||||
|
.inProgress(InProgressOperation(label: "Pulling…")),
|
||||||
|
.readOnlyLock(.vanishedRoot),
|
||||||
|
.oneShot(OneShotBanner(error: previewError(.delete(title: "Ship the beta")))),
|
||||||
|
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
|
||||||
|
.signpost(InfoSignpost(message: "This card changed on the remote — your edits still win")),
|
||||||
|
],
|
||||||
|
onDismiss: { _ in }
|
||||||
|
)
|
||||||
|
.frame(width: 520)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seven rows, one of them pinned: the strip shows the spinner plus the first three of the rest,
|
||||||
|
/// and "+3 more" counts only what actually collapsed.
|
||||||
|
#Preview("Collapse") {
|
||||||
|
BannerStripView(
|
||||||
|
rows: [
|
||||||
|
.inProgress(InProgressOperation(label: "Importing 24 attachments…", cancel: {})),
|
||||||
|
.readOnlyLock(.bracketedReloadFailed),
|
||||||
|
.reloadBreakage(BoardLoadError(path: "todo/index.md", reason: .missingOrder)),
|
||||||
|
.oneShot(OneShotBanner(error: previewError(.move(title: "Fix login")))),
|
||||||
|
.oneShot(OneShotBanner(error: previewError(.style(title: "Design review")))),
|
||||||
|
.oneShot(OneShotBanner(error: previewError(.renumberChildren))),
|
||||||
|
.oneShot(OneShotBanner(error: previewError(.importAttachment(filename: "photo.png"),
|
||||||
|
.unreadable(message: "the source file could not be read")))),
|
||||||
|
],
|
||||||
|
onDismiss: { _ in }
|
||||||
|
)
|
||||||
|
.frame(width: 520)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Presents `DirtyBufferGuard`'s blocked phase as the app's one modal moment on the write-failure
|
||||||
|
/// path (02-architecture.md § Write-failure surfacing).
|
||||||
|
///
|
||||||
|
/// ### The three choices, and why there is no fourth
|
||||||
|
///
|
||||||
|
/// Retry, save a copy elsewhere, discard. There is deliberately **no Cancel** — no "keep the window
|
||||||
|
/// open and think about it": the close is already stopped, so a fourth button would only mean
|
||||||
|
/// "stop asking", which is the silent failure this alert exists to prevent. Dismissing the alert
|
||||||
|
/// without choosing leaves the phase blocked and the alert comes back; the only ways out are the
|
||||||
|
/// three that put the text somewhere or knowingly let it go.
|
||||||
|
///
|
||||||
|
/// ### The copy destination
|
||||||
|
///
|
||||||
|
/// `copyDestination` supplies the URL. In a real window that is an `NSSavePanel` (or a
|
||||||
|
/// `fileExporter`) run from the button; here it is a closure so the flow is testable and so this
|
||||||
|
/// modifier stays free of file-picking machinery. Returning `nil` means the user backed out of the
|
||||||
|
/// panel — the phase stays blocked and the alert returns, which is the honest outcome.
|
||||||
|
///
|
||||||
|
/// ### Callers
|
||||||
|
///
|
||||||
|
/// m6's editor sessions (the card window's body and raw-source buffers) and m4's board-close
|
||||||
|
/// flush. Both attach this to the window that is trying to close.
|
||||||
|
public extension View {
|
||||||
|
func dirtyBufferAlert(
|
||||||
|
_ bufferGuard: DirtyBufferGuard,
|
||||||
|
copyDestination: @escaping @MainActor () -> URL?
|
||||||
|
) -> some View {
|
||||||
|
modifier(DirtyBufferAlertModifier(bufferGuard: bufferGuard, copyDestination: copyDestination))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DirtyBufferAlertModifier: ViewModifier {
|
||||||
|
|
||||||
|
let bufferGuard: DirtyBufferGuard
|
||||||
|
let copyDestination: @MainActor () -> URL?
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.alert(
|
||||||
|
"Your changes couldn't be saved",
|
||||||
|
isPresented: Binding(
|
||||||
|
// A getter over the phase and a setter that does nothing: SwiftUI writes `false`
|
||||||
|
// when the alert is dismissed by any route it manages, and honouring that would
|
||||||
|
// close the window with the text still nowhere but memory. Only the three buttons
|
||||||
|
// move the phase, so only they can take the alert down.
|
||||||
|
get: { bufferGuard.phase != .idle },
|
||||||
|
set: { _ in }
|
||||||
|
),
|
||||||
|
presenting: blockingError
|
||||||
|
) { _ in
|
||||||
|
Button("Try Again") {
|
||||||
|
bufferGuard.retry()
|
||||||
|
}
|
||||||
|
Button("Save a Copy…") {
|
||||||
|
guard let url = copyDestination() else { return }
|
||||||
|
// A failed copy rethrows into a still-blocked phase, so the alert simply returns —
|
||||||
|
// the same place the user already was, with nothing lost. There is no second error
|
||||||
|
// surface to build here: this *is* the error surface.
|
||||||
|
try? bufferGuard.saveCopy(to: url)
|
||||||
|
}
|
||||||
|
Button("Discard Changes", role: .destructive) {
|
||||||
|
bufferGuard.discard()
|
||||||
|
}
|
||||||
|
} message: { error in
|
||||||
|
Text("\(BannerCenter.headline(for: error))\n\nSave a copy somewhere else, or discard the changes to close.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var blockingError: BoardWriteError? {
|
||||||
|
if case let .blocked(error) = bufferGuard.phase { error } else { nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// `BannerCenter` is two things, and this suite is two suites: an **ordering rule** that is a pure
|
||||||
|
/// function over six row classes, and the **product's voice** — every sentence a user reads when a
|
||||||
|
/// write fails. The first is tested the way pure functions are, with no store and no filesystem;
|
||||||
|
/// the second is tested for the properties phrasing has to have (non-empty, distinct per
|
||||||
|
/// operation, titles quoted when known and graceful when not) rather than by pinning literals that
|
||||||
|
/// would turn every copy edit into a failing test.
|
||||||
|
///
|
||||||
|
/// The third part — that a failed write *reaches* the strip at all — needs a real store and a real
|
||||||
|
/// Writer failure, because the claim is about `performWrite`'s wiring rather than about the center.
|
||||||
|
/// Those tests are at the bottom, over `WriterTestSupport`'s fixtures like every other suite here.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||||
|
|
||||||
|
/// A board with one lane whose frontmatter is readable but **uneditable** — the whole-frontmatter
|
||||||
|
/// flow mapping. It loads and renders fine; any app write to it refuses loudly, which is the
|
||||||
|
/// cheapest genuine Writer failure there is (no permissions to fiddle with, no disk to fill).
|
||||||
|
@MainActor
|
||||||
|
private func makeBoardWithUneditableLane() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item(Ident.lane2, Item.uneditable)
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
private func error(
|
||||||
|
_ operation: WriteOperation,
|
||||||
|
_ reason: BoardWriteError.Reason = .io(message: "the disk is full")
|
||||||
|
) -> BoardWriteError {
|
||||||
|
BoardWriteError(operation: operation, path: "/Boards/Work/lane/index.md", reason: reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One representative error per `WriteOperation` case. Spelled out rather than derived so that a
|
||||||
|
/// new case added to the enum shows up here as a missing entry the moment anyone looks — the
|
||||||
|
/// switch in `actionPhrase(for:)` is the compile-time guard; this is the reading guard.
|
||||||
|
private let everyOperation: [WriteOperation] = [
|
||||||
|
.createBoard,
|
||||||
|
.createLane,
|
||||||
|
.createCard,
|
||||||
|
.move(title: "Fix login"),
|
||||||
|
.reorder(title: "Fix login"),
|
||||||
|
.copy(title: "Fix login"),
|
||||||
|
.delete(title: "Fix login"),
|
||||||
|
.restore(title: "Fix login"),
|
||||||
|
.purge(title: "Fix login"),
|
||||||
|
.style(title: "Fix login"),
|
||||||
|
.importAttachment(filename: "photo.png"),
|
||||||
|
.listAttachments,
|
||||||
|
.renumberChildren,
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The titled cases, and only those: `withTitle(_:)`'s own list of what can carry one.
|
||||||
|
private let titledOperations: [(with: WriteOperation, without: WriteOperation)] = [
|
||||||
|
(.move(title: "Fix login"), .move(title: nil)),
|
||||||
|
(.reorder(title: "Fix login"), .reorder(title: nil)),
|
||||||
|
(.copy(title: "Fix login"), .copy(title: nil)),
|
||||||
|
(.delete(title: "Fix login"), .delete(title: nil)),
|
||||||
|
(.restore(title: "Fix login"), .restore(title: nil)),
|
||||||
|
(.purge(title: "Fix login"), .purge(title: nil)),
|
||||||
|
(.style(title: "Fix login"), .style(title: nil)),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Ordering
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BannerCenter ▸ ordering")
|
||||||
|
struct BannerCenterOrderingTests {
|
||||||
|
|
||||||
|
@Test("Every class present produces the settled precedence order")
|
||||||
|
func everyClassOrdersByPrecedence() {
|
||||||
|
let move = OneShotBanner(error: error(.move(title: "Fix login")), occurredAt: Date(timeIntervalSince1970: 100))
|
||||||
|
let attachment = OneShotBanner(
|
||||||
|
error: error(.importAttachment(filename: "photo.png")),
|
||||||
|
occurredAt: Date(timeIntervalSince1970: 200)
|
||||||
|
)
|
||||||
|
let operation = InProgressOperation(label: "Pulling…")
|
||||||
|
let signpost = InfoSignpost(message: "This card changed on the remote")
|
||||||
|
|
||||||
|
let rows = BannerCenter.rows(
|
||||||
|
lock: .vanishedRoot,
|
||||||
|
breakage: BoardLoadError(path: "todo/index.md", reason: .missingOrder),
|
||||||
|
oneShots: [attachment, move],
|
||||||
|
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
|
||||||
|
operations: [operation],
|
||||||
|
signposts: [signpost]
|
||||||
|
)
|
||||||
|
|
||||||
|
// in-progress (pinned) > read-only lock > reload breakage > one-shot write failures >
|
||||||
|
// commit and attachment failures > passive info rows. The two info classes sit at opposite
|
||||||
|
// ends of the strip.
|
||||||
|
#expect(rows.map(\.id) == [
|
||||||
|
"operation:\(operation.id.uuidString)",
|
||||||
|
"read-only-lock",
|
||||||
|
"reload-breakage",
|
||||||
|
"one-shot:\(move.id.uuidString)",
|
||||||
|
"history-suspension",
|
||||||
|
"one-shot:\(attachment.id.uuidString)",
|
||||||
|
"signpost:\(signpost.id.uuidString)",
|
||||||
|
])
|
||||||
|
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .error, .info])
|
||||||
|
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false],
|
||||||
|
"a spinner may never hide behind '+N more' — nothing else is pinned")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An attachment failure ranks below other one-shots even when it is newer")
|
||||||
|
func attachmentFailuresRankLast() {
|
||||||
|
let attachment = OneShotBanner(
|
||||||
|
error: error(.importAttachment(filename: "photo.png")),
|
||||||
|
occurredAt: Date(timeIntervalSince1970: 900)
|
||||||
|
)
|
||||||
|
let style = OneShotBanner(error: error(.style(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1))
|
||||||
|
|
||||||
|
let rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [attachment, style], suspension: nil, operations: [])
|
||||||
|
|
||||||
|
#expect(rows.map(\.id) == ["one-shot:\(style.id.uuidString)", "one-shot:\(attachment.id.uuidString)"],
|
||||||
|
"precedence class outranks recency; recency only orders within a class")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("One-shots order newest first within their class")
|
||||||
|
func oneShotsAreNewestFirst() {
|
||||||
|
let oldest = OneShotBanner(error: error(.move(title: "A")), occurredAt: Date(timeIntervalSince1970: 1))
|
||||||
|
let middle = OneShotBanner(error: error(.move(title: "B")), occurredAt: Date(timeIntervalSince1970: 2))
|
||||||
|
let newest = OneShotBanner(error: error(.move(title: "C")), occurredAt: Date(timeIntervalSince1970: 3))
|
||||||
|
|
||||||
|
let rows = BannerCenter.rows(
|
||||||
|
lock: nil,
|
||||||
|
breakage: nil,
|
||||||
|
oneShots: [middle, oldest, newest],
|
||||||
|
suspension: nil,
|
||||||
|
operations: []
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(rows.map(\.id) == [
|
||||||
|
"one-shot:\(newest.id.uuidString)",
|
||||||
|
"one-shot:\(middle.id.uuidString)",
|
||||||
|
"one-shot:\(oldest.id.uuidString)",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Failures sharing a timestamp keep the order they were posted in")
|
||||||
|
func tiesAreStable() {
|
||||||
|
let center = BannerCenter()
|
||||||
|
// Posted in one run loop turn: `Date()` may well hand both the same value, and the strip
|
||||||
|
// must not shuffle between renders because of it.
|
||||||
|
center.post(error(.move(title: "First")))
|
||||||
|
center.post(error(.move(title: "Second")))
|
||||||
|
center.post(error(.move(title: "Third")))
|
||||||
|
|
||||||
|
let rows = BannerCenter.rows(
|
||||||
|
lock: nil,
|
||||||
|
breakage: nil,
|
||||||
|
oneShots: center.oneShots,
|
||||||
|
suspension: nil,
|
||||||
|
operations: []
|
||||||
|
)
|
||||||
|
#expect(rows.map(\.headline).map { $0.contains("'Third'") } == [true, false, false])
|
||||||
|
#expect(rows.count == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nothing standing is an empty strip")
|
||||||
|
func quietBoardHasNoRows() {
|
||||||
|
#expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: []).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycles
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BannerCenter ▸ lifecycles")
|
||||||
|
struct BannerCenterLifecycleTests {
|
||||||
|
|
||||||
|
@Test("One-shots dismiss individually; conditions carry no dismiss control at all")
|
||||||
|
func oneShotsDismissIndividually() throws {
|
||||||
|
let center = BannerCenter()
|
||||||
|
center.post(error(.move(title: "First")))
|
||||||
|
center.post(error(.style(title: "Second")))
|
||||||
|
#expect(center.oneShots.count == 2)
|
||||||
|
|
||||||
|
let doomed = try #require(center.oneShots.first)
|
||||||
|
center.dismiss(doomed.id)
|
||||||
|
|
||||||
|
#expect(center.oneShots.count == 1)
|
||||||
|
#expect(center.oneShots.first?.id != doomed.id, "dismissing one must not take its neighbour")
|
||||||
|
|
||||||
|
// The condition rows have no id to dismiss with — the API shape *is* the rule ("conditions
|
||||||
|
// heal", 02 § The banner surface), and this is where it is stated as a test.
|
||||||
|
let rows = BannerCenter.rows(
|
||||||
|
lock: .bracketedReloadFailed,
|
||||||
|
breakage: BoardLoadError(path: ".", reason: .boardRootMissingIndex),
|
||||||
|
oneShots: center.oneShots,
|
||||||
|
suspension: HistorySuspension(reason: "disk full"),
|
||||||
|
operations: [InProgressOperation(label: "Pulling…")]
|
||||||
|
)
|
||||||
|
#expect(rows.filter { $0.dismissID != nil }.count == 1, "only the one-shot may be dismissed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Dismissing an in-progress operation's id does nothing — dismiss is not cancel")
|
||||||
|
func dismissDoesNotEndOperations() {
|
||||||
|
let center = BannerCenter()
|
||||||
|
let id = center.beginOperation(label: "Importing…", cancel: nil)
|
||||||
|
center.dismiss(id)
|
||||||
|
#expect(center.operations.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Suspending history raises a warning row; clearing it takes the row away")
|
||||||
|
func historySuspensionIsAHealingCondition() throws {
|
||||||
|
let center = BannerCenter()
|
||||||
|
#expect(center.historySuspension == nil)
|
||||||
|
|
||||||
|
center.suspendHistory(reason: "the repository is corrupt")
|
||||||
|
let suspension = try #require(center.historySuspension)
|
||||||
|
#expect(suspension.reason == "the repository is corrupt")
|
||||||
|
|
||||||
|
let rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: center.historySuspension, operations: [])
|
||||||
|
#expect(rows.count == 1)
|
||||||
|
#expect(rows[0].tone == .warning, "the files are safe; only the undo trail is degraded")
|
||||||
|
#expect(rows[0].headline.contains("history"))
|
||||||
|
#expect(rows[0].dismissID == nil)
|
||||||
|
|
||||||
|
center.clearHistorySuspension()
|
||||||
|
#expect(center.historySuspension == nil)
|
||||||
|
#expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: center.historySuspension, operations: []).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Re-suspending keeps the original start and takes the newer diagnosis")
|
||||||
|
func resuspendingKeepsTheClock() throws {
|
||||||
|
let center = BannerCenter()
|
||||||
|
center.suspendHistory(reason: "disk full")
|
||||||
|
let first = try #require(center.historySuspension)
|
||||||
|
|
||||||
|
center.suspendHistory(reason: "the repository is corrupt")
|
||||||
|
let second = try #require(center.historySuspension)
|
||||||
|
|
||||||
|
#expect(second.since == first.since, "the condition never stopped being true")
|
||||||
|
#expect(second.reason == "the repository is corrupt")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Beginning an operation shows an info row; ending it clears the row")
|
||||||
|
func operationsCompleteByLeaving() {
|
||||||
|
let center = BannerCenter()
|
||||||
|
let id = center.beginOperation(label: "Pulling…", cancel: nil)
|
||||||
|
|
||||||
|
var rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: center.operations)
|
||||||
|
#expect(rows.count == 1)
|
||||||
|
#expect(rows[0].tone == .info)
|
||||||
|
#expect(rows[0].headline == "Pulling…")
|
||||||
|
guard case let .inProgress(operation) = rows[0] else {
|
||||||
|
Issue.record("expected an in-progress row")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(!operation.isCancelable, "git brackets get no Cancel — settled")
|
||||||
|
|
||||||
|
center.endOperation(id)
|
||||||
|
rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: center.operations)
|
||||||
|
#expect(rows.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Copy-shaped work carries Cancel, and cancelling is the caller's closure")
|
||||||
|
func cancelableOperationsRunTheirClosure() throws {
|
||||||
|
let center = BannerCenter()
|
||||||
|
let cancelled = Box()
|
||||||
|
let id = center.beginOperation(label: "Importing 24 attachments…", cancel: { cancelled.value = true })
|
||||||
|
|
||||||
|
let operation = try #require(center.operations.first)
|
||||||
|
#expect(operation.isCancelable)
|
||||||
|
operation.cancel?()
|
||||||
|
#expect(cancelled.value)
|
||||||
|
|
||||||
|
center.endOperation(id)
|
||||||
|
#expect(center.operations.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A signpost is a calm, dismissable row that ranks last and may collapse")
|
||||||
|
func signpostsRankLastAndDismiss() throws {
|
||||||
|
let center = BannerCenter()
|
||||||
|
center.postSignpost("This card changed on the remote — your edits still win")
|
||||||
|
center.post(error(.move(title: "Fix login")))
|
||||||
|
|
||||||
|
let rows = BannerCenter.rows(
|
||||||
|
lock: nil,
|
||||||
|
breakage: nil,
|
||||||
|
oneShots: center.oneShots,
|
||||||
|
suspension: nil,
|
||||||
|
operations: center.operations,
|
||||||
|
signposts: center.signposts
|
||||||
|
)
|
||||||
|
#expect(rows.count == 2)
|
||||||
|
#expect(rows.last?.tone == .info, "same tone as an in-progress row, opposite end of the strip")
|
||||||
|
#expect(rows.last?.isPinned == false, "calm by design — nothing is gated on seeing it instantly")
|
||||||
|
|
||||||
|
let dismissID = try #require(rows.last?.dismissID)
|
||||||
|
center.dismiss(dismissID)
|
||||||
|
#expect(center.signposts.isEmpty)
|
||||||
|
#expect(center.oneShots.count == 1, "dismissing a signpost leaves the failure standing")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A failed operation is an end plus a post — the row swaps for the error")
|
||||||
|
func failureSwapsTheRow() {
|
||||||
|
let center = BannerCenter()
|
||||||
|
let id = center.beginOperation(label: "Importing 'photo.png'…", cancel: nil)
|
||||||
|
center.endOperation(id)
|
||||||
|
center.post(error(.importAttachment(filename: "photo.png"), .unreadable(message: "the source file could not be read")))
|
||||||
|
|
||||||
|
let rows = BannerCenter.rows(
|
||||||
|
lock: nil,
|
||||||
|
breakage: nil,
|
||||||
|
oneShots: center.oneShots,
|
||||||
|
suspension: nil,
|
||||||
|
operations: center.operations
|
||||||
|
)
|
||||||
|
#expect(rows.count == 1)
|
||||||
|
#expect(rows[0].tone == .error)
|
||||||
|
#expect(rows[0].headline == "Couldn't import 'photo.png' — the source file could not be read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-field reference cell, so a `@Sendable` cancel closure has somewhere to record that it ran.
|
||||||
|
@MainActor
|
||||||
|
private final class Box {
|
||||||
|
var value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Phrasing
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BannerCenter ▸ phrasing")
|
||||||
|
struct BannerCenterPhrasingTests {
|
||||||
|
|
||||||
|
@Test("Every write operation has its own non-empty headline")
|
||||||
|
func everyOperationSaysSomethingDistinct() {
|
||||||
|
let headlines = everyOperation.map { BannerCenter.headline(for: error($0)) }
|
||||||
|
|
||||||
|
for (operation, headline) in zip(everyOperation, headlines) {
|
||||||
|
#expect(!headline.isEmpty, "\(operation) has no headline")
|
||||||
|
#expect(headline.hasPrefix("Couldn't "), "\(operation) does not name what failed")
|
||||||
|
#expect(!headline.contains("nil"), "\(operation) leaked an optional into the product's voice")
|
||||||
|
}
|
||||||
|
#expect(Set(headlines).count == headlines.count, "two operations share a sentence — one of them is wrong")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Titles are quoted when known and the phrasing stays graceful when they are not")
|
||||||
|
func titlesAreQuotedOrGracefullyAbsent() {
|
||||||
|
for (withTitle, withoutTitle) in titledOperations {
|
||||||
|
let named = BannerCenter.headline(for: error(withTitle))
|
||||||
|
let anonymous = BannerCenter.headline(for: error(withoutTitle))
|
||||||
|
|
||||||
|
#expect(named.contains("'Fix login'"), "\(withTitle) does not quote the title it carries")
|
||||||
|
#expect(!anonymous.contains("''"), "\(withoutTitle) rendered an empty pair of quotes")
|
||||||
|
#expect(anonymous.contains("the item"), "\(withoutTitle) should fall back to the kind-free noun")
|
||||||
|
#expect(named != anonymous)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The cause tail comes from the error's reason and nowhere else")
|
||||||
|
func causeTailCarriesTheDiagnosis() {
|
||||||
|
#expect(BannerCenter.headline(for: error(.move(title: "Fix login"), .io(message: "the disk is full")))
|
||||||
|
== "Couldn't move 'Fix login' — the disk is full")
|
||||||
|
|
||||||
|
// A trailing full stop in a diagnostic message must not survive into the middle of a line.
|
||||||
|
#expect(BannerCenter.headline(for: error(.createCard, .io(message: "no space left on device.")))
|
||||||
|
== "Couldn't create a card — no space left on device")
|
||||||
|
|
||||||
|
// The uneditable-frontmatter reason is the one whose own `description` is written for a
|
||||||
|
// developer, so the banner translates it rather than quoting it.
|
||||||
|
let uneditable = BannerCenter.headline(for: error(.style(title: "Odd"), .uneditableFrontmatter(.keyWithoutOwnLine)))
|
||||||
|
#expect(uneditable.hasPrefix("Couldn't restyle 'Odd' — "))
|
||||||
|
#expect(uneditable.contains("frontmatter"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every lock reason says what is wrong and that the view is still the last good one")
|
||||||
|
func lockHeadlinesReassure() {
|
||||||
|
let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot, .unwritableLocation]
|
||||||
|
let headlines = reasons.map(BannerCenter.headline(for:))
|
||||||
|
|
||||||
|
for headline in headlines {
|
||||||
|
#expect(headline.contains("last good view"))
|
||||||
|
#expect(headline.contains("read-only"))
|
||||||
|
}
|
||||||
|
#expect(Set(headlines).count == reasons.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Reload breakage carries fail-fast's specifics — the path and what is wrong with it")
|
||||||
|
func breakageHeadlineNamesThePath() {
|
||||||
|
let headline = BannerCenter.headline(
|
||||||
|
for: BoardLoadError(path: "todo/fix-login/index.md", reason: .unparseableYAML(message: "unexpected end", line: 4))
|
||||||
|
)
|
||||||
|
#expect(headline.contains("'todo/fix-login/index.md'"))
|
||||||
|
#expect(headline.contains("line 4"))
|
||||||
|
#expect(headline.contains("showing the last good view"))
|
||||||
|
|
||||||
|
// The board's own index.md reports as "." — a lone dot in the product's voice would be a
|
||||||
|
// bug report, not a sentence.
|
||||||
|
let rootHeadline = BannerCenter.headline(for: BoardLoadError(path: ".", reason: .boardRootMissingIndex))
|
||||||
|
#expect(!rootHeadline.contains("'.'"))
|
||||||
|
#expect(rootHeadline.hasPrefix("This board isn't loading"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The suspended-history line names the consequence, then the diagnosis")
|
||||||
|
func suspensionHeadlineNamesTheConsequence() {
|
||||||
|
#expect(BannerCenter.headline(for: HistorySuspension(reason: "the disk is full"))
|
||||||
|
== "Changes aren't being recorded to history — the disk is full")
|
||||||
|
#expect(BannerCenter.headline(for: HistorySuspension(reason: ""))
|
||||||
|
== "Changes aren't being recorded to history")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Store integration
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BannerCenter ▸ store integration")
|
||||||
|
struct BannerCenterStoreTests {
|
||||||
|
|
||||||
|
@Test("A failed write lands exactly one one-shot in the store's banners, and still throws")
|
||||||
|
func failedWritePostsOnce() throws {
|
||||||
|
let fixture = try makeBoardWithUneditableLane()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
// A genuine Writer refusal: the lane's frontmatter is a whole-frontmatter flow mapping, so
|
||||||
|
// the surgical editor cannot address it and the write fails loudly rather than corrupting
|
||||||
|
// the file (01-storage-format.md § Frontmatter).
|
||||||
|
var thrown: BoardWriteError?
|
||||||
|
do {
|
||||||
|
try store.performWrite { () throws(BoardWriteError) -> Void in
|
||||||
|
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane2), operation: .style(title: nil)) { _ in }
|
||||||
|
}
|
||||||
|
Issue.record("expected the write to fail")
|
||||||
|
} catch let failure as BoardWriteError {
|
||||||
|
thrown = failure
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = try #require(thrown)
|
||||||
|
#expect(store.banners.oneShots.count == 1, "the banner is posted once, not per layer")
|
||||||
|
#expect(store.banners.oneShots.first?.error == error, "the strip carries the same failure the caller saw")
|
||||||
|
|
||||||
|
// And it is the whole of what the strip shows: a healthy board with one unread failure.
|
||||||
|
#expect(store.bannerRows.count == 1)
|
||||||
|
#expect(store.bannerRows[0].tone == .error)
|
||||||
|
#expect(store.bannerRows[0].headline == "Couldn't restyle 'Odd' — this file's frontmatter can't be edited in place (a top-level key has no line of its own)")
|
||||||
|
#expect(store.bannerRows[0].dismissID != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A write refused by the read-only lock posts nothing — the lock row already stands")
|
||||||
|
func refusalUnderLockPostsNothing() throws {
|
||||||
|
let fixture = try makeBoardWithUneditableLane()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
store.enterVanishedRootLock()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try store.performWrite { () throws(BoardWriteError) -> Void in
|
||||||
|
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in }
|
||||||
|
}
|
||||||
|
Issue.record("expected the store to refuse the write")
|
||||||
|
} catch let refusal as BoardStoreWriteRefusal {
|
||||||
|
#expect(refusal == .readOnlyLocked(.vanishedRoot))
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(store.banners.oneShots.isEmpty, "a refusal is not a failed write; the lock row is the message")
|
||||||
|
#expect(store.bannerRows.count == 1)
|
||||||
|
guard case .readOnlyLock(.vanishedRoot) = store.bannerRows[0] else {
|
||||||
|
Issue.record("expected the lock row alone")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The store's rows compose its own conditions with the center's")
|
||||||
|
func bannerRowsComposeBothHalves() throws {
|
||||||
|
let fixture = try makeBoardWithUneditableLane()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.enterUnwritableLock()
|
||||||
|
store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full")))
|
||||||
|
store.banners.suspendHistory(reason: "the disk is full")
|
||||||
|
store.banners.beginOperation(label: "Duplicating…", cancel: nil)
|
||||||
|
store.banners.postSignpost("This card changed on the remote")
|
||||||
|
|
||||||
|
#expect(store.bannerRows.map(\.id) == [
|
||||||
|
"operation:\(store.banners.operations[0].id.uuidString)",
|
||||||
|
"read-only-lock",
|
||||||
|
"one-shot:\(store.banners.oneShots[0].id.uuidString)",
|
||||||
|
"history-suspension",
|
||||||
|
"signpost:\(store.banners.signposts[0].id.uuidString)",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -486,21 +486,40 @@ struct BoardStoreTests {
|
|||||||
|
|
||||||
// MARK: Root changes
|
// MARK: Root changes
|
||||||
|
|
||||||
@Test("A root change is accepted and leaves the last good snapshot alone")
|
@Test("A root change with no delegate wired leaves the last good snapshot alone")
|
||||||
func rootChangeIsAStubToday() async throws {
|
func rootChangeWithoutADelegateIsANoOp() async throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let lastGood = store.snapshot
|
let lastGood = store.snapshot
|
||||||
|
|
||||||
// Documented no-op until the registry's bookmark arrives: re-resolve-or-lock needs an
|
// Re-resolve-or-lock needs a bookmark this store does not own, so the response is
|
||||||
// identity this store does not own yet, and guessing would lock a board that merely moved.
|
// delegated (the registry wires it — `RootRecoveryTests` drives the real thing). With no
|
||||||
|
// delegate, the honest answer is the same one every failure path gives: keep the last good
|
||||||
|
// snapshot. Guessing here would lock a board that had merely moved.
|
||||||
store.handleWatcherEvent(.rootChanged)
|
store.handleWatcherEvent(.rootChanged)
|
||||||
await store.awaitQuiescence()
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
#expect(store.snapshot == lastGood)
|
#expect(store.snapshot == lastGood)
|
||||||
#expect(store.reloadFailure == nil)
|
#expect(store.reloadFailure == nil)
|
||||||
#expect(store.readOnlyLock == nil)
|
#expect(store.readOnlyLock == nil)
|
||||||
#expect(store.reloadGeneration == 0, "a root change schedules no reload today")
|
#expect(store.reloadGeneration == 0, "a root change schedules no reload of its own")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A root change with a delegate hands over and starts no reload of its own")
|
||||||
|
func rootChangeIsDelegated() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
var calls = 0
|
||||||
|
store.rootChangeDelegate = { calls += 1 }
|
||||||
|
store.handleWatcherEvent(.rootChanged)
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
|
#expect(calls == 1)
|
||||||
|
// The delegate's two outcomes both end in a reload — at the re-resolved root, or on the
|
||||||
|
// root's return. One fired from here would walk a path that just stopped being the board.
|
||||||
|
#expect(store.reloadGeneration == 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// The close-with-a-dirty-buffer state machine (02-architecture.md § Write-failure surfacing, "The
|
||||||
|
/// one modal moment on the write-failure path").
|
||||||
|
///
|
||||||
|
/// Everything specific is a closure, so these tests are about one thing only: **whether the close
|
||||||
|
/// may proceed**. Each of the four exits — the save that just works, the retry that works on the
|
||||||
|
/// second try, the copy saved elsewhere, and the deliberate discard — has to leave the guard
|
||||||
|
/// `.idle`, and the failing save has to leave it `.blocked` carrying the error the alert will
|
||||||
|
/// phrase.
|
||||||
|
|
||||||
|
// MARK: - Support
|
||||||
|
|
||||||
|
/// Stands in for an editor's dirty buffer: some text, a save that can be made to fail, and a record
|
||||||
|
/// of where things actually went.
|
||||||
|
@MainActor
|
||||||
|
private final class FakeBuffer {
|
||||||
|
var text: String
|
||||||
|
/// Non-`nil` makes the next save (and every save after it) fail.
|
||||||
|
var saveFailure: BoardWriteError?
|
||||||
|
/// Set when `writeCopy` should refuse — a save panel pointed at a full disk.
|
||||||
|
var copyFails = false
|
||||||
|
|
||||||
|
private(set) var saveAttempts = 0
|
||||||
|
private(set) var savedText: String?
|
||||||
|
private(set) var copies: [URL: String] = [:]
|
||||||
|
|
||||||
|
init(text: String = "the paragraph that exists nowhere else") {
|
||||||
|
self.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGuard() -> DirtyBufferGuard {
|
||||||
|
DirtyBufferGuard(
|
||||||
|
attemptSave: { [self] () throws(BoardWriteError) in
|
||||||
|
saveAttempts += 1
|
||||||
|
if let saveFailure {
|
||||||
|
throw saveFailure
|
||||||
|
}
|
||||||
|
savedText = text
|
||||||
|
},
|
||||||
|
writeCopy: { [self] url in
|
||||||
|
if copyFails {
|
||||||
|
throw CocoaError(.fileWriteOutOfSpace)
|
||||||
|
}
|
||||||
|
copies[url] = text
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let diskFull = BoardWriteError(
|
||||||
|
operation: .style(title: "Fix login"),
|
||||||
|
path: "/Boards/Work/todo/fix-login/index.md",
|
||||||
|
reason: .io(message: "the disk is full")
|
||||||
|
)
|
||||||
|
|
||||||
|
private func copyDestination() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory.appendingPathComponent("dirty-buffer-\(UUID().uuidString).md")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("DirtyBufferGuard")
|
||||||
|
struct DirtyBufferGuardTests {
|
||||||
|
|
||||||
|
@Test("A save that lands never blocks — the close proceeds with no modal at all")
|
||||||
|
func successfulSaveNeverBlocks() {
|
||||||
|
let buffer = FakeBuffer()
|
||||||
|
let bufferGuard = buffer.makeGuard()
|
||||||
|
|
||||||
|
#expect(bufferGuard.beginClose())
|
||||||
|
#expect(bufferGuard.phase == .idle)
|
||||||
|
#expect(buffer.savedText == "the paragraph that exists nowhere else")
|
||||||
|
#expect(buffer.saveAttempts == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A failing save blocks the close and carries the error the alert will phrase")
|
||||||
|
func failingSaveBlocks() {
|
||||||
|
let buffer = FakeBuffer()
|
||||||
|
buffer.saveFailure = diskFull
|
||||||
|
let bufferGuard = buffer.makeGuard()
|
||||||
|
|
||||||
|
#expect(!bufferGuard.beginClose())
|
||||||
|
#expect(bufferGuard.phase == .blocked(diskFull))
|
||||||
|
|
||||||
|
guard case let .blocked(error) = bufferGuard.phase else {
|
||||||
|
Issue.record("expected the blocked phase")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(BannerCenter.headline(for: error) == "Couldn't restyle 'Fix login' — the disk is full")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Retrying after the cause is fixed unblocks the close")
|
||||||
|
func retrySucceedsAndCloses() {
|
||||||
|
let buffer = FakeBuffer()
|
||||||
|
buffer.saveFailure = diskFull
|
||||||
|
let bufferGuard = buffer.makeGuard()
|
||||||
|
#expect(!bufferGuard.beginClose())
|
||||||
|
|
||||||
|
// A retry while the disk is still full stays blocked — the alert returns, which is the
|
||||||
|
// honest outcome and the reason there is no "close anyway" button.
|
||||||
|
#expect(!bufferGuard.retry())
|
||||||
|
#expect(bufferGuard.phase == .blocked(diskFull))
|
||||||
|
|
||||||
|
buffer.saveFailure = nil
|
||||||
|
#expect(bufferGuard.retry())
|
||||||
|
#expect(bufferGuard.phase == .idle)
|
||||||
|
#expect(buffer.savedText == "the paragraph that exists nowhere else")
|
||||||
|
#expect(buffer.saveAttempts == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Saving a copy elsewhere writes the text and unblocks the close")
|
||||||
|
func saveCopyWritesAndUnblocks() throws {
|
||||||
|
let buffer = FakeBuffer()
|
||||||
|
buffer.saveFailure = diskFull
|
||||||
|
let bufferGuard = buffer.makeGuard()
|
||||||
|
#expect(!bufferGuard.beginClose())
|
||||||
|
|
||||||
|
let destination = copyDestination()
|
||||||
|
try bufferGuard.saveCopy(to: destination)
|
||||||
|
|
||||||
|
#expect(bufferGuard.phase == .idle, "the text is safe somewhere; the close may proceed")
|
||||||
|
#expect(buffer.copies[destination] == "the paragraph that exists nowhere else")
|
||||||
|
// The buffer's real home is still unwritten — that is the trade the user knowingly made.
|
||||||
|
#expect(buffer.savedText == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A copy that itself fails leaves the close blocked")
|
||||||
|
func failedCopyStaysBlocked() {
|
||||||
|
let buffer = FakeBuffer()
|
||||||
|
buffer.saveFailure = diskFull
|
||||||
|
buffer.copyFails = true
|
||||||
|
let bufferGuard = buffer.makeGuard()
|
||||||
|
#expect(!bufferGuard.beginClose())
|
||||||
|
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try bufferGuard.saveCopy(to: copyDestination())
|
||||||
|
}
|
||||||
|
#expect(bufferGuard.phase == .blocked(diskFull), "the text is still nowhere but memory")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Discarding unblocks the close and writes nothing anywhere")
|
||||||
|
func discardUnblocks() {
|
||||||
|
let buffer = FakeBuffer()
|
||||||
|
buffer.saveFailure = diskFull
|
||||||
|
let bufferGuard = buffer.makeGuard()
|
||||||
|
#expect(!bufferGuard.beginClose())
|
||||||
|
|
||||||
|
bufferGuard.discard()
|
||||||
|
|
||||||
|
#expect(bufferGuard.phase == .idle)
|
||||||
|
#expect(buffer.savedText == nil)
|
||||||
|
#expect(buffer.copies.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// What happens to an open board when its folder moves, disappears, or comes back — the settled
|
||||||
|
/// root-identity rules of 02-architecture.md § Write-failure surfacing, exercised end to end
|
||||||
|
/// against a **real FSEvents stream, a real registry, and a real bookmark**.
|
||||||
|
///
|
||||||
|
/// A fake would prove nothing here. The three claims under test are all claims about the
|
||||||
|
/// filesystem's actual behaviour: that a bookmark follows a rename, that FSEvents reports the
|
||||||
|
/// *creation* of a path it was watching before that path existed, and that a `access(2)` probe is
|
||||||
|
/// what distinguishes "this board loads fine" from "this board can be written to". Every one of
|
||||||
|
/// them would be assumed rather than tested against a double.
|
||||||
|
///
|
||||||
|
/// The flakiness that buys is handled the way `FolderWatcherTests` handles it: **waiting for
|
||||||
|
/// something is generous** (poll for seconds — a slow machine must not fail a correctness test)
|
||||||
|
/// and nothing is asserted from an elapsed interval. The one test that needs no filesystem timing
|
||||||
|
/// at all — the writability clearing rule — drives the store's inbound door directly instead, so
|
||||||
|
/// its ordering is exact rather than merely likely.
|
||||||
|
|
||||||
|
// MARK: - Support
|
||||||
|
|
||||||
|
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||||
|
|
||||||
|
/// A small board: two lanes, one card. Enough that a reload landing at a new root has something
|
||||||
|
/// recognisable in it.
|
||||||
|
@MainActor
|
||||||
|
private func makeBoard(at root: URL) throws {
|
||||||
|
let manager = FileManager.default
|
||||||
|
try manager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
func write(_ relativePath: String, _ text: String) throws {
|
||||||
|
let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true)
|
||||||
|
try manager.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
try Data(text.utf8).write(to: folder.appendingPathComponent("index.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
try write("", Item.board)
|
||||||
|
try write(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try write("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
try write(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try makeBoard(at: fixture.root)
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a card by hand — a *foreign* write by construction: no Writer, no bracket, exactly what an
|
||||||
|
/// agent or an editor does.
|
||||||
|
private func writeCard(inBoard root: URL, lane: String, id: String, title: String, order: String) throws {
|
||||||
|
let folder = root.appendingPathComponent(lane, isDirectory: true).appendingPathComponent(id, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
try Data(Item.rich(order: order, title: title).utf8).write(to: folder.appendingPathComponent("index.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cardTitles(inLane id: String, of snapshot: BoardModel) -> [String] {
|
||||||
|
(snapshot.lanes.first { $0.id.rawValue == id }?.cards ?? []).compactMap(\.title.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one shape a path comparison may take here. A bookmark resolves to the canonical location
|
||||||
|
/// (`/private/var/...`) while `FileManager.temporaryDirectory` hands out the symlinked one
|
||||||
|
/// (`/var/...`), so raw `URL` equality would fail on a board that relocated perfectly.
|
||||||
|
private func canonical(_ url: URL) -> String {
|
||||||
|
url.resolvingSymlinksInPath().standardizedFileURL.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls until `condition` holds or the deadline passes — generous, because FSEvents delivery is
|
||||||
|
/// not a bounded-latency promise. A root change in particular can take several seconds to surface.
|
||||||
|
@MainActor
|
||||||
|
private func waitUntil(_ deadline: Duration = .seconds(15), _ condition: () -> Bool) async {
|
||||||
|
let start = ContinuousClock.now
|
||||||
|
while ContinuousClock.now - start < deadline {
|
||||||
|
if condition() { return }
|
||||||
|
try? await Task.sleep(for: .milliseconds(25))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gives a freshly started stream a beat to register with `fseventsd`, so the first change a test
|
||||||
|
/// makes cannot land in the window between `FSEventStreamStart` and the stream actually being live.
|
||||||
|
@MainActor
|
||||||
|
private func settle() async {
|
||||||
|
try? await Task.sleep(for: .milliseconds(400))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Root recovery")
|
||||||
|
struct RootRecoveryTests {
|
||||||
|
|
||||||
|
// MARK: Rename absorption
|
||||||
|
|
||||||
|
@Test("A rename is absorbed transparently: new root, no banner, no lock, wiring intact")
|
||||||
|
func renameIsAbsorbed() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let renamed = fixture.root
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("renamed-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
// Registered first so it runs *last*: the store is released — and its watcher stopped —
|
||||||
|
// before the folder it is watching is removed.
|
||||||
|
defer { try? FileManager.default.removeItem(at: renamed) }
|
||||||
|
|
||||||
|
let registry = BoardStoreRegistry()
|
||||||
|
let store = try registry.acquire(fixture.root)
|
||||||
|
defer { registry.release(store) }
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
// A Finder rename, which 01-storage-format.md calls ordinary. The board is the *file*, not
|
||||||
|
// the string that names it.
|
||||||
|
try FileManager.default.moveItem(at: fixture.root, to: renamed)
|
||||||
|
|
||||||
|
// The done-when: the store's URLs re-derived, and a reload actually ran at the new root
|
||||||
|
// (`snapshot.rootURL` is the root the last successful walk used).
|
||||||
|
await waitUntil { canonical(store.snapshot.rootURL) == canonical(renamed) }
|
||||||
|
#expect(canonical(store.rootURL) == canonical(renamed))
|
||||||
|
#expect(canonical(store.snapshot.rootURL) == canonical(renamed))
|
||||||
|
|
||||||
|
// Nothing was ever wrong, and the strip says so.
|
||||||
|
#expect(store.readOnlyLock == nil)
|
||||||
|
#expect(store.reloadFailure == nil)
|
||||||
|
#expect(store.bannerRows.isEmpty)
|
||||||
|
#expect(store.snapshot.lanes.count == 2, "the board is still the board")
|
||||||
|
|
||||||
|
// The entry followed too: identity did not change, so the same store answers for the new
|
||||||
|
// path and a window opening it would share rather than duplicate.
|
||||||
|
#expect(registry.liveStore(for: renamed) === store)
|
||||||
|
#expect(registry.openBoardCount == 1)
|
||||||
|
|
||||||
|
// And the wiring survived the move: a foreign edit at the *new* path reloads.
|
||||||
|
try writeCard(inBoard: renamed, lane: Ident.lane1, id: Ident.card2, title: "Second", order: "2048")
|
||||||
|
await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Second") }
|
||||||
|
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second"])
|
||||||
|
#expect(store.bannerRows.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Vanish and return
|
||||||
|
|
||||||
|
@Test("A vanished root locks the board read-only, and the root's return clears it")
|
||||||
|
func vanishAndReturn() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let registry = BoardStoreRegistry()
|
||||||
|
let store = try registry.acquire(fixture.root)
|
||||||
|
defer { registry.release(store) }
|
||||||
|
let lastGood = store.snapshot
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
// The folder is deleted in Finder while the board is open. Bookmark re-resolution finds
|
||||||
|
// nothing that exists, and the last-known path is gone too.
|
||||||
|
try FileManager.default.removeItem(at: fixture.root)
|
||||||
|
|
||||||
|
await waitUntil { store.readOnlyLock == .vanishedRoot }
|
||||||
|
#expect(store.readOnlyLock == .vanishedRoot)
|
||||||
|
|
||||||
|
// Wait for the vanished state to *settle* before recreating anything, and not for tidiness:
|
||||||
|
// entering the lock re-attaches the watcher at the missing path, and that re-attach owes a
|
||||||
|
// debounced reconciling reload. Recreating the folder inside that 200 ms window would let
|
||||||
|
// the reload find the root already back and clear the lock without a root change ever being
|
||||||
|
// delivered — a legitimate recovery, but a different one from the one under test here.
|
||||||
|
// Waiting for that reload to land and fail pins the sequence to the real-world shape: the
|
||||||
|
// root comes back later, and its *creation* is what recovers the board.
|
||||||
|
await waitUntil { store.reloadFailure != nil }
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
#expect(store.reloadFailure != nil, "the re-attach's reconciling reload fails against the missing root")
|
||||||
|
|
||||||
|
// The last-good snapshot is still on screen — that is the whole point of the lock.
|
||||||
|
#expect(store.snapshot == lastGood)
|
||||||
|
#expect(store.readOnlyLock == .vanishedRoot, "a failed reload never lifts the lock")
|
||||||
|
|
||||||
|
// And the strip leads with the lock. (The re-attach's reconciling reload fails against the
|
||||||
|
// missing root, so a breakage row stands behind it; the *lock* is what comes first.)
|
||||||
|
guard case .readOnlyLock(.vanishedRoot) = store.bannerRows.first else {
|
||||||
|
Issue.record("expected the lock row to lead, got \(store.bannerRows.map(\.id))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every write is refused — nothing would land anywhere.
|
||||||
|
do {
|
||||||
|
try store.performWrite { () throws(BoardWriteError) -> Void in
|
||||||
|
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in }
|
||||||
|
}
|
||||||
|
Issue.record("expected the locked board to refuse the write")
|
||||||
|
} catch let refusal as BoardStoreWriteRefusal {
|
||||||
|
#expect(refusal == .readOnlyLocked(.vanishedRoot))
|
||||||
|
}
|
||||||
|
#expect(store.banners.oneShots.isEmpty, "a refusal is not a failed write")
|
||||||
|
|
||||||
|
// The root returns: a Finder undo, a remount, a folder recreated where the board was. Built
|
||||||
|
// aside and moved into place in one step, so the path appears as a whole board rather than
|
||||||
|
// as a directory that is filled in over several reload debounces.
|
||||||
|
let staging = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("RootRecoveryTests-staging-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try makeBoard(at: staging)
|
||||||
|
try writeCard(inBoard: staging, lane: Ident.lane1, id: Ident.card3, title: "Third", order: "3072")
|
||||||
|
try FileManager.default.moveItem(at: staging, to: fixture.root)
|
||||||
|
|
||||||
|
// FSEvents was left watching the path precisely so this creation would be reported.
|
||||||
|
await waitUntil { store.readOnlyLock == nil }
|
||||||
|
#expect(store.readOnlyLock == nil, "a successful reload proves the root came back")
|
||||||
|
|
||||||
|
await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Third") }
|
||||||
|
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Third"])
|
||||||
|
#expect(store.reloadFailure == nil)
|
||||||
|
#expect(store.bannerRows.isEmpty)
|
||||||
|
|
||||||
|
// The recreated folder is a different file than the one that was deleted, so the entry had
|
||||||
|
// to be re-keyed — otherwise the next window to open this board would get a second store
|
||||||
|
// over a board already on screen.
|
||||||
|
await waitUntil { registry.liveStore(for: fixture.root) === store }
|
||||||
|
#expect(registry.liveStore(for: fixture.root) === store)
|
||||||
|
#expect(registry.openBoardCount == 1)
|
||||||
|
|
||||||
|
// And writes are live again.
|
||||||
|
try store.performWrite { () throws(BoardWriteError) -> Void in
|
||||||
|
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in }
|
||||||
|
}
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: The writability clearing rule
|
||||||
|
|
||||||
|
@Test("The unwritable-location lock clears only on a reconciling reload whose probe passes")
|
||||||
|
func unwritableLockClearsOnlyOnAReconcilingProbe() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
// The probe has to be honest, so the root is made genuinely unwritable — `r-x`, which still
|
||||||
|
// reads perfectly. That is the whole difficulty of this case: the board loads fine.
|
||||||
|
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
|
||||||
|
store.enterUnwritableLock()
|
||||||
|
#expect(store.readOnlyLock == .unwritableLocation)
|
||||||
|
|
||||||
|
// A foreign reload succeeds — and clears nothing. Loading proves nothing about writing,
|
||||||
|
// which is exactly why this lock's clearing rule is not the other two's.
|
||||||
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
#expect(store.reloadFailure == nil, "an unwritable root still reads")
|
||||||
|
#expect(store.readOnlyLock == .unwritableLocation)
|
||||||
|
|
||||||
|
// Neither does a reconciling one while the permission is still what it was.
|
||||||
|
store.handleWatcherEvent(.treeChanged(.reconciling))
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
#expect(store.readOnlyLock == .unwritableLocation)
|
||||||
|
|
||||||
|
// The permission is fixed. Nothing announces that — a `chmod` in a terminal fires no event
|
||||||
|
// the board would act on — so the lock stands until the next reconciling sweep (wake, app
|
||||||
|
// activation) re-probes.
|
||||||
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path)
|
||||||
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
#expect(store.readOnlyLock == .unwritableLocation, "only a reconciling reload re-probes")
|
||||||
|
|
||||||
|
store.handleWatcherEvent(.treeChanged(.reconciling))
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
#expect(store.readOnlyLock == nil, "a fixed permission clears the lock without ceremony")
|
||||||
|
#expect(store.bannerRows.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A reconciling reload that finds the root unwritable does not raise the lock by itself")
|
||||||
|
func theProbeOnlyClears() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
|
||||||
|
|
||||||
|
store.handleWatcherEvent(.treeChanged(.reconciling))
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
|
// Arming the lock is the open flow's job (m4). Inferring it from a probe here would be a
|
||||||
|
// policy decision this layer has not been asked to make — recorded as a test so the
|
||||||
|
// asymmetry is deliberate rather than forgotten.
|
||||||
|
#expect(store.readOnlyLock == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user