Files
lanework/Kanban/UI/DirtyBufferAlert.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

74 lines
3.3 KiB
Swift

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