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 /// Whether this window is holding content the file does not have — a dirty Edit buffer, or an /// open raw-source outlet that has been typed in. /// /// Beside `endSession()` because it is the same fact from the other end: this is what the flush /// *would* write, asked before running it. Its one caller is File ▸ Save as Template's /// validation, which under the unwritable-location read-only lock stays live only "unless an /// open Edit or raw-source session holds unsaved content the suspended saves can't flush" /// (02-architecture.md ▸ Live-reload resilience; 09-templates.md ▸ Save as Template) — a /// template that silently missed those keystrokes would break 09's never-misses-keystrokes /// guarantee, which outranks the item's availability. var holdsUnsavedContent: Bool { get } } public extension CardSessionFlushing { func endSession() async {} var holdsUnsavedContent: Bool { false } } // 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. /// /// **Callable on its own**, which is the one place the sequence is entered part-way: File ▸ /// Duplicate needs pending work on disk before it copies but must leave the session standing /// (09-templates.md ▸ Save as Template's stated exception — "sessions staying open"). It reaches /// this step through `AppModel.flushPendingWork(for:)` rather than re-listing the three flushes, /// so their order still has one definition. public func flushPendingWork() async { await storeFlush() await editorFlush?() await committerFlush?() } }