Files
lanework/Kanban/App/BoardWindowHost.swift
T
rzen ba1726fa77 The loader collects every fail-fast defect and honors per-open skips
Phase 1 of the decision surface (01 ▸ Malformed input, settled
2026-07-31): BoardLoadFailure aggregates the walk's defects in walk
order — stop-at-first retires. Environmental failures (unreadable root,
not-a-directory) stay immediate single-defect throws: there is no walk
to collect from. A defective root index is recorded and the walk
continues into the children (nothing in the walk consults the parsed
root document — verified); a defective lane, card, or trash-entry index
records and skips its subtree, Re-check's whole-walk re-aggregation
being the designed loop for what hides beneath. load(skipping:) is the
per-open skip channel: a skipped path's item is omitted from the model
and surfaces as LoadWarning.userSkipped; root paths are unskippable by
construction. The reload-breakage banner carries the aggregate ("…and
N more"), single-defect sentences byte-identical to before. Two new
multi-defect fixture boards; suite 2591 green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 09:12:49 -04:00

337 lines
18 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.
///
/// ### 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
/// 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()
@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 start() }
.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.
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 "" }
return AppModel.displayName(of: store)
}
// MARK: - Opening
/// 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.
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)
let store: BoardStore
do throws(BoardLoadFailure) {
store = try appModel.storeRegistry.acquire(url)
} catch {
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
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
}
// 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)
}
/// 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
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.
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)
)
}
// 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.
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
)
}
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.
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)
}
}
}