background is {color:, image:} and only a mapping at every level; the board's image paints the full window under a transparent title bar, with a thin-material frost strip keeping the chrome legible and the standard accommodations intact.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
816 lines
46 KiB
Swift
816 lines
46 KiB
Swift
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.
|
||
.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 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))
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
}
|