Every open passes through a real loading window — the walk moves off the main actor

Phase 2 of the decision surface: the pre-snapshot loading state ruled
2026-07-29 (02 § Launch and window lifecycle), built. The board window
appears immediately at its saved frame, titled with the registry
record's cached name, its content a centered spinner behind an
injectable ~200ms grace — no skeletons, and the first snapshot snaps in
place. The tree walk runs off-main via BoardStoreRegistry.acquireOffMain
(per-board single-flight keyed by file identity — concurrent opens of
one root share a walk, restoration of many boards is genuinely
parallel), landing in BoardStore's new designated init(rootURL:loaded:);
the self-walking init survives as a convenience for its ~470 callers.

⌘W during the walk is real: configureWindow split into a loading half
(frame restore, frame tracking, close interception — installed before
the walk) and a store half (toolbar, widget, hideTitle, undo — installed
at the snap), and the walk lives in an explicitly held BoardOpenWalk so
the user's close and SwiftUI's teardown end in one cancel().
Cancellation is discard-on-completion: nil from acquireOffMain means
nothing was built, nothing retained, and no open-now flag was ever set.
Failure keeps today's sequence exactly: record the launch failure,
welcome's row carries it, the window retires.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 09:53:26 -04:00
parent ba1726fa77
commit 0933ac1b01
8 changed files with 795 additions and 41 deletions
+204 -29
View File
@@ -15,9 +15,23 @@ import os
/// it is `BoardView`'s (03-board-ui.md); this file hands it the store and the window and stays out
/// of the way.
///
/// ### Every open passes through a loading state
///
/// The window appears **immediately** welcome click, File Open, Finder double-click,
/// restoration alike at its saved frame, its title carrying the registry record's cached name,
/// and its content area holding `BoardLoadingView` until the first snapshot lands
/// (02-architecture.md § Launch and window lifecycle, ruled 2026-07-29). The walk that produces
/// that snapshot runs **off the main actor** (`BoardStoreRegistry.acquireOffMain`), so a board of
/// any size opens as a live window rather than as a beachball, and every restored window walks
/// independently of every other.
///
/// The window is therefore real, and closeable, before it has a store: W during the walk cancels
/// it and closes the window. That is why `configureWindow` is in two halves see
/// `configureLoadingWindow(recordID:)`.
///
/// ### Failure opens welcome, on a row that already exists
///
/// A board that will not load has nothing to show, so its window never appears. But its registry
/// A board that will not load has nothing to show, so its window retires. But its registry
/// record is created **before** the load runs (02-architecture.md § Per-board app state, "a first
/// open that fails fail-fast still records"), so the failure that joins `AppModel.launchFailures`
/// always has a recents row waiting for it `WelcomeRow.derive` matches the two by path, uniform
@@ -59,6 +73,19 @@ struct BoardWindowHost: View {
/// window's field.
@State private var boardSearch = BoardSearchPresentation()
/// The pre-snapshot surface's grace clock (02 § Launch and window lifecycle). `@State` for
/// `boardInfo`'s reason one per window, living exactly as long as the window.
@State private var loading = BoardLoadingIndicator()
/// The open walk, so W can cancel it by name rather than by waiting for SwiftUI's teardown to
/// get around to it.
@State private var openWalk = BoardOpenWalk()
/// This board's registry record, from the moment `recordOpen` mints it which is what the
/// loading window's title reads (`Self.loadingTitle`). `nil` only for the one body evaluation
/// that precedes `start()`.
@State private var recordID: UUID?
@State private var phase: Phase = .opening
private enum Phase {
@@ -82,16 +109,21 @@ struct BoardWindowHost: View {
)
.background(WindowAccessor(controller: windowController))
.navigationTitle(windowTitle)
.task { await start() }
.task { await beginOpening() }
.onDisappear { endSessionIfStillOpen() }
}
@ViewBuilder
private var content: some View {
switch phase {
case .opening, .failed:
// Nothing to render and nothing worth animating: this window either becomes a board in a
// moment or dismisses itself.
case .opening:
// **The pre-snapshot loading state** (02 § Launch and window lifecycle): empty for the
// grace, a centered spinner after it, never a skeleton. The board replaces it in place
// when `phase` becomes `.open` a snap, which is what assigning outside `withAnimation`
// means here.
BoardLoadingView(indicator: loading)
case .failed:
// Nothing to render and nothing worth animating: this window is dismissing itself.
Color.clear
case let .open(store):
VStack(spacing: 0) {
@@ -154,22 +186,63 @@ struct BoardWindowHost: View {
}
private var windowTitle: String {
guard case let .open(store) = phase else { return "" }
guard case let .open(store) = phase else {
return Self.loadingTitle(
record: recordID.flatMap { appModel.boardRegistry.record(id: $0) },
url: ref.url
)
}
return AppModel.displayName(of: store)
}
/// What a window that has no snapshot yet is called **the registry record's cached name**, the
/// same no-scan source the welcome row reads (02 § Launch and window lifecycle: "its chrome
/// carrying the registry record's cached title and icon"; § Per-board app state, "the welcome row
/// reads only the record it never opens any board's `index.md`").
///
/// Read back off the record rather than recomputed, so a board that has opened before shows the
/// title it is known by and a first-ever open shows the folder name which is that record's
/// provisional display name, not a second rule. The `nil` fallback is the folder name anyway,
/// covering only the body evaluation that precedes `recordOpen`.
///
/// Static and pure so the rule is checkable without a window (`BoardWindowHostTests`).
static func loadingTitle(record: BoardRecord?, url: URL) -> String {
record?.displayName ?? AppModel.folderDisplayName(of: url)
}
// MARK: - Opening
/// Starts the open as a task of its own, so something can hold it.
///
/// `.task` cancels on teardown but hands out no handle, and W during loading needs one *by
/// name* the ruled cancel is an act of the user's, not a consequence of a window that has
/// already gone away (02 § Launch and window lifecycle). So the walk runs in a task `openWalk`
/// keeps, and the cancellation handler forwards `.task`'s own cancellation into it, leaving both
/// routes the user's W and any teardown SwiftUI decides on ending in the same `cancel()`.
private func beginOpening() async {
let walk = Task { await start() }
openWalk.adopt(walk)
await withTaskCancellationHandler {
await walk.value
} onCancel: {
walk.cancel()
}
}
/// Acquires the board and starts its session, or fails it out to welcome.
///
/// The order is load-bearing, and it now has one more step than the load itself does. Security-
/// scoped access is claimed **before** anything else, because the record and the load both need
/// it. The registry record comes **before** `acquire` "the registry record is created before
/// loading" (02 § Per-board app state) so a fail-fast failure always has a row to land on;
/// `acquire`'s own first act is the tree walk, and a sandboxed read outside the claimed scope is
/// exactly the one that gets refused. `setOpenNow` comes **after** the load succeeds and after
/// the window has demonstrably opened a flag set on a board that never appeared would hand the
/// next launch a restoration set describing a failure.
/// it. The registry record comes **before** the walk "the registry record is created before
/// loading" (02 § Per-board app state) so a fail-fast failure always has a row to land on, and
/// so the loading window has a name to wear; the walk's first act is a directory read, and a
/// sandboxed read outside the claimed scope is exactly the one that gets refused. `setOpenNow`
/// comes **after** the load succeeds and after the window has demonstrably opened a flag set on
/// a board that never appeared would hand the next launch a restoration set describing a failure.
///
/// **The walk is the one suspension here**, and everything before it is what makes the window
/// real while it runs: the record, the loading window's chrome, and the grace clock. Everything
/// after it is the snap.
private func start() async {
guard case .opening = phase else { return }
@@ -182,12 +255,31 @@ struct BoardWindowHost: View {
// name, both `recordOpen`'s own rule now. This is also this open's one bookmark mint: a
// successful load below replaces the name through `syncDisplayState`, which never re-mints.
let recordID = appModel.boardRegistry.recordOpen(of: url)
// Published to the view *before* the walk: this is what the title bar reads while loading.
self.recordID = recordID
// The window is on screen and the user can act on it from here on placed where they left
// it, and closeable.
configureLoadingWindow(recordID: recordID)
loading.begin()
let store: BoardStore
do throws(BoardLoadFailure) {
store = try appModel.storeRegistry.acquire(url)
guard let acquired = try await appModel.storeRegistry.acquireOffMain(url) 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
// open's scoped access. There is no open-now flag to clear: `setOpenNow` is below,
// 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()
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)
@@ -200,6 +292,8 @@ struct BoardWindowHost: View {
return
}
loading.end()
// 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
@@ -222,21 +316,36 @@ struct BoardWindowHost: View {
dismissWindow(id: WindowID.welcome)
}
/// Wires the window: the saved frame on the way in, frame changes on the way back out, the
/// close interception that makes the flush unavoidable, and the title-bar widget.
private func configureWindow(store: BoardStore, recordID: UUID) {
// Filled in here rather than at declaration because the closure captures `openWindow`, an
// environment action; until the board has loaded there is also nothing for Open Card to act
// on, which is exactly what the item's `nil` check reads.
cardOpener.open = openCard
/// **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
/// `configureWindow(store:recordID:)` runs after it.
///
/// Three things, and each is a rule from § Launch and window lifecycle:
///
/// - **The saved frame**, so the window appears "at its saved frame" rather than at the system's
/// cascade and then jumping to the user's place a second later.
/// - **The frame changes**, so a window the user moves *while it loads* is remembered. Not
/// store-dependent and so not worth deferring the alternative is a slow board's window whose
/// move is silently discarded.
/// - **The close interception**, which is what makes W during loading mean anything at all. It
/// is replaced wholesale by the flushing version once the board is open (see below); a single
/// closure branching on `phase` would be the same thing spelled as a state read.
///
/// The title bar keeps AppKit's own title display for now the string is the record's cached
/// name (`windowTitle`) and `hideTitle()` follows only once the board-popover widget is there
/// to say the name instead. Hiding it here would leave a loading window with no name anywhere in
/// its chrome, which is precisely what 02 asks the loading state to carry.
private func configureLoadingWindow(recordID: UUID) {
windowController.onAttach = { window in
guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return }
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
}
// The window may already be attached `viewDidMoveToWindow` fires well before this task's
// load returns so the placement is applied directly too rather than waiting for a callback
// that has already happened.
// The window may already be attached `viewDidMoveToWindow` fires before this task's first
// suspension so the placement is applied directly too rather than waiting for a callback
// that has already happened. The closure stays installed either way: the controller re-fires
// it if SwiftUI swaps the provisional window for the real one (`HostedWindowController
// .detach`).
if let window = windowController.window {
windowController.onAttach?(window)
}
@@ -248,12 +357,39 @@ struct BoardWindowHost: View {
)
}
windowController.onCloseRequested = {
// **W during the walk** (02 § Launch and window lifecycle: "the walk is cancellable:
// W during loading cancels it and closes the window"). The window closes *now* there
// is no store, so there is nothing to flush and nothing to wait for and the walk's
// tail is wasted work we accept rather than thread a cancellation flag through the
// loader (`BoardStoreRegistry.acquireOffMain`, discard-on-completion).
//
// No open-now flag is cleared here because none was ever set: `setOpenNow` runs after
// the load, so a board that never finished loading is not in the restoration set. The
// ordinary user-close *does* clear it, in `AppModel.closeBoard`, which is the path the
// replacement closure below takes.
openWalk.cancel()
windowController.closeAfterFlush()
}
}
/// Wires the rest of the window, once there is a board to wire it to: the store's write-through,
/// the close flush, the undo stack, the title-bar widget and the toolbar.
///
/// Everything here **carries the store or the session**, which is the whole reason it waits for
/// them; the window-level half ran before the walk (`configureLoadingWindow(recordID:)`).
private func configureWindow(store: BoardStore, recordID: UUID) {
// Filled in here rather than at declaration because the closure captures `openWindow`, an
// environment action; until the board has loaded there is also nothing for Open Card to act
// on, which is exactly what the item's `nil` check reads.
cardOpener.open = openCard
// The registry's live write-through (02-architecture.md § Per-board app state) wired
// the same way `onFrameChanged` just was: a closure that reaches into the registry,
// captured weakly on both sides so neither the store nor this closure's own home keeps
// the other alive past its window. `syncDisplayState` in `start()` already stamped the
// values current as of this open, so nothing is fired here immediately; this only fires
// on the reloads that follow.
// the way `onFrameChanged` was a moment ago in the loading half: a closure that reaches into
// the registry, captured weakly on both sides so neither the store nor this closure's own
// home keeps the other alive past its window. `syncDisplayState` in `start()` already
// stamped the values current as of this open, so nothing is fired here immediately; this
// only fires on the reloads that follow.
store.displayStateDelegate = { [weak appModel, weak store] in
guard let appModel, let store else { return }
appModel.boardRegistry.syncDisplayState(
@@ -264,6 +400,10 @@ struct BoardWindowHost: View {
)
}
// **Replacing the loading half's cancel-and-close**: from here the window has a session, so
// a close is the flush (02 § Windows, "Close flushes") and the user-close that clears the
// open-now flag. A slot rather than a branch `onCloseRequested` is one closure, and the
// window that owns it has moved on.
windowController.onCloseRequested = {
Task { @MainActor in
await appModel.closeBoard(ref: ref, cause: .userClose)
@@ -306,6 +446,11 @@ struct BoardWindowHost: View {
// the same reason. `.navigationTitle(windowTitle)` a few lines up in `body` is untouched
// `window.title` keeps feeding the Window menu, Exposé, VoiceOver and restoration; only the
// title bar's own rendering of that string is suppressed.
//
// **After the load, and only after it**, which is why it is not in the loading half above:
// this line and the widget it defers to are one exchange, and a loading window that hid its
// title before the widget existed would carry no name at all against 02's "its chrome
// carrying the registry record's cached title".
windowController.hideTitle()
// The board's customizable toolbar (03-board-ui.md Toolbar) installed here for the
@@ -334,3 +479,33 @@ struct BoardWindowHost: View {
}
}
}
// MARK: - BoardOpenWalk
/// The open walk's handle, held for exactly one reason: **W during loading has to be able to cancel
/// it** (02-architecture.md § Launch and window lifecycle).
///
/// A one-field box rather than the `Task` itself in `@State`, because the thing that cancels it is a
/// closure the window controller holds (`onCloseRequested`) and the thing that fills it is the
/// `.task` that starts the walk two places that must agree on one task, which is what a reference
/// type is. `@MainActor` like everything else on this path, so the box needs no synchronisation of
/// its own.
///
/// Cancelling does not stop the walk (see `BoardStoreRegistry.acquireOffMain` for why the walk is
/// deliberately not cooperatively cancellable). It stops the *open*: the task that would have
/// adopted the result never does.
@MainActor
final class BoardOpenWalk {
private var task: Task<Void, Never>?
init() {}
func adopt(_ task: Task<Void, Never>) {
self.task = task
}
func cancel() {
task?.cancel()
}
}