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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user