Files
lanework/Kanban/App/CardWindowHost.swift
T
rzen fccdf56cf4 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
2026-07-27 07:47:11 -04:00

237 lines
10 KiB
Swift

import AppKit
import SwiftUI
import os
// MARK: - Fate
/// What the current snapshot says about a card window: render this card, or go away.
///
/// A named decision rather than a scattering of `if`s, because 05-card-window.md ▸ Deletion &
/// lifecycle and 02-architecture.md § Live-reload resilience state the same rule from two directions
/// and both have to be true of one piece of code. Making it a value also makes it a *pure* function
/// of a snapshot, which is the only way the tombstoned-lane case gets tested without a window.
public enum CardWindowFate: Equatable {
case shows(Card)
case dismisses
}
// MARK: - The session seam
/// A card window's editor session — m4's no-op stand-in for the thing 05-card-window.md will build.
///
/// It exists so the close flush has something real to call and something real to be *ordered against*
/// (see `CardSessionFlushing`). The only behaviour it has is the one the ordering depends on: ending
/// twice does nothing the second time, which matters because two paths legitimately end a session —
/// the board's close flush drives it for every card window, and a card window closed on its own runs
/// it from its disappear.
@MainActor
final class CardWindowSession: CardSessionFlushing {
private var hasEnded = false
func endSession() async {
guard !hasEnded else { return }
hasEnded = true
// m6: commit the open Edit session here (06-history-undo.md's session granularity), flushing
// the debounced body save first.
}
}
// MARK: - CardWindowHost
/// One card window (05-card-window.md).
///
/// ### Its whole identity is `(board, card)`
///
/// Which is why this host is mostly a set of dismissal rules. The window follows its card between
/// lanes for free — the key names neither — and it dismisses in the three cases where the key stops
/// naming anything: the card is tombstoned, its *lane* is tombstoned (effective liveness is
/// ancestor-walked, 02 § Live-reload resilience), or the card is simply not in this board's snapshot
/// any more, which is what a cross-board move looks like from here.
///
/// ### It can never outlive its board window
///
/// "The board window owns the board" (02 § Components) — so a card window whose board has no live
/// store, or whose board session has gone, dismisses immediately rather than becoming an orphan with
/// a store it acquired by itself. That covers the ordinary case (the board window closed and its
/// flush dismissed this one) and the odd one (the system restoring a card window from a previous
/// launch, which scene restoration is disabled precisely to prevent).
///
/// The content is a placeholder — the two-column composition, the sidebar, Edit/Preview and the rest
/// are the card-window milestone's.
struct CardWindowHost: View {
let ref: CardWindowRef
@Environment(AppModel.self) private var appModel
@Environment(\.dismissWindow) private var dismissWindow
@State private var windowController = HostedWindowController()
@State private var session = CardWindowSession()
@State private var phase: Phase = .opening
private enum Phase {
case opening
case open(BoardStore)
case closing
}
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "card-window")
// MARK: - The lifecycle rule
/// Whether a card window keyed on `cardID` still has a card, given this board's snapshot.
///
/// The three dismissal cases collapse into two lines: a card that is not in the snapshot is gone
/// (deleted outright, or moved to another board — the board half of the key no longer names it),
/// and a card whose **effective** liveness is trashed renders nowhere, whether the tombstone is
/// its own or its lane's. Only a live card in a live lane keeps its window.
///
/// Takes the id as the ref stores it — a raw folder name — and compares it as an `ItemID`, so two
/// case-spellings of one UUID are one card here exactly as they are everywhere else.
static func cardWindowFate(cardID: String, in snapshot: BoardModel) -> CardWindowFate {
let identity = ItemID(rawValue: cardID)
for lane in snapshot.lanes {
guard let card = lane.cards.first(where: { $0.id == identity }) else { continue }
return lane.isDeleted || card.isDeleted ? .dismisses : .shows(card)
}
return .dismisses
}
// MARK: - View
var body: some View {
content
.frame(minWidth: 360, minHeight: 240)
.background(WindowAccessor(controller: windowController))
.navigationTitle(windowTitle)
.task { start() }
.onChange(of: shouldDismiss, initial: true) { _, dismisses in
guard dismisses else { return }
dismissWindow(id: WindowID.card, value: ref)
}
.onDisappear { finish() }
}
@ViewBuilder
private var content: some View {
if let card {
VStack(alignment: .leading, spacing: 12) {
Text(card.title.value ?? "Untitled")
.font(.title)
// The untitled placeholder is styling, not a title: a card with no `title` key
// shows the word in secondary, never as if somebody had typed it
// (01-storage-format.md § Frontmatter).
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(24)
} else {
Color.clear
}
}
private var card: Card? {
guard case let .open(store) = phase,
case let .shows(card) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot)
else { return nil }
return card
}
private var windowTitle: String {
card?.title.value ?? ""
}
/// The dismissal decision, re-evaluated on every snapshot the store applies.
///
/// Two clauses, and the second is the safety net: the board's session vanishing means the board
/// window has finished tearing down, and a card window still on screen at that point has nothing
/// behind it. It is deliberately redundant with the close flush, which dismisses these windows
/// itself — a net is only useful when the thing it backs up has already failed.
private var shouldDismiss: Bool {
guard case let .open(store) = phase else { return false }
guard appModel.session(for: ref.board) != nil else { return true }
return Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) == .dismisses
}
// MARK: - Opening
/// Joins the board's session, or dismisses.
///
/// **`liveStore(for:)` first, and no fallback to `acquire` on a closed board.** A card window
/// never opens a board: doing so would put a store — and a watcher — behind a window that,
/// by 02's ownership rule, is not allowed to exist. The `acquire` below can only hit the
/// already-open path, which is why its failure is logged rather than surfaced.
private func start() {
guard case .opening = phase else { return }
guard appModel.storeRegistry.liveStore(for: ref.boardURL) != nil else {
Self.logger.debug("card window has no live board — dismissing")
phase = .closing
dismissWindow(id: WindowID.card, value: ref)
return
}
let store: BoardStore
do throws(BoardLoadError) {
store = try appModel.storeRegistry.acquire(ref.boardURL)
} catch {
Self.logger.error("card window could not acquire its board: \(error.description, privacy: .public)")
phase = .closing
dismissWindow(id: WindowID.card, value: ref)
return
}
appModel.registerCardWindow(ref, session: session)
phase = .open(store)
configureWindow()
}
/// Size and placement: the last-used card-window size, cascaded (05-card-window.md, "New windows
/// open at the last-used card-window size, cascaded").
///
/// The size is app-wide rather than per-board or per-card — 02 § Per-board app state files "the
/// last-used card-window size" under App-wide state explicitly. Per-*card* frame restoration is a
/// separate promise in 05 ("frames restore per card across relaunch where state restoration
/// allows") and belongs to the card-window milestone, which owns the per-card record it needs.
private func configureWindow() {
windowController.onAttach = { window in
if let size = AppPreferences.lastCardWindowSize {
window.setContentSize(size)
}
// `cascadeTopLeft(from:)` both places this window and returns the origin for the next
// one, so the running point is the whole cascade.
appModel.cardCascadePoint = window.cascadeTopLeft(from: appModel.cardCascadePoint)
}
if let window = windowController.window {
windowController.onAttach?(window)
}
windowController.onFrameChanged = { frame in
guard let window = windowController.window else { return }
let size = window.contentRect(forFrameRect: frame).size
guard size != AppPreferences.lastCardWindowSize else { return }
AppPreferences.setLastCardWindowSize(size)
}
}
// MARK: - Closing
/// Leaves the session and lets the store go.
///
/// The release rides **behind** the session's end rather than beside it: a session that has
/// something to commit (m6) needs the store it is committing through, and a refcount that hit
/// zero first would have stopped the watcher underneath it. In m4 the hook is a no-op and the
/// ordering costs one run-loop turn — the point is that the shape is already right.
private func finish() {
guard case let .open(store) = phase else { return }
phase = .closing
appModel.unregisterCardWindow(ref)
Task { @MainActor in
await session.endSession()
appModel.storeRegistry.release(store)
}
}
}