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,206 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - Vocabulary
|
||||
|
||||
/// Why a board is closing — the one bit the flush sequence branches on.
|
||||
///
|
||||
/// The distinction *is* the restoration mechanism (02-architecture.md § Launch and window
|
||||
/// lifecycle): a user close clears the record's open-now flag, a quit deliberately leaves it
|
||||
/// standing so the next launch reopens what was on screen. Everything else about the two paths is
|
||||
/// identical, which is why this is an enum consulted at one step rather than two sequences.
|
||||
public enum BoardCloseCause: Sendable, Equatable {
|
||||
/// ⌘W, the red button, File ▸ Close — the user said this board is done.
|
||||
case userClose
|
||||
/// App quit. The boards were open at quit by definition, so their flags stay set.
|
||||
case quit
|
||||
}
|
||||
|
||||
/// The end-of-session hook a card window runs before it goes away.
|
||||
///
|
||||
/// **A seam, not a feature, in m4.** The real work is 05-card-window.md's: "each open Edit session
|
||||
/// ends with its normal session commit" (06-history-undo.md's granularity), which needs an editor
|
||||
/// and a dirty buffer that do not exist yet. The default implementation is therefore a no-op, and
|
||||
/// what this milestone actually pins is the *ordering* — that the hook runs, for every open card
|
||||
/// window, before any of the board's pending work is flushed and long before the store is released.
|
||||
/// The card-window milestone supplies a body; nothing above it has to change.
|
||||
///
|
||||
/// `AnyObject` because a card window's session is a live object with a buffer in it, and because the
|
||||
/// coordinator holds it across an `await`.
|
||||
@MainActor
|
||||
public protocol CardSessionFlushing: AnyObject {
|
||||
func endSession() async
|
||||
}
|
||||
|
||||
public extension CardSessionFlushing {
|
||||
func endSession() async {}
|
||||
}
|
||||
|
||||
// MARK: - CloseFlushCoordinator
|
||||
|
||||
/// The close-flush sequence, in order, for one board — the whole of 02-architecture.md § Windows'
|
||||
/// "Close flushes" bullet.
|
||||
///
|
||||
/// > closing a board window (and app quit) first closes the board's card windows — each open Edit
|
||||
/// > session ends with its normal session commit — then flushes pending debounced work, editor saves
|
||||
/// > before the pending auto-commit, before the store tears down.
|
||||
///
|
||||
/// **Nothing about it is conditional.** A card window cannot exist without its board window (the
|
||||
/// ownership rule in § Components), so there is no shape of the world in which some other order is
|
||||
/// correct — "the close flush is always the whole story". App quit runs this same object once per
|
||||
/// open board rather than a second sequence that could drift.
|
||||
///
|
||||
/// ### Why closures rather than an object graph
|
||||
///
|
||||
/// Every step here is a claim about *order*, and an order is only testable if the steps can be
|
||||
/// observed. Written against `NSWindow`, `BoardStore`, and SwiftUI's dismiss action this would be
|
||||
/// verifiable only by running the app; written against these seams it is a pure ordering machine
|
||||
/// that a test drives with an event log. `AppModel.closeBoard(ref:cause:)` is the one production
|
||||
/// call site and supplies the real ones.
|
||||
///
|
||||
/// The two flush seams that are `nil` today — `editorFlush` and `committerFlush` — are named rather
|
||||
/// than left to be discovered: 02 fixes their relative order ("editor saves before the pending
|
||||
/// auto-commit"), and the milestone that adds a debounced editor save should have nowhere to put it
|
||||
/// except the slot that already sits in the right place.
|
||||
@MainActor
|
||||
public struct CloseFlushCoordinator {
|
||||
|
||||
// MARK: Step 1 — the card windows
|
||||
|
||||
/// This board's open card windows, read **live**: the coordinator calls it again while waiting,
|
||||
/// because the set is what shrinks as each host tears down.
|
||||
public var openCardRefs: () -> [CardWindowRef]
|
||||
|
||||
/// Runs one card window's end-session hook. Driven from here rather than left to the window's own
|
||||
/// teardown so that "the sessions ended before the board's work was flushed" is an ordering this
|
||||
/// object guarantees rather than one that happens to hold because SwiftUI ran the disappear
|
||||
/// callbacks promptly.
|
||||
public var endCardSession: (CardWindowRef) async -> Void
|
||||
|
||||
/// Asks the card window to go away. Its host unregisters on the way out, which is what drains
|
||||
/// `openCardRefs`.
|
||||
public var dismissCardWindow: (CardWindowRef) -> Void
|
||||
|
||||
/// How long to wait for the dismissed card windows to actually unregister.
|
||||
///
|
||||
/// A bound rather than an open-ended wait, and the reason is the quit path: this runs inside
|
||||
/// `applicationShouldTerminate`'s deferred reply, so a window that never tears down would leave
|
||||
/// the app unquittable. The sessions have already ended by then — the wait exists to keep the
|
||||
/// refcount honest, not to protect data — so expiring it costs ordering tidiness and nothing
|
||||
/// else.
|
||||
public var cardDrainDeadline: Duration = .seconds(2)
|
||||
|
||||
// MARK: Step 2 — pending work
|
||||
|
||||
/// The store's own pipeline settling — `BoardStore.awaitQuiescence()` in production.
|
||||
public var storeFlush: () async -> Void
|
||||
|
||||
/// The card windows' debounced body saves (05-card-window.md, m6). Runs **before**
|
||||
/// `committerFlush`: 02 is explicit that editor saves land before the pending auto-commit, so a
|
||||
/// session's last keystrokes are inside the commit that closes it rather than orphaned in the
|
||||
/// next one.
|
||||
public var editorFlush: (() async -> Void)?
|
||||
|
||||
/// The pending debounced auto-commit (06-history-undo.md, m7).
|
||||
public var committerFlush: (() async -> Void)?
|
||||
|
||||
// MARK: Step 3 — the record
|
||||
|
||||
/// Stamps the recents counts (live items only — `AppModel.liveCounts(of:)`).
|
||||
public var recordClose: () -> Void
|
||||
|
||||
/// Clears the record's open-now flag. Called **only** for `.userClose`; see `BoardCloseCause`.
|
||||
public var clearOpenNow: () -> Void
|
||||
|
||||
// MARK: Step 4 — teardown
|
||||
|
||||
/// Releases the store, stops the board's security-scoped access, and forgets the session.
|
||||
public var tearDown: () -> Void
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "close-flush")
|
||||
|
||||
public init(
|
||||
openCardRefs: @escaping () -> [CardWindowRef],
|
||||
endCardSession: @escaping (CardWindowRef) async -> Void,
|
||||
dismissCardWindow: @escaping (CardWindowRef) -> Void,
|
||||
cardDrainDeadline: Duration = .seconds(2),
|
||||
storeFlush: @escaping () async -> Void,
|
||||
editorFlush: (() async -> Void)? = nil,
|
||||
committerFlush: (() async -> Void)? = nil,
|
||||
recordClose: @escaping () -> Void,
|
||||
clearOpenNow: @escaping () -> Void,
|
||||
tearDown: @escaping () -> Void
|
||||
) {
|
||||
self.openCardRefs = openCardRefs
|
||||
self.endCardSession = endCardSession
|
||||
self.dismissCardWindow = dismissCardWindow
|
||||
self.cardDrainDeadline = cardDrainDeadline
|
||||
self.storeFlush = storeFlush
|
||||
self.editorFlush = editorFlush
|
||||
self.committerFlush = committerFlush
|
||||
self.recordClose = recordClose
|
||||
self.clearOpenNow = clearOpenNow
|
||||
self.tearDown = tearDown
|
||||
}
|
||||
|
||||
// MARK: - The sequence
|
||||
|
||||
/// Runs the four steps in the one order 02 fixes. Never throws and never returns early: a board
|
||||
/// that is closing is closing, and a step that fails must not strand the store, the record, or
|
||||
/// the window.
|
||||
public func run(cause: BoardCloseCause) async {
|
||||
await closeCardWindows()
|
||||
await flushPendingWork()
|
||||
recordClose()
|
||||
if cause == .userClose {
|
||||
clearOpenNow()
|
||||
}
|
||||
tearDown()
|
||||
}
|
||||
|
||||
/// Step 1. Every card window's session ends, then every card window is dismissed, then the
|
||||
/// coordinator waits for them to unregister.
|
||||
///
|
||||
/// **All the sessions end before any window is dismissed**, deliberately. The alternative —
|
||||
/// end-then-dismiss, one card at a time — would interleave commits with window teardowns, and a
|
||||
/// teardown that took a moment would leave a later card's unsaved buffer sitting in memory that
|
||||
/// much longer for no reason. The hooks are awaited in the order the refs came back, so a board
|
||||
/// with several dirty editors commits them in a stable order rather than a racy one.
|
||||
private func closeCardWindows() async {
|
||||
let refs = openCardRefs()
|
||||
guard !refs.isEmpty else { return }
|
||||
|
||||
for ref in refs {
|
||||
await endCardSession(ref)
|
||||
}
|
||||
for ref in refs {
|
||||
dismissCardWindow(ref)
|
||||
}
|
||||
await drainCardWindows()
|
||||
}
|
||||
|
||||
/// Waits for the dismissed hosts to unregister, or for the deadline.
|
||||
///
|
||||
/// Polled rather than signalled by a continuation, and the deadline is why: the point of this
|
||||
/// wait is that it *ends*, and a continuation resumed by the last unregister has no way to end
|
||||
/// if that unregister never comes. The loop costs a handful of 10 ms turns during a window close
|
||||
/// and nothing at all when the hosts tear down promptly, which they do.
|
||||
private func drainCardWindows() async {
|
||||
let start = ContinuousClock.now
|
||||
while !openCardRefs().isEmpty {
|
||||
guard ContinuousClock.now - start < cardDrainDeadline else {
|
||||
Self.logger.error("card windows did not unregister within the drain deadline; closing anyway")
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 2. The store's pipeline, then the editor saves, then the pending commit — 02's order,
|
||||
/// stated once.
|
||||
private func flushPendingWork() async {
|
||||
await storeFlush()
|
||||
await editorFlush?()
|
||||
await committerFlush?()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user