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* — lanes, cards, drag, the whole of /// 03-board-ui.md — is a placeholder below, deliberately throwaway and confined to one small view so /// the milestone that builds the real thing replaces exactly that and nothing else. /// /// ### Failure opens welcome /// /// A board that will not load has nothing to show, so its window never appears: the failure joins /// `AppModel.launchFailures`, welcome comes up, and this window dismisses itself. That is the same /// path a failed restoration takes — "welcome appears alongside whatever did restore, the failed /// board's recents row carrying fail-fast's specifics" — with the row-level rendering still owed. struct BoardWindowHost: View { let ref: BoardWindowRef @Environment(AppModel.self) private var appModel @Environment(\.openWindow) private var openWindow @Environment(\.dismissWindow) private var dismissWindow /// 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() @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 .frame(minWidth: 640, minHeight: 400) .background(WindowAccessor(controller: windowController)) .navigationTitle(windowTitle) .task { await start() } .onDisappear { endSessionIfStillOpen() } } @ViewBuilder private var content: some View { switch phase { case .opening, .failed: // Nothing to render and nothing worth animating: this window either becomes a board in a // moment or dismisses itself. Color.clear case let .open(store): VStack(spacing: 0) { BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) } PlaceholderBoardView(store: store) } } } private var windowTitle: String { guard case let .open(store) = phase else { return "" } return AppModel.displayName(of: store) } // MARK: - Opening /// Acquires the board and starts its session, or fails it out to welcome. /// /// The order is load-bearing. Security-scoped access is claimed **before** `acquire`, because /// acquire's first act is a full tree walk and a sandboxed read outside the scope is exactly the /// one that gets refused. `setOpenNow` comes **after** the record exists and after the window has /// demonstrably opened — a flag set on a board that never appeared would hand the next launch a /// restoration set describing a failure. private func start() async { guard case .opening = phase else { return } // Claimed even on the failure path: an unclaimed stash is a scope nobody balances. let access = appModel.claimPendingAccess(for: ref) let url = access?.url ?? ref.url let store: BoardStore do throws(BoardLoadError) { store = try appModel.storeRegistry.acquire(url) } catch { Self.logger.error("board failed to open: \(error.description, privacy: .public)") access?.stop() phase = .failed appModel.recordLaunchFailure(path: ref.path, message: error.description) openWindow(id: WindowID.welcome) dismissWindow(id: WindowID.board, value: ref) return } let recordID = appModel.boardRegistry.recordOpen(of: url, displayName: AppModel.displayName(of: store)) appModel.boardRegistry.setOpenNow(id: recordID) appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access) phase = .open(store) configureWindow(recordID: recordID) // "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless // when welcome is not open, which is the ordinary case. dismissWindow(id: WindowID.welcome) } /// Wires the window: the saved frame on the way in, frame changes on the way back out, and the /// close interception that makes the flush unavoidable. private func configureWindow(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 well before this task's // load returns — so the placement is applied directly too rather than waiting for a callback // that has already happened. if let window = windowController.window { windowController.onAttach?(window) } windowController.onFrameChanged = { frame in appModel.boardRegistry.updateWindowFrame( id: recordID, frame: WindowFrame(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height) ) } windowController.onCloseRequested = { Task { @MainActor in await appModel.closeBoard(ref: ref, cause: .userClose) windowController.closeAfterFlush() } } } // 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: - The placeholder board /// Stand-in for the board (03-board-ui.md): the title and a list of lane titles, and nothing else. /// /// **Deliberately throwaway.** The next milestone builds the full-visibility lane layout — masonry /// cards, drag, the trash quasi-lane, the toolbar — and replaces this view wholesale. It is kept in /// one small view with no state of its own so that replacement is a deletion rather than an /// untangling. What it does prove today is that the window renders the *store's* snapshot: an /// external edit shows up here through the watcher like it will in the real thing. private struct PlaceholderBoardView: View { let store: BoardStore var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { Text(AppModel.displayName(of: store)) .font(.largeTitle) if liveLanes.isEmpty { Text("No lanes yet") .foregroundStyle(.secondary) } else { ForEach(liveLanes) { lane in HStack(alignment: .firstTextBaseline, spacing: 8) { Text(lane.title.value ?? "Untitled") .font(.headline) .foregroundStyle(lane.title.value == nil ? .secondary : .primary) Text("\(liveCardCount(in: lane))") .font(.callout) .foregroundStyle(.secondary) } } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(24) } } /// Tombstoned lanes render nowhere on the board (03-board-ui.md collapses them into the trash /// quasi-lane) — true of the placeholder as much as of the real layout. private var liveLanes: [Lane] { store.snapshot.lanes.filter { !$0.isDeleted } } private func liveCardCount(in lane: Lane) -> Int { lane.cards.filter { !$0.isDeleted }.count } }