Files
lanework/Kanban/LiveStore/DirtyBufferGuard.swift
T
rzen 747dea552d 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
2026-07-26 20:41:48 -04:00

133 lines
6.2 KiB
Swift

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
}
}