The decision surface — a refused open becomes a live repair, in place
Phase 3 of the decision surface, completing the card (01 ▸ Malformed input, settled 2026-07-31). An attended open's fail-fast walk transforms the loading window's content into one aggregated surface — never a sheet, never a chain: defects grouped by class, each class stated once with its files listed (Reveal in Finder + Open in Editor per row), a class-level default preselected, per-item override behind a disclosure. Only honest choices: YAML and malformed-schema get Editor + Re-check (Skip below the root); newer-than-app gets Skip alone and blocks the board at the root; the two root repairs — minted index, schema: 1 stamp — are defaults. Repair and Open applies fixes in one store-less write bracket and re-walks: clean proceeds, remainder re-aggregates into the same surface. Cancel and ⌘W retire to welcome's row; restored opens never see the surface at all (OpenOrigin rides the PendingOpen carrier). Skips are per-open consent that rides the session — the store retains the skip set and every reload passes it — and the opened board posts a warning-tone notice naming what was left out, each item's Reveal riding the banner strip's new reveal control. On Pro boards the repair bracket binds its own EchoLedger, heal-marks everything, and the store adopts it before the committer starts, so repairs land as one separate commit authored Lanework Integrity — pinned end to end. Also fixed en route: a retired loading window left its close interception installed and returned false from windowShouldClose forever, blocking quit. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
+85
-15
@@ -190,6 +190,32 @@ public final class ScopedAccess {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OpenOrigin
|
||||
|
||||
/// **Whether a person asked for this board right now** (01-storage-format.md § Malformed input, the
|
||||
/// decision surface, settled 2026-07-31):
|
||||
///
|
||||
/// > It appears on **attended opens only** (welcome click, File ▸ Open…, Finder): restoration
|
||||
/// > failures keep the retire-to-welcome-row landing, and the row's retry click is the attended open
|
||||
/// > that then shows the surface — repair is an attended act, and launch never chains dialogs.
|
||||
///
|
||||
/// So this is not a description of *where* an open came from — it is the one bit that decides what a
|
||||
/// failed one does. A closed two-case vocabulary rather than a `Bool` because the sentence a reader
|
||||
/// needs at the branch is "restored boards retire", not "`isAttended` is false".
|
||||
///
|
||||
/// **Attended is the default everywhere**, and that is load-bearing: welcome's rows, File ▸ Open…,
|
||||
/// the Finder open, Duplicate's follow-on open and the template chooser's are all somebody clicking
|
||||
/// something. Exactly one caller says otherwise — launch restoration (`RestoreBootstrapView`) — which
|
||||
/// makes "did a person ask for this" a question one place answers rather than a flag every call site
|
||||
/// has to get right.
|
||||
public enum OpenOrigin: Sendable, Equatable {
|
||||
/// A person just asked for this board.
|
||||
case attended
|
||||
/// Launch restoration reopening what was open last time. Nobody is waiting on it, and a failure
|
||||
/// lands on welcome's row rather than in a surface.
|
||||
case restored
|
||||
}
|
||||
|
||||
// MARK: - AppModel
|
||||
|
||||
/// The app's one piece of cross-window state: which boards are open, which card windows belong to
|
||||
@@ -595,15 +621,31 @@ public final class AppModel {
|
||||
|
||||
// MARK: Pending opens
|
||||
|
||||
/// The security-scoped URL a board window is about to be built from, stashed between
|
||||
/// `openBoard(at:)` and the host's first appearance.
|
||||
/// Everything `openBoard(at:origin:)` knows that a `BoardWindowRef` cannot carry.
|
||||
///
|
||||
/// The handoff exists because a window value has to be `Codable` and a scoped URL is not a
|
||||
/// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone
|
||||
/// unless something carried it across. The host claims it on appear; an unclaimed entry (a window
|
||||
/// that never opened) leaks one scope until quit, which is the cheapest failure available here.
|
||||
/// **One struct rather than two parallel dictionaries** (the shape this replaced was
|
||||
/// `pendingAccess` alone): both facts are stashed by the same call, claimed by the same call, and
|
||||
/// meaningless apart — a window that found an origin but no access, or the reverse, would be a
|
||||
/// bug with no honest reading. Keeping them in one value makes "they are always in step" true by
|
||||
/// construction instead of by two `removeValue`s that must not drift.
|
||||
private struct PendingOpen {
|
||||
/// The security-scoped URL the board will be built from, or `nil` where the open needed no
|
||||
/// scope. See `ScopedAccess`: a `URL` rebuilt from `ref.path` grants nothing.
|
||||
let access: ScopedAccess?
|
||||
/// Whether a person asked for this board — what a failed open branches on (`OpenOrigin`).
|
||||
let origin: OpenOrigin
|
||||
}
|
||||
|
||||
/// What a board window is about to be built from, stashed between `openBoard(at:origin:)` and the
|
||||
/// host's first appearance.
|
||||
///
|
||||
/// The handoff exists because a window value has to be `Codable` and neither of these is a
|
||||
/// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone,
|
||||
/// and the origin was never in the ref at all. The host claims both on appear; an unclaimed entry
|
||||
/// (a window that never opened) leaks one scope until quit, which is the cheapest failure
|
||||
/// available here.
|
||||
@ObservationIgnored
|
||||
private var pendingAccess: [BoardWindowRef: ScopedAccess] = [:]
|
||||
private var pendingOpens: [BoardWindowRef: PendingOpen] = [:]
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model")
|
||||
|
||||
@@ -677,8 +719,19 @@ public final class AppModel {
|
||||
/// **Called before any scene has appeared, and that's fine.** A cold launch's Finder-open can
|
||||
/// reach here before `windowOpener` is captured; the URL joins `pendingOpenURLs` and this same
|
||||
/// method runs again for it once `captureWindowActions` has something to open it with.
|
||||
public func openBoard(at url: URL) {
|
||||
///
|
||||
/// - Parameter origin: whether a person asked for this board right now (`OpenOrigin`) — the one
|
||||
/// bit a *failed* open branches on (01-storage-format.md § Malformed input: the decision
|
||||
/// surface "appears on attended opens only"). `.attended` by default, which is every caller but
|
||||
/// launch restoration: welcome's rows, File ▸ Open…, the Finder open, the template chooser's
|
||||
/// follow-on, Duplicate's. The default is the rule stated once rather than repeated five times.
|
||||
public func openBoard(at url: URL, origin: OpenOrigin = .attended) {
|
||||
guard let windowOpener else {
|
||||
// **The queue carries no origin, and needs none**: it is reachable only *before any scene
|
||||
// exists*, which is the cold-launch Finder/URL open and nothing else — restoration
|
||||
// captures the window actions as its first act (`RestoreBootstrapView.restore`) and so can
|
||||
// never queue. Everything in here is therefore attended, which is what the replay's
|
||||
// default gives it.
|
||||
pendingOpenURLs.append(url)
|
||||
return
|
||||
}
|
||||
@@ -689,11 +742,22 @@ public final class AppModel {
|
||||
}
|
||||
|
||||
let ref = BoardWindowRef(url: url)
|
||||
stashPendingOpen(for: ref, url: url, origin: origin)
|
||||
windowOpener(id: WindowID.board, value: ref)
|
||||
}
|
||||
|
||||
/// Stashes what the window about to appear will claim — the handoff's write half.
|
||||
///
|
||||
/// A method of its own rather than two lines inside `openBoard(at:origin:)` because that method
|
||||
/// cannot run without SwiftUI's `OpenWindowAction`, which is not a thing a test can construct: the
|
||||
/// carrier would otherwise be the one part of the attendance plumbing with no headless proof, and
|
||||
/// an origin that quietly stopped travelling would look exactly like the app before this
|
||||
/// milestone.
|
||||
func stashPendingOpen(for ref: BoardWindowRef, url: URL, origin: OpenOrigin) {
|
||||
// Replacing a stash for the same ref would strand the old scope; there is no such case today
|
||||
// (an unopened window's ref is not reachable), but stopping the loser is free.
|
||||
pendingAccess.removeValue(forKey: ref)?.stop()
|
||||
pendingAccess[ref] = ScopedAccess(url)
|
||||
windowOpener(id: WindowID.board, value: ref)
|
||||
pendingOpens.removeValue(forKey: ref)?.access?.stop()
|
||||
pendingOpens[ref] = PendingOpen(access: ScopedAccess(url), origin: origin)
|
||||
}
|
||||
|
||||
/// Shows — or focuses — the welcome window. Its own scene id, so this works with no windows at
|
||||
@@ -748,10 +812,16 @@ public final class AppModel {
|
||||
sessions[ref]
|
||||
}
|
||||
|
||||
/// Claims the scoped URL `openBoard(at:)` stashed for this window, or `nil` if it opened by some
|
||||
/// other route. Claiming removes it: the session owns the balance from here.
|
||||
func claimPendingAccess(for ref: BoardWindowRef) -> ScopedAccess? {
|
||||
pendingAccess.removeValue(forKey: ref)
|
||||
/// Claims what `openBoard(at:origin:)` stashed for this window. Claiming removes it: the session
|
||||
/// owns the scope's balance from here.
|
||||
///
|
||||
/// A window that opened by some other route — a route that never went through `openBoard` — gets
|
||||
/// no scope and reads as **attended**, which is the safe direction: the worst an attended reading
|
||||
/// can do to a failed open is offer the user a repair they did not ask for, where the reverse
|
||||
/// would silently retire a board somebody just double-clicked.
|
||||
func claimPendingOpen(for ref: BoardWindowRef) -> (access: ScopedAccess?, origin: OpenOrigin) {
|
||||
guard let pending = pendingOpens.removeValue(forKey: ref) else { return (nil, .attended) }
|
||||
return (pending.access, pending.origin)
|
||||
}
|
||||
|
||||
/// Starts a board's session — the board window's host calls this once its load has succeeded.
|
||||
|
||||
@@ -86,10 +86,39 @@ struct BoardWindowHost: View {
|
||||
/// that precedes `start()`.
|
||||
@State private var recordID: UUID?
|
||||
|
||||
/// This open's security-scoped access, claimed in `start()` and held until the session takes it
|
||||
/// over or the open ends.
|
||||
///
|
||||
/// `@State` rather than a local in `start()` because the decision surface outlives that call: a
|
||||
/// repair *writes into the board*, and a scope released when `start()` returned would be released
|
||||
/// exactly before the one write that needs it. Every exit balances it — the session adopts it,
|
||||
/// or Cancel and the failure path stop it.
|
||||
@State private var access: ScopedAccess?
|
||||
|
||||
/// Whether a person asked for this board (`OpenOrigin`) — claimed beside the access, and read by
|
||||
/// exactly one branch: what a failed walk does.
|
||||
@State private var origin: OpenOrigin = .attended
|
||||
|
||||
/// The board's URL as this open resolved it — the scoped one where there is one. Held for the
|
||||
/// surface's sake, which re-walks and repairs against it long after `start()` has returned.
|
||||
@State private var boardURL: URL?
|
||||
|
||||
/// **The repair bracket's ledger**, held between Repair and Open's writes and the store that the
|
||||
/// following walk builds (`BoardRepairRun`, `EchoLedger.adopt`).
|
||||
///
|
||||
/// It cannot live anywhere else: the repairs run before a store exists and the receipts have to
|
||||
/// reach that store's ledger before `beginSession` composes Pro's committer, or the app's own
|
||||
/// repair commits as `Lanework External`. Cleared once adopted.
|
||||
@State private var repairLedger: EchoLedger?
|
||||
|
||||
@State private var phase: Phase = .opening
|
||||
|
||||
private enum Phase {
|
||||
case opening
|
||||
/// **The walk refused and a person is looking at it** — the decision surface, in the loading
|
||||
/// window's own content area (01-storage-format.md § Malformed input, settled 2026-07-31:
|
||||
/// "the loading content transforms in place, never a sheet over a spinner").
|
||||
case deciding(BoardDecisionSurfaceModel)
|
||||
case open(BoardStore)
|
||||
/// The load failed; this window is on its way out and must not try again.
|
||||
case failed
|
||||
@@ -122,6 +151,15 @@ struct BoardWindowHost: View {
|
||||
// when `phase` becomes `.open` — a snap, which is what assigning outside `withAnimation`
|
||||
// means here.
|
||||
BoardLoadingView(indicator: loading)
|
||||
case let .deciding(model):
|
||||
// **In place.** The same content area the spinner was in, with no transition of its own:
|
||||
// 02's snap, read for the surface that arrives instead of a snapshot.
|
||||
BoardDecisionSurface(
|
||||
model: model,
|
||||
onRepairAndOpen: { repairAndOpen(model) },
|
||||
onRecheck: { recheck(model) },
|
||||
onCancel: { cancelDecision(model, closingWindow: false) }
|
||||
)
|
||||
case .failed:
|
||||
// Nothing to render and nothing worth animating: this window is dismissing itself.
|
||||
Color.clear
|
||||
@@ -246,9 +284,13 @@ struct BoardWindowHost: View {
|
||||
private func start() async {
|
||||
guard case .opening = phase else { return }
|
||||
|
||||
// Claimed even on the failure path: an unclaimed stash is a scope nobody balances.
|
||||
let access = appModel.claimPendingAccess(for: ref)
|
||||
let url = access?.url ?? ref.url
|
||||
// Claimed even on the failure path: an unclaimed stash is a scope nobody balances. The origin
|
||||
// rides along — one claim, one dictionary (`AppModel.claimPendingOpen`).
|
||||
let claimed = appModel.claimPendingOpen(for: ref)
|
||||
access = claimed.access
|
||||
origin = claimed.origin
|
||||
let url = claimed.access?.url ?? ref.url
|
||||
boardURL = url
|
||||
|
||||
// Record before load (settled, 02 § Per-board app state). `displayName` is omitted — an
|
||||
// existing record's cached title survives untouched, and a brand-new one takes the folder
|
||||
@@ -263,9 +305,23 @@ struct BoardWindowHost: View {
|
||||
configureLoadingWindow(recordID: recordID)
|
||||
loading.begin()
|
||||
|
||||
await attemptOpen(url: url, recordID: recordID, skipping: [])
|
||||
}
|
||||
|
||||
/// **One walk, and what it lands in** — the loop Repair and Open and Re-check re-enter.
|
||||
///
|
||||
/// It is a method rather than the tail of `start()` because the surface's two buttons "re-run the
|
||||
/// whole walk" (01-storage-format.md § Malformed input) and must land in exactly the places this
|
||||
/// lands: a clean walk proceeds into the ordinary open, and a walk that still refuses
|
||||
/// re-aggregates into the *same* surface. Sharing the body is what makes "never a chained second
|
||||
/// dialog" structural rather than remembered.
|
||||
///
|
||||
/// - Parameter skipping: the surface's consented skips, empty on a first attempt. It reaches the
|
||||
/// walk *and* the store, which retains it for the session (`BoardStore.skippedPaths`).
|
||||
private func attemptOpen(url: URL, recordID: UUID, skipping: Set<String>) async {
|
||||
let store: BoardStore
|
||||
do throws(BoardLoadFailure) {
|
||||
guard let acquired = try await appModel.storeRegistry.acquireOffMain(url) else {
|
||||
guard let acquired = try await appModel.storeRegistry.acquireOffMain(url, skipping: skipping) else {
|
||||
// ⌘W landed while the walk was running, and the walk has now finished into a result
|
||||
// nobody wants (`acquireOffMain`, discard-on-completion). The window is already
|
||||
// closing and the registry kept nothing, so the only thing left to balance is this
|
||||
@@ -273,27 +329,39 @@ struct BoardWindowHost: View {
|
||||
// after the load, so a cancelled open never set one — the very reason it lives there.
|
||||
Self.logger.debug("board open cancelled during its walk")
|
||||
loading.end()
|
||||
access?.stop()
|
||||
releaseAccess()
|
||||
return
|
||||
}
|
||||
store = acquired
|
||||
} catch {
|
||||
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
|
||||
loading.end()
|
||||
access?.stop()
|
||||
phase = .failed
|
||||
appModel.recordLaunchFailure(path: ref.path, message: error.description)
|
||||
// The record above just changed the registry — welcome, about to appear, must not
|
||||
// render the stale list `AppModel` cached before this open began, or the failure would
|
||||
// fall through to the unmatched-failures list for want of a row that already exists.
|
||||
appModel.refreshRecents()
|
||||
openWindow(id: WindowID.welcome)
|
||||
dismissWindow(id: WindowID.board, value: ref)
|
||||
handleWalkFailure(error, url: url, recordID: recordID)
|
||||
return
|
||||
}
|
||||
|
||||
// **The window retired while this walk was in flight** — Cancel (or ⌘W) pressed during a
|
||||
// Re-check, whose walk then landed successfully. The board must not open behind a window that
|
||||
// has already gone to welcome, and the reference `acquireOffMain` took has to go back or the
|
||||
// registry would hold a watcher for a board nobody is showing.
|
||||
if case .failed = phase {
|
||||
Self.logger.debug("a walk landed after the open was cancelled — releasing it")
|
||||
appModel.storeRegistry.release(store)
|
||||
releaseAccess()
|
||||
return
|
||||
}
|
||||
|
||||
loading.end()
|
||||
|
||||
// **The repair's receipts, into the board's own ledger — before the session composes**
|
||||
// (01: "On Pro boards the repairs drop heal-marked receipts and commit separately as one
|
||||
// repair commit"). `beginSession` is where Pro's committer is built and started, and the
|
||||
// committer harvests the ledger it is handed; receipts adopted after that line would be
|
||||
// receipts the repair commit never sees, and the app's own repair would be authored
|
||||
// `Lanework External`.
|
||||
if let repairLedger {
|
||||
store.echoes.adopt(repairLedger)
|
||||
self.repairLedger = nil
|
||||
}
|
||||
|
||||
// The load succeeded — the frontmatter can be trusted now, so it replaces whatever
|
||||
// provisional or stale name the record above was carrying. Through `syncDisplayState`,
|
||||
// deliberately not a second `recordOpen`: this is a display-state refresh, not a second
|
||||
@@ -307,15 +375,210 @@ struct BoardWindowHost: View {
|
||||
)
|
||||
appModel.boardRegistry.setOpenNow(id: recordID)
|
||||
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
|
||||
// The session owns the balance from here (`AppModel.beginSession`), so this window must not
|
||||
// stop it on any later path.
|
||||
access = nil
|
||||
phase = .open(store)
|
||||
|
||||
configureWindow(store: store, recordID: recordID)
|
||||
postSkipNoticeIfNeeded(store: store, skipping: skipping)
|
||||
|
||||
// "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
|
||||
// when welcome is not open, which is the ordinary case.
|
||||
dismissWindow(id: WindowID.welcome)
|
||||
}
|
||||
|
||||
/// **The attendance branch** (01-storage-format.md § Malformed input, settled 2026-07-31): the
|
||||
/// surface "appears on attended opens only … restoration failures keep the retire-to-welcome-row
|
||||
/// landing".
|
||||
///
|
||||
/// Three outcomes, and the third is the new one:
|
||||
///
|
||||
/// - **A restored open retires**, exactly as it did before this milestone. Nobody is sitting in
|
||||
/// front of a launch that reopened four boards, and "launch never chains dialogs".
|
||||
/// - **An attended open whose failure is environmental retires too** — a root that is gone, or a
|
||||
/// root that is a file. There is nothing on disk to repair, so a surface would offer the user a
|
||||
/// decision with no choices in it; welcome's row says the same thing in one line, which is
|
||||
/// where it belonged already. (The carve-out the ruling implies rather than states — Redesign
|
||||
/// Gap 87cd782a.)
|
||||
/// - **Anything else transforms into the surface**, in place, in this window.
|
||||
///
|
||||
/// A surface is *entered* rather than shown: it holds this window's access, its record, and its
|
||||
/// URL for as long as the user is deciding, which is why none of the three is released here.
|
||||
private func handleWalkFailure(_ error: BoardLoadFailure, url: URL, recordID: UUID) {
|
||||
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
|
||||
|
||||
// Retired while this walk ran (Cancel during a Re-check): the failure has already been
|
||||
// recorded and the window is on its way out. A second `retire` would post the row twice.
|
||||
if case .failed = phase { return }
|
||||
|
||||
guard Self.landing(for: error, origin: origin) == .decide else {
|
||||
loading.end()
|
||||
retire(message: error.description)
|
||||
return
|
||||
}
|
||||
|
||||
// Already deciding: this is a Re-check or a Repair and Open landing, and it re-aggregates in
|
||||
// place — the same model, the same window, no second dialog.
|
||||
if case let .deciding(model) = phase {
|
||||
model.reaggregate(error)
|
||||
model.isWorking = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.end()
|
||||
phase = .deciding(BoardDecisionSurfaceModel(failure: error, boardRoot: url))
|
||||
// ⌘W now means Cancel (01: the surface's own exit), replacing the loading half's
|
||||
// cancel-the-walk closure — a slot rather than a branch, `configureWindow`'s posture.
|
||||
windowController.onCloseRequested = {
|
||||
guard case let .deciding(model) = phase else { return }
|
||||
cancelDecision(model, closingWindow: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a refused walk lands.
|
||||
enum FailureLanding: Equatable {
|
||||
/// Welcome, on the board's own recents row — today's landing, unchanged.
|
||||
case retire
|
||||
/// The decision surface, in this window.
|
||||
case decide
|
||||
}
|
||||
|
||||
/// **The attendance branch as a pure rule** — two facts in, one landing out, provable without a
|
||||
/// window (`BoardDecisionSurfaceTests`).
|
||||
///
|
||||
/// It is a static rather than an `if` inside `handleWalkFailure` because it is the ruling's own
|
||||
/// sentence and the one thing about this milestone that a regression would make silently wrong:
|
||||
/// a launch that started showing surfaces would chain dialogs across four restored boards, and an
|
||||
/// attended open that stopped showing one would look exactly like the app before this milestone.
|
||||
static func landing(for failure: BoardLoadFailure, origin: OpenOrigin) -> FailureLanding {
|
||||
guard origin == .attended else { return .retire }
|
||||
return isEnvironmental(failure) ? .retire : .decide
|
||||
}
|
||||
|
||||
/// **Nothing on disk to repair** — the environmental carve-out's predicate.
|
||||
///
|
||||
/// A single defect, and that defect is a fact about the *root itself* rather than about a file
|
||||
/// inside it: `BoardLoader` throws these immediately, before any walk, precisely because there is
|
||||
/// nothing to walk. The single-defect check is stated rather than assumed — the loader's
|
||||
/// environmental throws are single by construction, and a future aggregate carrying one *among*
|
||||
/// repairable defects should show the surface, because the rest of it is still actionable.
|
||||
static func isEnvironmental(_ failure: BoardLoadFailure) -> Bool {
|
||||
guard failure.defects.count == 1 else { return false }
|
||||
switch failure.primary.reason {
|
||||
case .unreadableRoot, .notADirectory:
|
||||
return true
|
||||
case .boardRootMissingIndex, .unparseableYAML, .missingSchema, .malformedSchema,
|
||||
.schemaNewerThanApp, .missingOrder, .malformedOrder:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The surface's three buttons
|
||||
|
||||
/// **Repair and Open**: apply every chosen fix in one write bracket, then re-run the whole walk
|
||||
/// with the skip set (01-storage-format.md § Malformed input).
|
||||
///
|
||||
/// The repairs are store-less by necessity and heal-marked by rule (`BoardRepairRun`); the walk
|
||||
/// that follows is the ordinary one, so a clean result proceeds into the ordinary open and a
|
||||
/// dirty one re-aggregates here. A repair that failed does not abort anything — the batch stops,
|
||||
/// the failure shows on the surface, and the walk runs anyway, because "a partial repair simply
|
||||
/// re-aggregates on the next walk".
|
||||
private func repairAndOpen(_ model: BoardDecisionSurfaceModel) {
|
||||
guard let url = boardURL, let recordID, !model.isWorking else { return }
|
||||
model.isWorking = true
|
||||
model.repairFailure = nil
|
||||
|
||||
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: url)
|
||||
model.repairFailure = outcome.failure
|
||||
// Held for the store the walk below may build — see `repairLedger`. Merged with anything an
|
||||
// earlier pass left, so two rounds of repair both reach the commit.
|
||||
if let existing = repairLedger {
|
||||
existing.adopt(outcome.ledger)
|
||||
} else {
|
||||
repairLedger = outcome.ledger
|
||||
}
|
||||
|
||||
let walk = Task { await attemptOpen(url: url, recordID: recordID, skipping: model.skipSet) }
|
||||
openWalk.adopt(walk)
|
||||
}
|
||||
|
||||
/// **Re-check**: re-run the whole walk with the current skip set, changing nothing on disk
|
||||
/// (01: "a disk changed underneath re-aggregates into the *same* surface with the fresh defect
|
||||
/// list … a clean walk proceeds to the first snapshot").
|
||||
private func recheck(_ model: BoardDecisionSurfaceModel) {
|
||||
guard let url = boardURL, let recordID, !model.isWorking else { return }
|
||||
model.isWorking = true
|
||||
let walk = Task { await attemptOpen(url: url, recordID: recordID, skipping: model.skipSet) }
|
||||
openWalk.adopt(walk)
|
||||
}
|
||||
|
||||
/// **Cancel** — and ⌘W, which means exactly this while the surface is up (01: "**Cancel** aborts
|
||||
/// the open: the window retires and the board lands row-level on welcome, record-before-load
|
||||
/// unchanged").
|
||||
///
|
||||
/// It is the failure path's own sequence, run against the aggregate the surface was showing: the
|
||||
/// record was minted before the walk, so the row is already waiting for this message.
|
||||
///
|
||||
/// - Parameter closingWindow: true when AppKit asked (⌘W), where the interception has to be
|
||||
/// released for the close to complete. The button's own press dismisses through SwiftUI.
|
||||
private func cancelDecision(_ model: BoardDecisionSurfaceModel, closingWindow: Bool) {
|
||||
// A walk may still be in flight behind the surface (Cancel during a Re-check). Its landing is
|
||||
// guarded by `phase`, which this sets to `.failed`; cancelling as well means the open is not
|
||||
// merely ignored but abandoned.
|
||||
openWalk.cancel()
|
||||
retire(message: model.failure.description)
|
||||
if closingWindow {
|
||||
windowController.closeAfterFlush()
|
||||
}
|
||||
}
|
||||
|
||||
/// The retire-to-welcome landing, in one place: the sequence a failed restore has always had, and
|
||||
/// the sequence Cancel now shares with it.
|
||||
private func retire(message: String) {
|
||||
releaseAccess()
|
||||
phase = .failed
|
||||
// **The interception has to go with the phase.** `windowShouldClose` returns `false` whenever
|
||||
// a closure is installed — that is how the close flush gets its turn — so a retired window
|
||||
// whose closure had nothing left to do would *refuse every close request for the rest of the
|
||||
// app's life*, quit included. There is nothing to intercept once this window is on its way to
|
||||
// welcome: no store, no session, no decision.
|
||||
windowController.onCloseRequested = nil
|
||||
appModel.recordLaunchFailure(path: ref.path, message: message)
|
||||
// The record minted before the walk just changed the registry — welcome, about to appear,
|
||||
// must not render the stale list `AppModel` cached before this open began, or the failure
|
||||
// would fall through to the unmatched-failures list for want of a row that already exists.
|
||||
appModel.refreshRecents()
|
||||
openWindow(id: WindowID.welcome)
|
||||
dismissWindow(id: WindowID.board, value: ref)
|
||||
}
|
||||
|
||||
private func releaseAccess() {
|
||||
access?.stop()
|
||||
access = nil
|
||||
}
|
||||
|
||||
/// **The skip notice** (01: "the opened board carries a warning-tone notice naming the skipped
|
||||
/// items, each with Reveal in Finder").
|
||||
///
|
||||
/// Written from the walk's own `LoadWarning.userSkipped` entries rather than from the surface's
|
||||
/// skip set, deliberately: what the notice owes the user is what actually left the board, and a
|
||||
/// skip for a defect that repaired itself between the decision and the walk names nothing.
|
||||
///
|
||||
/// **Only the open that carried the skips posts it.** A second window onto the same board
|
||||
/// acquires the store that already exists, whose warnings still describe the first open — and a
|
||||
/// notice repeated per window would be the app reporting one decision twice. `skipping` is
|
||||
/// non-empty for exactly the open that made the decision.
|
||||
private func postSkipNoticeIfNeeded(store: BoardStore, skipping: Set<String>) {
|
||||
guard !skipping.isEmpty else { return }
|
||||
let root = store.rootURL
|
||||
let items = store.loadWarnings.compactMap { warning -> RevealTarget? in
|
||||
guard case let .userSkipped(path) = warning else { return nil }
|
||||
return RevealTarget(path: path, url: root.appendingPathComponent(path))
|
||||
}
|
||||
store.banners.postSkippedOnOpen(items)
|
||||
}
|
||||
|
||||
/// **The half of the wiring a window needs before it has a board** — everything here is about
|
||||
/// the *window*, and nothing here mentions the store, which is exactly the split
|
||||
/// 02-architecture.md's loading state forces: this runs before the walk, and
|
||||
|
||||
@@ -88,7 +88,12 @@ struct RestoreBootstrapView: View {
|
||||
for board in appModel.boardRegistry.restorables() {
|
||||
switch board {
|
||||
case let .available(_, url):
|
||||
appModel.openBoard(at: url)
|
||||
// **The one restored open in the app** (01-storage-format.md § Malformed input, the
|
||||
// decision surface): a board that fails here keeps today's retire-to-welcome-row
|
||||
// landing — "launch never chains dialogs", and nobody is sitting in front of a
|
||||
// restoration waiting to repair four boards at once. The row's retry click is the
|
||||
// attended open that then shows the surface.
|
||||
appModel.openBoard(at: url, origin: .restored)
|
||||
attempted += 1
|
||||
case let .unavailable(record):
|
||||
Self.logger.error("a flagged board could not be restored — its bookmark no longer resolves")
|
||||
@@ -109,9 +114,11 @@ struct RestoreBootstrapView: View {
|
||||
///
|
||||
/// **Which board is the launch arguments' to say** (`UITestLaunch.variant`), and this method does
|
||||
/// not care: the malformed variant is built and opened exactly like the other two, and its
|
||||
/// failure arrives one layer down as the *loader's* — a board window that records fail-fast's own
|
||||
/// sentence and dismisses itself (`BoardWindowHost.start`). Special-casing it here would replace
|
||||
/// the sentence under test with a sentence about the fixture.
|
||||
/// failure arrives one layer down as the *loader's*. It opens **attended**, like every other
|
||||
/// board a person asks for, so its refusal transforms the loading window into the decision surface
|
||||
/// (`BoardWindowHost.handleWalkFailure`) rather than retiring — which is precisely what the
|
||||
/// fail-fast UI pass is there to see. Special-casing it here would replace the behaviour under
|
||||
/// test with a behaviour about the fixture.
|
||||
///
|
||||
/// **A failure to *build* lands on welcome as an ordinary launch failure**, with the fixture's own
|
||||
/// path on it. That is deliberate: a suite whose fixture failed to build would otherwise audit an
|
||||
|
||||
Reference in New Issue
Block a user