import AppKit import SwiftUI import os // MARK: - BoardWindowHost /// One board window: the thing that owns a board's session for as long as it is on screen /// (02-architecture.md § Windows, § Launch and window lifecycle). /// /// ### It is a lifecycle, not a layout /// /// Almost everything here is about beginning and ending: acquiring the shared store, stamping the /// registry, holding the board's security-scoped access, remembering the window's frame, and running /// the close flush before any of it is let go. The board *itself* — the lane strip and everything in /// it — is `BoardView`'s (03-board-ui.md); this file hands it the store and the window and stays out /// of the way. /// /// ### Every open passes through a loading state /// /// The window appears **immediately** — welcome click, File ▸ Open…, Finder double-click, /// restoration alike — at its saved frame, its title carrying the registry record's cached name, /// and its content area holding `BoardLoadingView` until the first snapshot lands /// (02-architecture.md § Launch and window lifecycle, ruled 2026-07-29). The walk that produces /// that snapshot runs **off the main actor** (`BoardStoreRegistry.acquireOffMain`), so a board of /// any size opens as a live window rather than as a beachball, and every restored window walks /// independently of every other. /// /// The window is therefore real, and closeable, before it has a store: ⌘W during the walk cancels /// it and closes the window. That is why `configureWindow` is in two halves — see /// `configureLoadingWindow(recordID:)`. /// /// ### Failure opens welcome, on a row that already exists /// /// A board that will not load has nothing to show, so its window retires. But its registry /// record is created **before** the load runs (02-architecture.md § Per-board app state, "a first /// open that fails fail-fast still records"), so the failure that joins `AppModel.launchFailures` /// always has a recents row waiting for it — `WelcomeRow.derive` matches the two by path, uniform /// with the failed-restoration row. This window dismisses itself and welcome comes up. struct BoardWindowHost: View { let ref: BoardWindowRef @Environment(AppModel.self) private var appModel @Environment(\.openWindow) private var openWindow @Environment(\.dismissWindow) private var dismissWindow /// The transient search strip's arrival and departure has a reduced variant like every other /// appearance in the app (10-accessibility.md; `Motion.transientSearchTransition`). @Environment(\.accessibilityReduceMotion) private var reduceMotion /// The window's own controller — `@State` so it outlives body evaluations and so SwiftUI keeps it /// alive for exactly as long as this window exists. @State private var windowController = HostedWindowController() /// This window's board popover, open or not (03-board-ui.md § Board popover). `@State` for the /// window controller's reason — one per window, living exactly as long as the window — which is /// also what makes ⌘I mean "the board in front" rather than "some board": the flag reaches the /// menu item through the focus system, like the store. @State private var boardInfo = BoardInfoPresentation() /// This window's purge alert, open or not (03-board-ui.md § Trash). `@State` for `boardInfo`'s /// reason and reaching the menu bar the same way: File ▸ Delete (landing on a trash selection) /// and Empty Trash… are menu-bar items, and a menu item cannot present anything of its own. @State private var trashConfirmations = TrashConfirmations() /// How Board ▸ Open Card reaches this window's card windows. `@State` for `boardInfo`'s reason, /// and published the same way: a menu item has no window of its own, and only this view holds /// the board half of a card window's `(board, card)` identity — see `CardOpener`. @State private var cardOpener = CardOpener() /// This window's toolbar search field, as a handle (`BoardSearchPresentation`). `@State` for /// `boardInfo`'s reason — one per window — and published the same way, because Edit ▸ Find ⌘F /// and the caret-chord commands are menu-bar items that have to reach the frontmost board /// window's field. @State private var boardSearch = BoardSearchPresentation() /// The pre-snapshot surface's grace clock (02 § Launch and window lifecycle). `@State` for /// `boardInfo`'s reason — one per window, living exactly as long as the window. @State private var loading = BoardLoadingIndicator() /// The open walk, so ⌘W can cancel it by name rather than by waiting for SwiftUI's teardown to /// get around to it. @State private var openWalk = BoardOpenWalk() /// This board's registry record, from the moment `recordOpen` mints it — which is what the /// loading window's title reads (`Self.loadingTitle`). `nil` only for the one body evaluation /// that precedes `start()`. @State private var recordID: UUID? @State private var phase: Phase = .opening private enum Phase { case opening case open(BoardStore) /// The load failed; this window is on its way out and must not try again. case failed } private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-window") var body: some View { content // Font-derived like everything else the board lays out (`BoardMetrics.windowMinimumSize`, // 10-accessibility.md's full-relative-scaling rule): at a large system text size a // 640×400 floor would be narrower than two lane headers, and "every lane is always on // screen" would degrade into a strip of truncation. .frame( minWidth: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).width, minHeight: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).height ) .background(WindowAccessor(controller: windowController)) .navigationTitle(windowTitle) .task { await beginOpening() } .onDisappear { endSessionIfStillOpen() } } @ViewBuilder private var content: some View { switch phase { case .opening: // **The pre-snapshot loading state** (02 § Launch and window lifecycle): empty for the // grace, a centered spinner after it, never a skeleton. The board replaces it in place // when `phase` becomes `.open` — a snap, which is what assigning outside `withAnimation` // means here. BoardLoadingView(indicator: loading) case .failed: // Nothing to render and nothing worth animating: this window is dismissing itself. Color.clear case let .open(store): VStack(spacing: 0) { BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) } // **⌘F's fallback**, and only that: the search field's home is the toolbar item // (`BoardToolbar`), and this strip exists for the window where the user has taken // that item out — "with the field removed from the toolbar, invoking it surfaces the // field transiently until the search clears" (03-board-ui.md ▸ Toolbar). It sits // directly under the title bar, where the item it stands in for would be. if boardSearch.isTransient { BoardSearchBar(store: store, presentation: boardSearch) .transition(Motion.transientSearchTransition(reduced: reduceMotion)) } // The window is handed to the board as a closure, not a value: `WindowAccessor` // attaches after this body first runs, and the lane-resize drag needs the *live* // window to grow at its right edge (03-board-ui.md § Lane). // // `openCard` is the host's too, for a different reason — see the property below. BoardView( store: store, window: { windowController.window }, confirmations: trashConfirmations, openCard: openCard, search: boardSearch ) } // The transient strip's two dismissal inputs (`BoardSearchPresentation // .transientPersists`): it stays while a query is filtering the board or while the field // holds the keyboard, and goes when neither is true. .onChange(of: store.searchQuery) { _, query in boardSearch.dismissTransientIfCleared(query: query) } .onChange(of: boardSearch.isFocused) { _, _ in boardSearch.dismissTransientIfCleared(query: store.searchQuery) } // "The board in front", for the menu items that act on it (`LaneWidthCommands`), and // beside it the window's own popover flag, which is what File ▸ Board Info toggles, its // purge-alert host, which the trash's two confirmed commands raise, and its search // field, which Edit ▸ Find focuses and the caret-chord commands yield to. .focusedSceneValue(\.boardStore, store) .focusedSceneValue(\.boardSearch, boardSearch) // The window's identity beside its store — File ▸ Duplicate flushes a *session*, which // is keyed on the window rather than on the board it is showing. .focusedSceneValue(\.boardWindowRef, ref) .focusedSceneValue(\.boardInfo, boardInfo) .focusedSceneValue(\.trashConfirmations, trashConfirmations) // Board ▸ Open Card's second half — the same closure `BoardView` gets, so the menu item // and the double-click open one window per card by construction. .focusedSceneValue(\.cardOpener, cardOpener) } } /// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it, /// so "at most one card window per card (reopen focuses)" needs no bookkeeping here /// (02-architecture.md § Windows). private var openCard: (ItemID) -> Void { { cardID in openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID)) } } private var windowTitle: String { guard case let .open(store) = phase else { return Self.loadingTitle( record: recordID.flatMap { appModel.boardRegistry.record(id: $0) }, url: ref.url ) } return AppModel.displayName(of: store) } /// What a window that has no snapshot yet is called — **the registry record's cached name**, the /// same no-scan source the welcome row reads (02 § Launch and window lifecycle: "its chrome /// carrying the registry record's cached title and icon"; § Per-board app state, "the welcome row /// reads only the record — it never opens any board's `index.md`"). /// /// Read back off the record rather than recomputed, so a board that has opened before shows the /// title it is known by and a first-ever open shows the folder name — which is that record's /// provisional display name, not a second rule. The `nil` fallback is the folder name anyway, /// covering only the body evaluation that precedes `recordOpen`. /// /// Static and pure so the rule is checkable without a window (`BoardWindowHostTests`). static func loadingTitle(record: BoardRecord?, url: URL) -> String { record?.displayName ?? AppModel.folderDisplayName(of: url) } // MARK: - Opening /// Starts the open as a task of its own, so something can hold it. /// /// `.task` cancels on teardown but hands out no handle, and ⌘W during loading needs one *by /// name* — the ruled cancel is an act of the user's, not a consequence of a window that has /// already gone away (02 § Launch and window lifecycle). So the walk runs in a task `openWalk` /// keeps, and the cancellation handler forwards `.task`'s own cancellation into it, leaving both /// routes — the user's ⌘W and any teardown SwiftUI decides on — ending in the same `cancel()`. private func beginOpening() async { let walk = Task { await start() } openWalk.adopt(walk) await withTaskCancellationHandler { await walk.value } onCancel: { walk.cancel() } } /// Acquires the board and starts its session, or fails it out to welcome. /// /// The order is load-bearing, and it now has one more step than the load itself does. Security- /// scoped access is claimed **before** anything else, because the record and the load both need /// it. The registry record comes **before** the walk — "the registry record is created before /// loading" (02 § Per-board app state) — so a fail-fast failure always has a row to land on, and /// so the loading window has a name to wear; the walk's first act is a directory read, and a /// sandboxed read outside the claimed scope is exactly the one that gets refused. `setOpenNow` /// comes **after** the load succeeds and after the window has demonstrably opened — a flag set on /// a board that never appeared would hand the next launch a restoration set describing a failure. /// /// **The walk is the one suspension here**, and everything before it is what makes the window /// real while it runs: the record, the loading window's chrome, and the grace clock. Everything /// after it is the snap. private func start() async { guard case .opening = phase else { return } // Claimed even on the failure path: an unclaimed stash is a scope nobody balances. let access = appModel.claimPendingAccess(for: ref) let url = access?.url ?? ref.url // Record before load (settled, 02 § Per-board app state). `displayName` is omitted — an // existing record's cached title survives untouched, and a brand-new one takes the folder // name, both `recordOpen`'s own rule now. This is also this open's one bookmark mint: a // successful load below replaces the name through `syncDisplayState`, which never re-mints. let recordID = appModel.boardRegistry.recordOpen(of: url) // Published to the view *before* the walk: this is what the title bar reads while loading. self.recordID = recordID // The window is on screen and the user can act on it from here on — placed where they left // it, and closeable. configureLoadingWindow(recordID: recordID) loading.begin() let store: BoardStore do throws(BoardLoadFailure) { guard let acquired = try await appModel.storeRegistry.acquireOffMain(url) else { // ⌘W landed while the walk was running, and the walk has now finished into a result // nobody wants (`acquireOffMain`, discard-on-completion). The window is already // closing and the registry kept nothing, so the only thing left to balance is this // open's scoped access. There is no open-now flag to clear: `setOpenNow` is below, // after the load, so a cancelled open never set one — the very reason it lives there. Self.logger.debug("board open cancelled during its walk") loading.end() access?.stop() return } store = acquired } catch { Self.logger.error("board failed to open: \(error.description, privacy: .public)") loading.end() access?.stop() phase = .failed appModel.recordLaunchFailure(path: ref.path, message: error.description) // The record above just changed the registry — welcome, about to appear, must not // render the stale list `AppModel` cached before this open began, or the failure would // fall through to the unmatched-failures list for want of a row that already exists. appModel.refreshRecents() openWindow(id: WindowID.welcome) dismissWindow(id: WindowID.board, value: ref) return } loading.end() // The load succeeded — the frontmatter can be trusted now, so it replaces whatever // provisional or stale name the record above was carrying. Through `syncDisplayState`, // deliberately not a second `recordOpen`: this is a display-state refresh, not a second // open, and it must not mint this board's bookmark again (02 § Per-board app state, "one // bookmark per open board"). appModel.boardRegistry.syncDisplayState( id: recordID, title: AppModel.displayName(of: store), icon: store.snapshot.icon.value, iconColor: store.snapshot.iconColor.value ) appModel.boardRegistry.setOpenNow(id: recordID) appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access) phase = .open(store) configureWindow(store: store, recordID: recordID) // "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless // when welcome is not open, which is the ordinary case. dismissWindow(id: WindowID.welcome) } /// **The half of the wiring a window needs before it has a board** — everything here is about /// the *window*, and nothing here mentions the store, which is exactly the split /// 02-architecture.md's loading state forces: this runs before the walk, and /// `configureWindow(store:recordID:)` runs after it. /// /// Three things, and each is a rule from § Launch and window lifecycle: /// /// - **The saved frame**, so the window appears "at its saved frame" rather than at the system's /// cascade and then jumping to the user's place a second later. /// - **The frame changes**, so a window the user moves *while it loads* is remembered. Not /// store-dependent and so not worth deferring — the alternative is a slow board's window whose /// move is silently discarded. /// - **The close interception**, which is what makes ⌘W during loading mean anything at all. It /// is replaced wholesale by the flushing version once the board is open (see below); a single /// closure branching on `phase` would be the same thing spelled as a state read. /// /// The title bar keeps AppKit's own title display for now — the string is the record's cached /// name (`windowTitle`) — and `hideTitle()` follows only once the board-popover widget is there /// to say the name instead. Hiding it here would leave a loading window with no name anywhere in /// its chrome, which is precisely what 02 asks the loading state to carry. private func configureLoadingWindow(recordID: UUID) { windowController.onAttach = { window in guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return } window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true) } // The window may already be attached — `viewDidMoveToWindow` fires before this task's first // suspension — so the placement is applied directly too rather than waiting for a callback // that has already happened. The closure stays installed either way: the controller re-fires // it if SwiftUI swaps the provisional window for the real one (`HostedWindowController // .detach`). if let window = windowController.window { windowController.onAttach?(window) } windowController.onFrameChanged = { frame in appModel.boardRegistry.updateWindowFrame( id: recordID, frame: WindowFrame(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height) ) } windowController.onCloseRequested = { // **⌘W during the walk** (02 § Launch and window lifecycle: "the walk is cancellable: // ⌘W during loading cancels it and closes the window"). The window closes *now* — there // is no store, so there is nothing to flush and nothing to wait for — and the walk's // tail is wasted work we accept rather than thread a cancellation flag through the // loader (`BoardStoreRegistry.acquireOffMain`, discard-on-completion). // // No open-now flag is cleared here because none was ever set: `setOpenNow` runs after // the load, so a board that never finished loading is not in the restoration set. The // ordinary user-close *does* clear it, in `AppModel.closeBoard`, which is the path the // replacement closure below takes. openWalk.cancel() windowController.closeAfterFlush() } } /// Wires the rest of the window, once there is a board to wire it to: the store's write-through, /// the close flush, the undo stack, the title-bar widget and the toolbar. /// /// Everything here **carries the store or the session**, which is the whole reason it waits for /// them; the window-level half ran before the walk (`configureLoadingWindow(recordID:)`). private func configureWindow(store: BoardStore, recordID: UUID) { // Filled in here rather than at declaration because the closure captures `openWindow`, an // environment action; until the board has loaded there is also nothing for Open Card to act // on, which is exactly what the item's `nil` check reads. cardOpener.open = openCard // The registry's live write-through (02-architecture.md § Per-board app state) — wired // the way `onFrameChanged` was a moment ago in the loading half: a closure that reaches into // the registry, captured weakly on both sides so neither the store nor this closure's own // home keeps the other alive past its window. `syncDisplayState` in `start()` already // stamped the values current as of this open, so nothing is fired here immediately; this // only fires on the reloads that follow. store.displayStateDelegate = { [weak appModel, weak store] in guard let appModel, let store else { return } appModel.boardRegistry.syncDisplayState( id: recordID, title: AppModel.displayName(of: store), icon: store.snapshot.icon.value, iconColor: store.snapshot.iconColor.value ) } // **Replacing the loading half's cancel-and-close**: from here the window has a session, so // a close is the flush (02 § Windows, "Close flushes") and the user-close that clears the // open-now flag. A slot rather than a branch — `onCloseRequested` is one closure, and the // window that owns it has moved on. windowController.onCloseRequested = { Task { @MainActor in await appModel.closeBoard(ref: ref, cause: .userClose) windowController.closeAfterFlush() } } // This window's answer to "what does ⌘Z act on" (13-native-undo.md ▸ Rules; 06 ▸ Undo // routing) — the *session's* stack, read afresh on every ask so a torn-down board answers // nothing rather than a stack with no board behind it. The Edit menu's Undo/Redo rows and // the toolbar's pair are nil-target `undo:`/`redo:`, so this one line is what lights them // up: `NSWindow` validates and crosses them against exactly this manager. windowController.windowUndoManager = { appModel.session(for: ref)?.undoManager } // The window-title widget (03-board-ui.md § Board popover) — **board windows only**, which // is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and // card windows share that machinery and have no board to describe. It goes in after the // load rather than at attach because it carries the store; the controller installs it once, // whichever of the two arrives second. // // The tier and the git state come from the **session**, which `start()` began a moment ago, // rather than from the entitlement or the disk: a board's popover must describe the board as // it opened (12-editions.md ▸ The entitlement, "an open board finishes with the provider it // composed"; 06-history-undo.md ▸ Rules, mode is an open-time fact). A `nil` session cannot // happen on this path — `beginSession` precedes `configureWindow` — and reads as the free // tier's posture, which is the harmless direction. let session = appModel.session(for: ref) windowController.installTitlebarAccessory( boardInfoTitlebarAccessory( store: store, recents: appModel.styleRecents, tier: session?.tier ?? .free, git: session?.git, presentation: boardInfo ) ) // The widget above now says the board's name (and, on a git-mode Pro board, its branch) // itself, so the system title display would only repeat it — the card-window seam // (`CardWindowHost.configureWindow`, `HostedWindowController.hideTitle`), applied here for // the same reason. `.navigationTitle(windowTitle)` a few lines up in `body` is untouched — // `window.title` keeps feeding the Window menu, Exposé, VoiceOver and restoration; only the // title bar's own rendering of that string is suppressed. // // **After the load, and only after it**, which is why it is not in the loading half above: // this line and the widget it defers to are one exchange, and a loading window that hid its // title before the widget existed would carry no name at all — against 02's "its chrome // carrying the registry record's cached title". windowController.hideTitle() // The board's customizable toolbar (03-board-ui.md ▸ Toolbar) — installed here for the // accessory's reason exactly: it carries the store, and it is a board window's, not every // hosted window's. Its search item is the search field's home, and it is what tells // `boardSearch` whether that home still exists. windowController.installToolbar(BoardToolbar.controller(store: store, search: boardSearch)) } // MARK: - Closing /// The safety net behind the close interception. /// /// `windowShouldClose` covers ⌘W, File ▸ Close and the red button — every way a *user* closes a /// window. It does not cover a window torn down some other way (a programmatic dismiss, a scene /// SwiftUI decides to end), and a board whose session outlived its window would leave a watcher /// running over nothing. So the disappear runs the same sequence; `AppModel.closeBoard` is /// idempotent precisely so these two can both fire without the flush running twice. /// /// Deliberately **not** the quit path: quit is `AppDelegate`'s, and it must complete before the /// app exits rather than in a task nobody waits for. private func endSessionIfStillOpen() { guard appModel.session(for: ref) != nil else { return } Task { @MainActor in await appModel.closeBoard(ref: ref, cause: .userClose) } } } // MARK: - BoardOpenWalk /// The open walk's handle, held for exactly one reason: **⌘W during loading has to be able to cancel /// it** (02-architecture.md § Launch and window lifecycle). /// /// A one-field box rather than the `Task` itself in `@State`, because the thing that cancels it is a /// closure the window controller holds (`onCloseRequested`) and the thing that fills it is the /// `.task` that starts the walk — two places that must agree on one task, which is what a reference /// type is. `@MainActor` like everything else on this path, so the box needs no synchronisation of /// its own. /// /// Cancelling does not stop the walk (see `BoardStoreRegistry.acquireOffMain` for why the walk is /// deliberately not cooperatively cancellable). It stops the *open*: the task that would have /// adopted the result never does. @MainActor final class BoardOpenWalk { private var task: Task? init() {} func adopt(_ task: Task) { self.task = task } func cancel() { task?.cancel() } }