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
133 lines
6.2 KiB
Swift
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
|
|
}
|
|
}
|