Stand up the window architecture — welcome, board, card
Four scenes (welcome, restore bootstrap, board group, card group) with system restoration disabled in favor of the registry's open-now flags: set when a window actually opens, cleared only on user close, so quit — and crash — leave exactly the restoration set behind. AppModel joins windows to sessions (shared store, registry record, card refs, held security scope); CloseFlushCoordinator pins 02's strict close order as a seam-injected machine (card sessions end, windows drain, store flushes, record stamps, teardown) with named slots where m6/m7 flushes land. HostedWindowController proxies — never replaces — SwiftUI's window delegate to intercept windowShouldClose for the flush, report frames, and place saved frames onto live screens. Card windows are (board path, case-folded card id) values: reopen focuses, and a snapshot-pure fate function dismisses on delete, tombstone, tombstoned lane, or cross-board move. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user