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,236 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user