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