Files
lanework/Kanban/App/BoardWindowHost.swift
T
rzen cfee4a4b41 The board learns to zoom — eight rungs on one ruler, and Actual Size is the untouched board
View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight
rungs, app-wide and persisted (the Show Comments precedent) — a viewing
comfort, not a property of any one board. The level travels as
BoardZoomContext in the environment, injected on BoardView alone so the
banner strip, search bar, sheets and popovers stay at the system size; the
environment is also what carries it through CardFaceView's equality gate,
which compares nothing that moves with the level. Every BoardMetrics figure
follows zoom.bodyPointSize — card and lane chrome, drag replicas and the
count badge, the resize handle, the trash column — and the drop registry
carries the ruler for event-time reads, with the autoscroller's three
reaches turning font-derived (reachSide named as the stripGap it always
equalled). Lanes still divide the window; zoom never moves the window or
its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the
menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung
announces itself to VoiceOver, and the render suite pins both invariants:
a rung repaints every face, a no-op Actual Size repaints nothing.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:22:02 -04:00

831 lines
47 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 board settings sheet, open or not (03-board-ui.md § Board settings sheet).
/// `@State` for `boardInfo`'s reason — one per window — and published the same way, because
/// Board ▸ Board Settings… is a menu-bar item that has to reach the frontmost board window, and
/// because the sheet is presented *on this window* and modal to it.
@State private var boardSettings = BoardSettingsPresentation()
/// 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?
/// 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
}
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.
//
// **The *system* size, not the zoomed one** (03-board-ui.md ▸ Layout — zoom: "Zoom never
// moves the window"). Zooming to 200% must not push a floor up under a window the user
// already sized: moving the window belongs to the right-edge lane drag alone, and a
// minimum that grew with the level would resize every open board from a menu item.
.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 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
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 zoom level enters here and nowhere else** (03-board-ui.md ▸ Layout — zoom).
// On `BoardView` rather than on the `VStack`, deliberately: the level is the *board's*
// ruler, so the banner strip and the transient search bar above it — chrome, not the
// board — stay at the system's size, as do the sheets and popovers this window hosts.
.environment(\.boardZoom, appModel.zoom.context)
}
// 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 window chrome follows the board's background** (03-board-ui.md § Styling ▸
// Capabilities): a board that paints one runs its content the full height of the frame
// under a transparent title bar, with `BoardView.boardBackground`'s frosted strip
// keeping the widget and the toolbar legible over it; a board that paints none keeps
// the standard chrome untouched.
//
// Here rather than in `configureWindow` because it is not a wiring fact but a *live*
// one: `background` is hand-editable, the watcher reloads on a change to `index.md`, and
// the chrome has to follow the reading in both directions. `initial: true` because the
// first render is already a level, not a change — this is the board's first statement
// about its chrome, and the loading half deliberately made none
// (`HostedWindowController.extendsUnderTitlebar`).
.onChange(of: BoardBackdrop.isCustom(store.snapshot, root: store.rootURL), initial: true) { _, custom in
windowController.setExtendsContentUnderTitlebar(custom)
}
// **The board settings sheet** (03-board-ui.md ▸ Board settings sheet) — presented from
// the board window's own content, which is what makes it modal to *this* board rather
// than to the app: "a board-scoped, titled, sectioned sheet on the board window".
//
// It hangs here rather than inside `BoardView` for the reason the popover's flag is a
// window's: the two doors that open it are the titlebar popover's row and a menu-bar
// item, neither of which is inside the board, and a sheet has to be presented by
// something that outlives the surface that asked for it.
.sheet(isPresented: $boardSettings.isPresented) {
BoardSettingsSheet(store: store, presentation: boardSettings)
}
// "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(\.boardSettings, boardSettings)
.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. 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
// 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()
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, 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
// 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()
releaseAccess()
return
}
store = acquired
} catch {
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
// 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)
// 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
/// `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)
// The settings sheet's two doors validate on the same pair, so they are adopted here rather
// than read again somewhere else: the popover's Board Settings… row and Board ▸ Board
// Settings… must never disagree about whether this board has setup to show
// (`BoardSettingsAvailability`). The mode *inside* the git state stays live — add-git flipping
// it re-resolves the sheet's sections and both doors, which is the one mid-session transition
// 06 sanctions.
boardSettings.adopt(tier: session?.tier ?? .free, git: session?.git)
windowController.installTitlebarAccessory(
boardInfoTitlebarAccessory(
store: store,
recents: appModel.styleRecents,
tier: session?.tier ?? .free,
git: session?.git,
presentation: boardInfo,
settings: boardSettings
)
)
// 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,
zoom: appModel.zoom,
session: appModel.dragSession
))
}
// 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()
}
}