Files
lanework/Kanban/App/BoardWindowHost.swift
T
rzen 0933ac1b01 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
2026-08-01 09:53:26 -04:00

512 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
import os
// MARK: - BoardWindowHost
/// One board window: the thing that owns a board's session for as long as it is on screen
/// (02-architecture.md § Windows, § Launch and window lifecycle).
///
/// ### It is a lifecycle, not a layout
///
/// Almost everything here is about beginning and ending: acquiring the shared store, stamping the
/// registry, holding the board's security-scoped access, remembering the window's frame, and running
/// the close flush before any of it is let go. The board *itself* — the lane strip and everything in
/// 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 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
/// with the failed-restoration row. This window dismisses itself and welcome comes up.
struct BoardWindowHost: View {
let ref: BoardWindowRef
@Environment(AppModel.self) private var appModel
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
/// The transient search strip's arrival and departure has a reduced variant like every other
/// appearance in the app (10-accessibility.md; `Motion.transientSearchTransition`).
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// The window's own controller — `@State` so it outlives body evaluations and so SwiftUI keeps it
/// alive for exactly as long as this window exists.
@State private var windowController = HostedWindowController()
/// This window's board popover, open or not (03-board-ui.md § Board popover). `@State` for the
/// window controller's reason — one per window, living exactly as long as the window — which is
/// also what makes ⌘I mean "the board in front" rather than "some board": the flag reaches the
/// menu item through the focus system, like the store.
@State private var boardInfo = BoardInfoPresentation()
/// This window's purge alert, open or not (03-board-ui.md § Trash). `@State` for `boardInfo`'s
/// reason and reaching the menu bar the same way: File ▸ Delete (landing on a trash selection)
/// and Empty Trash… are menu-bar items, and a menu item cannot present anything of its own.
@State private var trashConfirmations = TrashConfirmations()
/// How Board ▸ Open Card reaches this window's card windows. `@State` for `boardInfo`'s reason,
/// and published the same way: a menu item has no window of its own, and only this view holds
/// the board half of a card window's `(board, card)` identity — see `CardOpener`.
@State private var cardOpener = CardOpener()
/// This window's toolbar search field, as a handle (`BoardSearchPresentation`). `@State` for
/// `boardInfo`'s reason — one per window — and published the same way, because Edit ▸ Find ⌘F
/// and the caret-chord commands are menu-bar items that have to reach the frontmost board
/// 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 {
case opening
case open(BoardStore)
/// The load failed; this window is on its way out and must not try again.
case failed
}
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-window")
var body: some View {
content
// Font-derived like everything else the board lays out (`BoardMetrics.windowMinimumSize`,
// 10-accessibility.md's full-relative-scaling rule): at a large system text size a
// 640×400 floor would be narrower than two lane headers, and "every lane is always on
// screen" would degrade into a strip of truncation.
.frame(
minWidth: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).width,
minHeight: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).height
)
.background(WindowAccessor(controller: windowController))
.navigationTitle(windowTitle)
.task { await beginOpening() }
.onDisappear { endSessionIfStillOpen() }
}
@ViewBuilder
private var content: some View {
switch phase {
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) {
BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) }
// **⌘F's fallback**, and only that: the search field's home is the toolbar item
// (`BoardToolbar`), and this strip exists for the window where the user has taken
// that item out — "with the field removed from the toolbar, invoking it surfaces the
// field transiently until the search clears" (03-board-ui.md ▸ Toolbar). It sits
// directly under the title bar, where the item it stands in for would be.
if boardSearch.isTransient {
BoardSearchBar(store: store, presentation: boardSearch)
.transition(Motion.transientSearchTransition(reduced: reduceMotion))
}
// The window is handed to the board as a closure, not a value: `WindowAccessor`
// attaches after this body first runs, and the lane-resize drag needs the *live*
// window to grow at its right edge (03-board-ui.md § Lane).
//
// `openCard` is the host's too, for a different reason — see the property below.
BoardView(
store: store,
window: { windowController.window },
confirmations: trashConfirmations,
openCard: openCard,
search: boardSearch
)
}
// The transient strip's two dismissal inputs (`BoardSearchPresentation
// .transientPersists`): it stays while a query is filtering the board or while the field
// holds the keyboard, and goes when neither is true.
.onChange(of: store.searchQuery) { _, query in
boardSearch.dismissTransientIfCleared(query: query)
}
.onChange(of: boardSearch.isFocused) { _, _ in
boardSearch.dismissTransientIfCleared(query: store.searchQuery)
}
// "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
// beside it the window's own popover flag, which is what File ▸ Board Info toggles, its
// purge-alert host, which the trash's two confirmed commands raise, and its search
// field, which Edit ▸ Find focuses and the caret-chord commands yield to.
.focusedSceneValue(\.boardStore, store)
.focusedSceneValue(\.boardSearch, boardSearch)
// The window's identity beside its store — File ▸ Duplicate flushes a *session*, which
// is keyed on the window rather than on the board it is showing.
.focusedSceneValue(\.boardWindowRef, ref)
.focusedSceneValue(\.boardInfo, boardInfo)
.focusedSceneValue(\.trashConfirmations, trashConfirmations)
// Board ▸ Open Card's second half — the same closure `BoardView` gets, so the menu item
// and the double-click open one window per card by construction.
.focusedSceneValue(\.cardOpener, cardOpener)
}
}
/// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it,
/// so "at most one card window per card (reopen focuses)" needs no bookkeeping here
/// (02-architecture.md § Windows).
private var openCard: (ItemID) -> Void {
{ cardID in
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
}
}
private var windowTitle: String {
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** 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 }
// 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
// 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
// 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) {
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)
// 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)
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
// open, and it must not mint this board's bookmark again (02 § Per-board app state, "one
// bookmark per open board").
appModel.boardRegistry.syncDisplayState(
id: recordID,
title: AppModel.displayName(of: store),
icon: store.snapshot.icon.value,
iconColor: store.snapshot.iconColor.value
)
appModel.boardRegistry.setOpenNow(id: recordID)
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
phase = .open(store)
configureWindow(store: store, recordID: recordID)
// "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 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 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)
}
windowController.onFrameChanged = { frame in
appModel.boardRegistry.updateWindowFrame(
id: recordID,
frame: WindowFrame(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height)
)
}
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 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(
id: recordID,
title: AppModel.displayName(of: store),
icon: store.snapshot.icon.value,
iconColor: store.snapshot.iconColor.value
)
}
// **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)
windowController.closeAfterFlush()
}
}
// This window's answer to "what does ⌘Z act on" (13-native-undo.md ▸ Rules; 06 ▸ Undo
// routing) — the *session's* stack, read afresh on every ask so a torn-down board answers
// nothing rather than a stack with no board behind it. The Edit menu's Undo/Redo rows and
// the toolbar's pair are nil-target `undo:`/`redo:`, so this one line is what lights them
// up: `NSWindow` validates and crosses them against exactly this manager.
windowController.windowUndoManager = { appModel.session(for: ref)?.undoManager }
// The window-title widget (03-board-ui.md § Board popover) — **board windows only**, which
// is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and
// card windows share that machinery and have no board to describe. It goes in after the
// load rather than at attach because it carries the store; the controller installs it once,
// whichever of the two arrives second.
//
// The tier and the git state come from the **session**, which `start()` began a moment ago,
// rather than from the entitlement or the disk: a board's popover must describe the board as
// it opened (12-editions.md ▸ The entitlement, "an open board finishes with the provider it
// composed"; 06-history-undo.md ▸ Rules, mode is an open-time fact). A `nil` session cannot
// happen on this path — `beginSession` precedes `configureWindow` — and reads as the free
// tier's posture, which is the harmless direction.
let session = appModel.session(for: ref)
windowController.installTitlebarAccessory(
boardInfoTitlebarAccessory(
store: store,
recents: appModel.styleRecents,
tier: session?.tier ?? .free,
git: session?.git,
presentation: boardInfo
)
)
// The widget above now says the board's name (and, on a git-mode Pro board, its branch)
// itself, so the system title display would only repeat it — the card-window seam
// (`CardWindowHost.configureWindow`, `HostedWindowController.hideTitle`), applied here for
// 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
// accessory's reason exactly: it carries the store, and it is a board window's, not every
// hosted window's. Its search item is the search field's home, and it is what tells
// `boardSearch` whether that home still exists.
windowController.installToolbar(BoardToolbar.controller(store: store, search: boardSearch))
}
// MARK: - Closing
/// The safety net behind the close interception.
///
/// `windowShouldClose` covers ⌘W, File ▸ Close and the red button — every way a *user* closes a
/// window. It does not cover a window torn down some other way (a programmatic dismiss, a scene
/// SwiftUI decides to end), and a board whose session outlived its window would leave a watcher
/// running over nothing. So the disappear runs the same sequence; `AppModel.closeBoard` is
/// idempotent precisely so these two can both fire without the flush running twice.
///
/// Deliberately **not** the quit path: quit is `AppDelegate`'s, and it must complete before the
/// app exits rather than in a task nobody waits for.
private func endSessionIfStillOpen() {
guard appModel.session(for: ref) != nil else { return }
Task { @MainActor in
await appModel.closeBoard(ref: ref, cause: .userClose)
}
}
}
// 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()
}
}