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,273 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The banner strip: one window's standing conditions, unread failures, and work in flight, as a
|
||||
/// stack of rows (02-architecture.md § The banner surface).
|
||||
///
|
||||
/// ### Tones, not components
|
||||
///
|
||||
/// There is one row layout and three colorings. An error, a warning, and an info row differ in
|
||||
/// symbol and tint and in nothing else — which is what lets the card window's remote-change
|
||||
/// signpost (07-sync-collab.md) be "this same component in the info tone" rather than a second
|
||||
/// thing that drifts. The per-kind affordances hang off the row's data, not off separate views: a
|
||||
/// dismiss control appears exactly where `BannerRow.dismissID` is non-`nil`, a spinner and its
|
||||
/// optional Cancel exactly where the row is `.inProgress`.
|
||||
///
|
||||
/// ### The collapse rule
|
||||
///
|
||||
/// "Beyond three rows the remainder collapse behind a '+N more' disclosure" — because the strip is
|
||||
/// window furniture above the board, and a board that has gone badly wrong must not disappear
|
||||
/// under its own error messages. Precedence ordering is what makes three the right number to show:
|
||||
/// `BannerCenter.rows(...)` has already put the lock, the breakage, and the newest unread failure
|
||||
/// at the top, so the collapsed remainder is always the least urgent tail.
|
||||
///
|
||||
/// **In-progress rows are exempt, and do not count toward the budget** (settled, 02): they are the
|
||||
/// strip's only explanation for a bracket's write lock and for a close or quit deferring teardown,
|
||||
/// and a copy row's Cancel has to stay reachable — "a spinner may never hide behind '+N more'".
|
||||
/// They are safe to pin because there are few at once and each clears itself. So the strip is
|
||||
/// always *every* pinned row, then at most three of the rest.
|
||||
///
|
||||
/// ### Deliberately self-contained
|
||||
///
|
||||
/// It takes rows and two callbacks; it reaches for no store and no environment. The board window
|
||||
/// (m4) hosts it over the lane area, the card window (m6) hosts its own, and the re-homing rule —
|
||||
/// a card window's condition moving to the board window's strip when it closes — is a question of
|
||||
/// which `BannerCenter` a row came from, never of this view.
|
||||
public struct BannerStripView: View {
|
||||
|
||||
private let rows: [BannerRow]
|
||||
private let onDismiss: (UUID) -> Void
|
||||
|
||||
/// How many rows show before the disclosure takes over.
|
||||
private static let collapseThreshold = 3
|
||||
|
||||
@State private var isExpanded = false
|
||||
|
||||
public init(rows: [BannerRow], onDismiss: @escaping (UUID) -> Void) {
|
||||
self.rows = rows
|
||||
self.onDismiss = onDismiss
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if !rows.isEmpty {
|
||||
VStack(spacing: 1) {
|
||||
ForEach(pinnedRows) { row in
|
||||
BannerRowView(row: row, onDismiss: onDismiss)
|
||||
}
|
||||
ForEach(visibleCollapsibleRows) { row in
|
||||
BannerRowView(row: row, onDismiss: onDismiss)
|
||||
}
|
||||
if hiddenCount > 0 {
|
||||
disclosure
|
||||
}
|
||||
}
|
||||
.background(.quaternary)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows that always show, in precedence order — in-progress rows, which `rows(...)` has
|
||||
/// already placed at the head, so rendering them first preserves that order rather than
|
||||
/// imposing a second one.
|
||||
private var pinnedRows: [BannerRow] {
|
||||
rows.filter(\.isPinned)
|
||||
}
|
||||
|
||||
private var collapsibleRows: [BannerRow] {
|
||||
rows.filter { !$0.isPinned }
|
||||
}
|
||||
|
||||
/// Everything collapsible when expanded or short enough, the first three otherwise. `prefix`
|
||||
/// and not a filter — precedence order is the whole point, so what shows is always a prefix of
|
||||
/// the ordered list.
|
||||
private var visibleCollapsibleRows: [BannerRow] {
|
||||
isExpanded || collapsibleRows.count <= Self.collapseThreshold
|
||||
? collapsibleRows
|
||||
: Array(collapsibleRows.prefix(Self.collapseThreshold))
|
||||
}
|
||||
|
||||
private var hiddenCount: Int {
|
||||
max(0, collapsibleRows.count - Self.collapseThreshold)
|
||||
}
|
||||
|
||||
private var disclosure: some View {
|
||||
Button {
|
||||
isExpanded.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.imageScale(.small)
|
||||
Text(isExpanded ? "Show fewer" : "+\(hiddenCount) more")
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.callout)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(.background.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - One row
|
||||
|
||||
/// A single banner row. Everything kind-specific is a branch on the row's data; the layout is one
|
||||
/// `HStack` for all five cases.
|
||||
private struct BannerRowView: View {
|
||||
|
||||
let row: BannerRow
|
||||
let onDismiss: (UUID) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
leading
|
||||
|
||||
Text(row.headline)
|
||||
.font(.callout)
|
||||
// Wrapping, never truncating: the cause tail ("— disk full") is the half that says
|
||||
// what to do about it, and a strip that hid it would be decoration.
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
trailingControls
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(row.tone.fill)
|
||||
// One element per row, tone included: a VoiceOver user must hear *that* this is an error
|
||||
// before hearing what the error is, and colour cannot carry that. The dismiss and Cancel
|
||||
// buttons survive as custom actions of the combined element rather than as separate stops
|
||||
// (10-accessibility.md; the announce-on-appear path arrives with that milestone).
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(Text("\(row.tone.accessibilityPrefix): \(row.headline)"))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var leading: some View {
|
||||
if case .inProgress = row {
|
||||
// The spinner replaces the symbol rather than joining it: an in-progress row's state
|
||||
// *is* "still going", and two glyphs saying so would be noise.
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: row.tone.symbolName)
|
||||
.foregroundStyle(row.tone.accent)
|
||||
.imageScale(.medium)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trailingControls: some View {
|
||||
if case let .inProgress(operation) = row, let cancel = operation.cancel {
|
||||
// Cancel appears on safe copies only (02, settled): it means "remove the partial copy,
|
||||
// nothing lost". Git brackets pass no closure and therefore get no button.
|
||||
Button("Cancel", action: cancel)
|
||||
.buttonStyle(.link)
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
if let dismissID = row.dismissID {
|
||||
Button {
|
||||
onDismiss(dismissID)
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.imageScale(.small)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Dismiss")
|
||||
.help("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tone rendering
|
||||
|
||||
private extension BannerTone {
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .error: "exclamationmark.triangle.fill"
|
||||
case .warning: "exclamationmark.circle.fill"
|
||||
case .info: "info.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var accent: Color {
|
||||
switch self {
|
||||
case .error: .red
|
||||
case .warning: .orange
|
||||
case .info: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
/// A wash, not a slab: the strip sits above the board and must read as furniture rather than as
|
||||
/// a second window. Info is deliberately the calmest of the three — "visually calm, no error
|
||||
/// colour" is the settled description of the signpost that shares this tone.
|
||||
var fill: some ShapeStyle {
|
||||
switch self {
|
||||
case .error: AnyShapeStyle(Color.red.opacity(0.12))
|
||||
case .warning: AnyShapeStyle(Color.orange.opacity(0.12))
|
||||
case .info: AnyShapeStyle(.background.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// What VoiceOver says before the headline. "Status" rather than "Info" because that is the
|
||||
/// word the platform uses for a non-alarming state announcement.
|
||||
var accessibilityPrefix: String {
|
||||
switch self {
|
||||
case .error: "Error"
|
||||
case .warning: "Warning"
|
||||
case .info: "Status"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
private func previewError(
|
||||
_ operation: WriteOperation,
|
||||
_ reason: BoardWriteError.Reason = .io(message: "the disk is full")
|
||||
) -> BoardWriteError {
|
||||
BoardWriteError(operation: operation, path: "/Users/x/Boards/Work/lane/index.md", reason: reason)
|
||||
}
|
||||
|
||||
#Preview("Single error") {
|
||||
BannerStripView(
|
||||
rows: [.oneShot(OneShotBanner(error: previewError(.move(title: "Fix login"))))],
|
||||
onDismiss: { _ in }
|
||||
)
|
||||
.frame(width: 520)
|
||||
}
|
||||
|
||||
#Preview("Stacked tones") {
|
||||
BannerStripView(
|
||||
rows: [
|
||||
.inProgress(InProgressOperation(label: "Pulling…")),
|
||||
.readOnlyLock(.vanishedRoot),
|
||||
.oneShot(OneShotBanner(error: previewError(.delete(title: "Ship the beta")))),
|
||||
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
|
||||
.signpost(InfoSignpost(message: "This card changed on the remote — your edits still win")),
|
||||
],
|
||||
onDismiss: { _ in }
|
||||
)
|
||||
.frame(width: 520)
|
||||
}
|
||||
|
||||
/// Seven rows, one of them pinned: the strip shows the spinner plus the first three of the rest,
|
||||
/// and "+3 more" counts only what actually collapsed.
|
||||
#Preview("Collapse") {
|
||||
BannerStripView(
|
||||
rows: [
|
||||
.inProgress(InProgressOperation(label: "Importing 24 attachments…", cancel: {})),
|
||||
.readOnlyLock(.bracketedReloadFailed),
|
||||
.reloadBreakage(BoardLoadError(path: "todo/index.md", reason: .missingOrder)),
|
||||
.oneShot(OneShotBanner(error: previewError(.move(title: "Fix login")))),
|
||||
.oneShot(OneShotBanner(error: previewError(.style(title: "Design review")))),
|
||||
.oneShot(OneShotBanner(error: previewError(.renumberChildren))),
|
||||
.oneShot(OneShotBanner(error: previewError(.importAttachment(filename: "photo.png"),
|
||||
.unreadable(message: "the source file could not be read")))),
|
||||
],
|
||||
onDismiss: { _ in }
|
||||
)
|
||||
.frame(width: 520)
|
||||
}
|
||||
@@ -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