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