Files
lanework/Kanban/App/AppModel.swift
T
rzen e1f89d9cf9 The subscription machinery leaves the code — Kanban/Tier excised, StoreKit wiring unwound
The 2026-08-08 one-version ruling (12-editions.md ▸ PIVOT 2026-08-08) carried out: Kanban/Tier/
deleted wholesale (Tier, ProEntitlement, ProProducts, ProStorefront, the never-rendered
ProSettingsSection) with TierTests and Configuration.storekit, whose project.yml resource entry
and scheme storeKitConfiguration go with it. AppModel loses the entitlement, the currentTier
seam, BoardSession.tier, and the purchase flow's reopenOpenBoards (its only caller was the
storefront); AppDelegate's launch keeps only the appearance application. The three tests
pinning the recorded tier and the reopen are deleted with their subject. The network-client
entitlement stays — the sync capability to come needs it regardless — and the HistoryProviding
seam stands untouched. 2,686 unit tests green (2,707 minus the 21 that tested what left).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 13:18:54 -04:00

1130 lines
63 KiB
Swift

import AppKit
import Observation
import SwiftUI
import os
// MARK: - Scene ids
/// The scene identifiers, in one place because they are matched by string in three unrelated
/// spots — the scene declaration, `openWindow(id:)`, and `dismissWindow(id:)` — and a typo in any
/// one of them fails silently at runtime.
public enum WindowID {
public static let welcome = "welcome"
public static let restoreBootstrap = "restore-bootstrap"
/// The template chooser (09-templates.md; File ▸ New Board… ⌥⌘N). Its own window rather than a
/// sheet on welcome because ⌥⌘N is available *everywhere* (11-command-nexus.md) — including from
/// a board window, and including when welcome is not open at all, which a sheet would have to
/// conjure a host for.
public static let templateChooser = "template-chooser"
public static let board = "board"
public static let card = "card"
}
// MARK: - App-wide preferences
/// The `UserDefaults` half of "App-wide state has the same home" (02-architecture.md § Per-board app
/// state): the app-scoped values that are scalars, kept out of the board registry because no board
/// owns them.
///
/// The keys are declared here rather than spelled at each `@AppStorage`, for the same reason
/// `WindowID` exists.
///
/// The domain is `UserDefaults.standard`, which the sandbox already scopes to this one app — the
/// same reason `AppStateHome` needs no bundle-id subfolder. A `@AppStorage` left to its own devices
/// reads exactly this domain, so nothing here has to be named at a binding site.
public enum AppPreferences {
/// "Restore open boards at launch" (Settings, ⌘, — 11-command-nexus.md). **Default on.**
public static let restoreOpenBoardsAtLaunchKey = "restoreOpenBoardsAtLaunch"
/// Read outside a view, where `@AppStorage` is not available — the launch flow needs it before
/// any scene exists. `object(forKey:)` rather than `bool(forKey:)` because the latter cannot
/// tell "off" from "never set", and this preference defaults to *on*.
public static var restoreOpenBoardsAtLaunch: Bool {
UserDefaults.standard.object(forKey: restoreOpenBoardsAtLaunchKey) as? Bool ?? true
}
/// The last-used card-window size (05-card-window.md; 02 files it as app-wide, not per-board —
/// "the last-used card-window size" is named there explicitly). Stored as a string because
/// `NSSize` is not a property-list type and two more keys would be worse.
public static let lastCardWindowSizeKey = "lastCardWindowSize"
public static var lastCardWindowSize: CGSize? {
guard let text = UserDefaults.standard.string(forKey: lastCardWindowSizeKey) else { return nil }
let size = NSSizeFromString(text)
guard size.width > 0, size.height > 0 else { return nil }
return size
}
public static func setLastCardWindowSize(_ size: CGSize) {
UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
}
// MARK: The comments pane's three bits
/// **View ▸ Show Comments** — "a checkmark toggle à la Show Trash, and its choice is **app-wide
/// and persisted across restarts**" (05-card-window.md ▸ The comments column, re-ruled
/// 2026-07-29; 11-command-nexus.md).
///
/// **One bit, and no content-derived auto-show**: checked, every card window carries the pane —
/// a comment-less card shows the empty thread and the composer, because the invitation is the
/// point; unchecked, threads and drafts are out of sight until the user says otherwise. The
/// checkmark reads exactly this value, so the menu never lies, and deleting the last comment
/// never closes the pane because nothing but this bit does.
///
/// **Default on.** 05 does not spell a default, and the two candidate readings pull in opposite
/// directions — the Show Trash bargain (a secondary surface, default off) against "the invitation
/// is the point" (a pane whose empty state is its whole argument). The invitation wins: a
/// comments feature nobody sees until they find a View-menu row is a feature that is not there,
/// and the user who does not want it turns it off once, forever, which is what the persistence is
/// for.
public static let showCommentsKey = "showComments"
public static var showComments: Bool {
UserDefaults.standard.object(forKey: showCommentsKey) as? Bool ?? true
}
/// **View ▸ Comments Beside Body** — "checked = side-by-side (default), unchecked = body over
/// comments; app-wide, persisted" (11-command-nexus.md; 05 ▸ Composition).
///
/// Default **on**, which 05 does state: "side-by-side is the default".
public static let commentsBesideBodyKey = "commentsBesideBody"
public static var commentsBesideBody: Bool {
UserDefaults.standard.object(forKey: commentsBesideBodyKey) as? Bool ?? true
}
/// The comments header's **sort-direction control** — "chronological ascending by default,
/// flippable to newest-first (app-wide, persisted)" (05 ▸ The comments column; 11 files it under
/// Configuration controls).
///
/// Stored as "newest first" rather than as a direction so the default is `false` and the plain
/// `bool(forKey:)` reading is the right one — the one preference here that does not need to tell
/// "off" from "never set".
public static let commentsNewestFirstKey = "commentsNewestFirst"
public static var commentsNewestFirst: Bool {
UserDefaults.standard.bool(forKey: commentsNewestFirstKey)
}
/// The quick-style row's recently-used backgrounds — an array of palette names / hex strings,
/// most-recent-first (03-board-ui.md § Styling ▸ Controls: "Recents are app-wide and persist
/// app-side (user preference, never board data)"; 11-command-nexus.md files it under the
/// preferences that "need no UI"). Read and written by `StyleRecents`, which owns the list rule;
/// the key is declared here with its neighbours for `WindowID`'s reason.
public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"
/// **The board's zoom level** — "app-wide and persisted across restarts" (11-command-nexus.md
/// ▸ View ▸ Actual Size; 03-board-ui.md ▸ Layout — zoom). A rung on `BoardZoom.levels`, stored as
/// the multiplier itself. Read and written by `BoardZoomStore`, which owns the ladder's rules; the
/// key is declared here with its neighbours for `WindowID`'s reason.
///
/// **Every read goes through `BoardZoom.normalize`**, and this one cannot use the
/// `object(forKey:) as? Double ?? default` idiom its neighbours use to tell "off" from "never set"
/// — the trap here is worse than an ambiguous default. `double(forKey:)` answers 0 for an unset
/// key, and 0 is not merely a wrong level: it drives every `BoardMetrics.em` multiple to its 1pt
/// floor and draws a board of hairlines. Normalising is what makes an unset, hand-edited or
/// stale-from-a-future-build value indistinguishable from a legal one downstream.
public static let boardZoomLevelKey = "boardZoomLevel"
// MARK: The appearance override
/// **View ▸ Appearance** (11-command-nexus.md) — Auto / Light / Dark, app-wide and persisted
/// across restarts (03-board-ui.md ▸ Toolbar). Read and written by `AppearanceStore`, which owns
/// the override's rules; the key is declared here with its neighbours for `WindowID`'s reason.
///
/// **Absent key = Auto.** Setting Auto removes the key rather than writing a third spelling of it
/// (the remove-at-default family — a default lane width and an empty rename both do the same), and
/// a stored string that is neither "light" nor "dark" — a hand edit, a future build's value read by
/// an older one — degrades to Auto rather than refusing to resolve.
public static let appearanceKey = "appearance"
/// The stored override, read the same lenient way `AppearanceStore.init` does. Not itself on that
/// type's read path — it takes its own injectable `defaults` rather than always reading
/// `.standard` — but declared here with a reader for the shape every other preference in this enum
/// keeps (`showComments`'s).
public static var appearance: AppAppearance? {
UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppAppearance.init(rawValue:))
}
}
// MARK: - Launch failures
/// A board that could not be restored or opened, as the welcome window renders it.
///
/// **A struct rather than the obvious tuple** only because SwiftUI needs identity to list these and
/// two failures can share a path (a board that failed, was retried, and failed again).
///
/// The join onto a recents row is `WelcomeRow.derive(recents:failures:)` — 02 § Launch and window
/// lifecycle wants the failure *on the board's row*, carrying fail-fast's specifics or the
/// unavailable state, and a failure naming no row (a first open of a folder that was never a board)
/// falls back to a list of its own. `path` is what the join matches on, which is why it is stored
/// rather than derived from the message.
public struct LaunchFailure: Identifiable, Sendable, Equatable {
public let id = UUID()
public let path: String
public let message: String
public init(path: String, message: String) {
self.path = path
self.message = message
}
/// What the row shows for a name: the folder, not the whole path. The path is the subtitle.
public var displayName: String {
URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent
}
}
// MARK: - Security-scoped access
/// One board's security-scoped access, held for the **whole session**.
///
/// `BoardRegistry.withScopedAccess(to:_:)` is the scoped-per-call form and is right for what it does
/// — resolving identities during a recents listing, where holding a scope open would be a leak. It is
/// exactly wrong for an open board: the store, the watcher, and every Writer call need access for
/// minutes or hours, and re-entering the scope per call would be both slower and racy against a
/// watcher thread that is already inside the folder.
///
/// So the pairing is explicit and its balance is the session's job: started when the board's window
/// opens, stopped in the close flush's teardown step. A class rather than a struct so the balance
/// cannot be duplicated by a copy.
///
/// **The URL matters, not the path.** A security-scoped URL is a token, not a string: a `URL`
/// rebuilt from `ref.path` grants nothing, which is why `AppModel.openBoard(at:)` stashes the
/// resolved URL for the host that is about to appear instead of letting it reconstruct one.
public final class ScopedAccess {
public let url: URL
private var started: Bool
public init(_ url: URL) {
self.url = url
// `false` for a URL that is not security-scoped — a plain bookmark's, one the open panel
// already blessed for the app's lifetime, anything inside the container. There is then
// nothing to stop, and the pairing stays balanced either way.
started = url.startAccessingSecurityScopedResource()
}
public func stop() {
guard started else { return }
started = false
url.stopAccessingSecurityScopedResource()
}
}
// MARK: - OpenOrigin
/// **Whether a person asked for this board right now** (01-storage-format.md § Malformed input, the
/// decision surface, settled 2026-07-31):
///
/// > It appears on **attended opens only** (welcome click, File ▸ Open…, Finder): restoration
/// > failures keep the retire-to-welcome-row landing, and the row's retry click is the attended open
/// > that then shows the surface — repair is an attended act, and launch never chains dialogs.
///
/// So this is not a description of *where* an open came from — it is the one bit that decides what a
/// failed one does. A closed two-case vocabulary rather than a `Bool` because the sentence a reader
/// needs at the branch is "restored boards retire", not "`isAttended` is false".
///
/// **Attended is the default everywhere**, and that is load-bearing: welcome's rows, File ▸ Open…,
/// the Finder open, Duplicate's follow-on open and the template chooser's are all somebody clicking
/// something. Exactly one caller says otherwise — launch restoration (`RestoreBootstrapView`) — which
/// makes "did a person ask for this" a question one place answers rather than a flag every call site
/// has to get right.
public enum OpenOrigin: Sendable, Equatable {
/// A person just asked for this board.
case attended
/// Launch restoration reopening what was open last time. Nobody is waiting on it, and a failure
/// lands on welcome's row rather than in a surface.
case restored
}
// MARK: - AppModel
/// The app's one piece of cross-window state: which boards are open, which card windows belong to
/// which board, and the two window actions AppKit-side code needs but cannot reach.
///
/// ### What lives here, and why it is not a singleton
///
/// The two registries (02-architecture.md § Layering ▸ Components and § Per-board app state) are
/// owned here because they are app-scoped and because "the app holds one instance, so a test can
/// hold its own without the two colliding" — `BoardStoreRegistry`'s own note. Everything else here is
/// window bookkeeping that has no other home: a `BoardStore` knows nothing about windows by design,
/// and a SwiftUI scene is a value that cannot hold state across a window's life.
///
/// ### Sessions are the join
///
/// A `BoardSession` is what makes the two halves of the app meet: the store the windows share, the
/// registry record they stamp, the card windows the close flush has to close first, and the
/// security-scoped access the whole thing runs inside. Its lifetime is exactly the board window's —
/// created when the host's load succeeds, removed by the close flush's last step. A card window with
/// no session is a card window with no board, which 02's ownership rule says cannot exist; the card
/// host reads that as "dismiss".
@MainActor
@Observable
public final class AppModel {
// MARK: Registries
public let storeRegistry = BoardStoreRegistry()
public let boardRegistry: BoardRegistry
/// The quick-style row's app-wide recents (03-board-ui.md § Styling ▸ Controls). Owned here for
/// the registries' reason — app-scoped, and a test holds its own rather than colliding with the
/// app's — and reached by the context menus through the environment, since a `BoardStore` is
/// board-scoped and this list deliberately is not.
public let styleRecents: StyleRecents
/// The board's app-wide zoom level (03-board-ui.md ▸ Layout — zoom). Owned here for
/// `styleRecents`' reason exactly: app-scoped, persisted beside it, and reached by every board
/// window through the environment — while the menu rows and the toolbar buttons, which live
/// outside every scene's environment, reach it through this object.
public let zoom: BoardZoomStore
/// The app-wide appearance override (11-command-nexus.md ▸ View ▸ Appearance; 03-board-ui.md ▸
/// Toolbar). Owned here for `zoom`'s reason exactly: app-scoped, persisted beside it, and reached
/// by the View-menu picker and the board-toolbar item alike — both live outside a board's own
/// environment (the menu bar entirely, the toolbar through `WindowToolbarController`), so an
/// `@Observable` object both can hold is the only thing keeping them from becoming two answers to
/// one question.
public let appearance: AppearanceStore
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
///
/// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
/// source board hides the dragged items while any other open board's drop delegates propose a
/// landing spot for them. It lives here rather than as a global for `styleRecents`' reason — a
/// test holds its own rather than colliding with the app's — and every board window reaches it
/// through the environment.
/// Internal rather than `public`, unlike its neighbours: the drag is entirely a UI-layer
/// concern, and nothing outside this module has any business reaching into a gesture in flight.
let dragSession = DragSession()
/// The app's one clipboard (04-interactions.md ▸ Clipboard).
///
/// App-wide for the drag session's reason turned up a level: a cut/copy **outlives the board it
/// came from** — the pasteboard and the staged snapshot survive the source window closing, and
/// survive the app quitting — so nothing board-scoped could own it. It lives here rather than as
/// a singleton for `styleRecents`' reason (a test holds its own rather than colliding with the
/// app's, which for this one also means staying off the machine's real pasteboard), and every
/// board window reaches it through the environment.
///
/// Building it here is also the **launch sweep** (04: "a sweep at launch"): the store's `init`
/// reads the pasteboard once and collects every staged tree it no longer names.
public let clipboard: ClipboardStore
// MARK: The provider seam
/// **The composition root for `HistoryProviding`** (12-editions.md ▸ The provider seam): what a
/// board session's undo stack is built by, called once per board as its session begins.
///
/// **Every board gets the native stack, and the seam has one answer**
/// (13-native-undo.md's header; `strategy/01-git-excision.md`, ruled 2026-08-08 — the app-managed
/// git substrate is excised, so `Kanban/History/` is the only one there is). Nothing about a board
/// decides this any more: not what the user paid (12-editions.md ▸ PIVOT 2026-08-08 — one
/// version, everything free, no edition axis left to consult), not whether it sits inside
/// somebody's repository, not what is on disk beside it. A board nobody has done anything
/// special to and a board living in a user's git repo bind the same stack, which was already
/// true before this ruling and is now true by construction.
///
/// ### Why it is still a seam
///
/// Deliberately kept, and the excision plan says so in as many words (▸ Reversibility: "the seams
/// the git stack plugged into … are all nil-safe/default-native and are **kept**, so a future
/// provider — journal, ops service, or even git again — re-binds without re-plumbing"). Two things
/// it buys today: a test binds a fake substrate — or a substrate-less board — without a second
/// `AppModel` initializer, and the day a second provider exists it arrives as a different default
/// here rather than as a branch threaded through the session.
///
/// It takes the store because that is what a provider is a history *of*: the native stack's steps
/// are computed from that store's snapshots, and a provider that needed the board root would find
/// it there too. `@ObservationIgnored` because nothing renders from it.
///
/// **The production closure never answers `nil`**, and the optionality is the seam's rather than a
/// board's: `nil` means "no undo at all", which no board the app composes is
/// (`BoardUndoManager.history` answers the empty way over an absent substrate, which is what makes
/// a test able to bind one).
///
/// ### Consumers
///
/// `beginSession`, once per board. Nothing re-binds a live session's substrate: the one event that
/// used to — add-git's commanded mid-session mode flip — went with the git stack.
@ObservationIgnored
public var makeHistoryProvider: (BoardStore) -> (any HistoryProviding)? = { _ in
NativeHistoryProvider()
}
// MARK: Sessions
/// One open board window and everything hanging off it.
public struct BoardSession {
/// The shared store — the same object every one of this board's windows renders.
public let store: BoardStore
/// Which registry record this board is, so the close flush can stamp counts and clear the
/// open-now flag without matching by identity a second time.
public let recordID: UUID
/// This board's undo/redo substrate — **the board half of 13-native-undo.md ▸ Rules' two
/// levels** (re-ruled 2026-07-31): one stack per board session, carrying board-surface
/// gestures and the one coarse step each card window's close registers. A card window's own
/// fine-grained stack is not here and never was the session's (`CardWindowUndo`, held by the
/// window). It lives here for the store's reason exactly: the session is what every window
/// over this board shares, and "undo is board-local".
///
/// Which implementation it is comes from one place — see `AppModel.makeHistoryProvider`,
/// which since the 2026-08-08 git excision has exactly one answer for every board.
///
/// **`nil` is a board with no undo at all, and no board the app composes is one**. What keeps
/// the optionality is the seam rather than a board: a test binds a substrate-less session
/// through `makeHistoryProvider`, and a store with no session at all registers nothing
/// (`BoardStore.registerStep`). The command surface disables through `undoManager`, which
/// answers the empty way over an absent substrate.
///
/// A `var` rather than a `let`, and now for no event at all: the one sanctioned mid-session
/// substrate swap was add-git's commanded mode flip, which went with the git stack. It stays
/// a `var` because a second provider is a live possibility (`strategy/01-git-excision.md` ▸
/// Reversibility) and because nothing is bought by tightening it.
public var history: (any HistoryProviding)?
/// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
/// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
/// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
///
/// Built once with the session rather than per window, because a second adapter would be a
/// second answer to "what is this board's undo" — and card windows share this one.
let undoManager: BoardUndoManager
/// This board's open card windows. The close flush's step 1 reads it; the card hosts
/// maintain it. Empty is the common case.
public var cardRefs: Set<CardWindowRef> = []
/// The scope the board is being read and written inside, released at teardown. `nil` when
/// the board was opened from a URL that needed none.
var access: ScopedAccess?
}
/// Keyed by board window, because that is the thing whose lifetime a session shares.
///
/// Observed: a card window watches for its board's session disappearing and dismisses itself when
/// it does — the safety net behind "card windows never outlive the board window".
public private(set) var sessions: [BoardWindowRef: BoardSession] = [:]
/// The end-session hooks, keyed the same way the card windows are.
///
/// Beside `BoardSession.cardRefs` rather than inside it: the set is *membership* (what the close
/// flush drains and what the safety net checks), this is the *seam table* (what it calls). They
/// are only ever written together, by the two register/unregister methods below, which is what
/// keeps them from becoming two answers to one question.
@ObservationIgnored
private var cardSessions: [CardWindowRef: any CardSessionFlushing] = [:]
/// Boards whose close flush is already running — the re-entrancy guard.
///
/// Needed because a board window can be told to close twice in quick succession: the
/// `windowShouldClose` interception starts the flush, and the host's own disappear runs a second
/// attempt as its safety net. The second must not re-enter a sequence that is mid-await.
@ObservationIgnored
private var closingBoards: Set<BoardWindowRef> = []
// MARK: Window actions
/// SwiftUI's window-opening action, captured from whatever scene view is alive.
///
/// It exists because the two things that most need to open a window are not views:
/// `AppDelegate.applicationShouldHandleReopen` (a Dock click with no windows must show welcome)
/// and the close-flush coordinator (which dismisses card windows). Neither can read
/// `@Environment`. The action stays valid after the view that supplied it is gone — it is a value
/// addressed to the app, not to a window — which is precisely the windowless case it is for.
///
/// `@ObservationIgnored` on both: nothing renders from them, and an assignment on every scene's
/// appear would otherwise invalidate every observer for no reason.
@ObservationIgnored
public var windowOpener: OpenWindowAction?
@ObservationIgnored
public var windowDismisser: DismissWindowAction?
/// URLs handed to `openBoard(at:)` before `windowOpener` existed to open them — a cold launch's
/// Finder-open (`AppDelegate.application(_:open:)`) can arrive ahead of the first scene's
/// `onAppear`. Held in order, replayed the moment `captureWindowActions` gives the app somewhere
/// to open them, then discarded — the buffer is a doorway, not a second registry of intent.
@ObservationIgnored
private var pendingOpenURLs: [URL] = []
/// What `CaptureOpenWindow` calls. A method rather than two assignments so the launch flow, which
/// needs the actions before any `onAppear` has run, has one thing to call.
///
/// Returns how many buffered Finder-open URLs it replayed — the restore bootstrap's input: a
/// launch that already opened a document's board must not put welcome up beside it, and only this
/// method knows the buffer wasn't empty.
@discardableResult
func captureWindowActions(open: OpenWindowAction, dismiss: DismissWindowAction) -> Int {
windowOpener = open
windowDismisser = dismiss
guard !pendingOpenURLs.isEmpty else { return 0 }
let urls = pendingOpenURLs
pendingOpenURLs.removeAll()
for url in urls {
openBoard(at: url)
}
return urls.count
}
// MARK: Recents
/// The recents list, cached: what the welcome window renders and what File ▸ Open Recent lists
/// (02-architecture.md § Per-board app state — "The recents list *is* this registry sorted by
/// last-opened").
///
/// **Cached rather than read through on demand, and both halves of that are deliberate.**
/// `BoardRegistry` is not `@Observable`, so a view reading it directly would never learn that a
/// row was forgotten; and `recents()` resolves every record's bookmark, which is filesystem work
/// no SwiftUI body should be doing on every evaluation — the File menu's command graph is
/// rebuilt far more often than this list changes. So the list lives here as observable state and
/// every path that can change the registry refreshes it explicitly (`refreshRecents()`).
///
/// The honest residual: a registry mutated behind this object's back would show stale until the
/// next refresh. There is no such path today — every writer goes through this type or through a
/// session it owns — and welcome refreshes on appearance as the cheap belt-and-braces.
public private(set) var recents: [RecentBoard] = []
/// Re-reads the registry into `recents`. Called wherever the registry changes: a board opening,
/// a board closing (the counts are stamped there), Forget, Clear Menu, and welcome appearing.
public func refreshRecents() {
recents = boardRegistry.recents()
}
/// The welcome row's Forget (11-command-nexus.md ▸ Welcome recent) — the record, plus any launch
/// failure that row was carrying, plus the refresh, in one call so no caller can do one without
/// the others.
///
/// **Forgetting the board forgets the failure too.** The row *is* the failure's surface (02
/// § Launch and window lifecycle); dropping the row while keeping the failure would relocate its
/// message into the unmatched-failures list, which reads as the app declining to forget.
public func forget(boardID: UUID) {
clearLaunchFailures(naming: knownPaths(ofBoard: boardID))
boardRegistry.forget(id: boardID)
refreshRecents()
}
/// File ▸ Open Recent ▸ Clear Menu (11-command-nexus.md).
///
/// **Finder clears the *menu*; here the registry is the menu**, so clearing removes every record
/// — there is no second list to clear, and a "menu" that still knew about the boards it had
/// stopped listing would be a distinction with no surface. What that costs is per-board settings
/// (window frames, push-on-commit) for boards the user reopens later, which is exactly what
/// Forget costs one row at a time and what 02's "its settings are conveniences" already accepts.
///
/// It is Forget applied wholesale, so it clears failures the same way — the ones naming records,
/// leaving a failure that named no row (and therefore no menu entry) standing in its own list.
///
/// A board that is open right now keeps working: its session holds a record id that no longer
/// resolves, and `BoardRegistry.update` treats an unknown id as a no-op for precisely this case.
public func clearRecents() {
clearLaunchFailures(naming: Set(recents.flatMap { recent in
[recent.record.lastKnownPath, recent.url?.path].compactMap { $0 }
}))
boardRegistry.forgetAll()
refreshRecents()
}
/// Every path a given record is known by — the one it was last seen at and, when its bookmark
/// still resolves, where it lives now. The two can differ (a bookmark follows a move), and a
/// failure recorded before the move names the older one.
private func knownPaths(ofBoard id: UUID) -> Set<String> {
var paths: Set<String> = []
if let record = boardRegistry.record(id: id) {
paths.insert(record.lastKnownPath)
}
if let url = recents.first(where: { $0.record.id == id })?.url {
paths.insert(url.path)
}
return paths
}
// MARK: Launch failures
/// Boards that failed to restore or open, newest last. Rendered on their own recents rows where
/// one exists, and in a fallback list where none does — `WelcomeRow.derive(recents:failures:)`.
public private(set) var launchFailures: [LaunchFailure] = []
// MARK: Card-window placement
/// Where the next card window cascades from (05-card-window.md, "New windows open at the
/// last-used card-window size, cascaded").
///
/// `NSWindow.cascadeTopLeft(from:)` is the whole mechanism: passing `.zero` places the window at
/// its natural position and returns the point for the next one, so this is a running cursor
/// rather than a computed grid. App-wide, not per-board: two boards' card windows cascade past
/// each other rather than landing on top of one another.
@ObservationIgnored
var cardCascadePoint: NSPoint = .zero
// MARK: Pending opens
/// Everything `openBoard(at:origin:)` knows that a `BoardWindowRef` cannot carry.
///
/// **One struct rather than two parallel dictionaries** (the shape this replaced was
/// `pendingAccess` alone): both facts are stashed by the same call, claimed by the same call, and
/// meaningless apart — a window that found an origin but no access, or the reverse, would be a
/// bug with no honest reading. Keeping them in one value makes "they are always in step" true by
/// construction instead of by two `removeValue`s that must not drift.
private struct PendingOpen {
/// The security-scoped URL the board will be built from, or `nil` where the open needed no
/// scope. See `ScopedAccess`: a `URL` rebuilt from `ref.path` grants nothing.
let access: ScopedAccess?
/// Whether a person asked for this board — what a failed open branches on (`OpenOrigin`).
let origin: OpenOrigin
}
/// What a board window is about to be built from, stashed between `openBoard(at:origin:)` and the
/// host's first appearance.
///
/// The handoff exists because a window value has to be `Codable` and neither of these is a
/// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone,
/// and the origin was never in the ref at all. The host claims both on appear; an unclaimed entry
/// (a window that never opened) leaks one scope until quit, which is the cheapest failure
/// available here.
@ObservationIgnored
private var pendingOpens: [BoardWindowRef: PendingOpen] = [:]
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model")
/// The app builds one of these with the real state home; a test passes its own for the reason
/// `BoardRegistry` takes a storage URL at all — "injecting it is how a test stays out of the real
/// Application Support directory" (`AppStateHome`). A suite that swept the real staging root
/// would be sweeping the developer's own clipboard.
///
/// `clipboardStagingRoot` is a separate parameter rather than derived from `registryStorageURL`'s
/// folder because the two are injected for different reasons and by different callers: the UI-test
/// fixture launch redirects both into one scratch root (`UITestLaunch`), a unit test usually wants
/// only one of them, and deriving would silently move a test's staging directory the day it moved
/// its registry file.
public init(
registryStorageURL: URL = BoardRegistry.defaultStorageURL,
clipboardStagingRoot: URL = ClipboardStore.defaultStagingRoot,
preferences: UserDefaults = .standard
) {
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
styleRecents = StyleRecents(defaults: preferences)
zoom = BoardZoomStore(defaults: preferences)
appearance = AppearanceStore(defaults: preferences)
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
// Read once here rather than lazily, so File ▸ Open Recent is populated from the app's first
// menu pass — a launch that restores boards never shows welcome, and a submenu that filled
// in only after the first close would look broken. It costs one bookmark-resolution sweep at
// launch, next to the one `restorables()` already runs.
refreshRecents()
}
// MARK: - Launch restoration
/// The launch-restoration gate, as a pure function (02-architecture.md § Launch and window
/// lifecycle: "the preference gates only whether the flagged set is consulted; the flags are
/// maintained regardless").
///
/// `KanbanApp.init()` is where this actually runs — read once, before any scene exists, into a
/// `let` rather than a computed property, because `restorables()` costs a bookmark resolution per
/// known board and nothing should pay that on every scene-graph evaluation. An `App`'s `init` is
/// not itself reachable from a test, so the decision is pulled out to here: two `Bool`s in, one
/// out, provable without a real `UserDefaults` domain or a live registry.
///
/// `nonisolated` because it is exactly as pure as that sentence claims — it touches no stored
/// state, and `LaunchPlan.decide` (which is not main-actor-bound either, for the same reason)
/// composes it into the three-way launch decision.
public nonisolated static func shouldRestoreAtLaunch(preference: Bool, hasRestorables: Bool) -> Bool {
preference && hasRestorables
}
// MARK: - Opening
public var hasOpenBoards: Bool { !sessions.isEmpty }
/// Opens a board window for `url`, or focuses the one this board already has.
///
/// **The already-open check is by file identity, not by path** — `liveStore(for:)` resolves it —
/// so a board reached through a resolved bookmark and the same board reached through the open
/// panel land on one window even when the two URLs are spelled differently. Only when nothing is
/// open for it does a ref get minted, and `openWindow(value:)` with an equal ref focuses rather
/// than duplicates, which is the second half of "one board window per root".
///
/// Security-scoped access starts here, *before* the window exists, because the host's very first
/// act is a tree walk: a scope started after the load would be too late.
///
/// **Called before any scene has appeared, and that's fine.** A cold launch's Finder-open can
/// reach here before `windowOpener` is captured; the URL joins `pendingOpenURLs` and this same
/// method runs again for it once `captureWindowActions` has something to open it with.
///
/// - Parameter origin: whether a person asked for this board right now (`OpenOrigin`) — the one
/// bit a *failed* open branches on (01-storage-format.md § Malformed input: the decision
/// surface "appears on attended opens only"). `.attended` by default, which is every caller but
/// launch restoration: welcome's rows, File ▸ Open…, the Finder open, the template chooser's
/// follow-on, Duplicate's. The default is the rule stated once rather than repeated five times.
public func openBoard(at url: URL, origin: OpenOrigin = .attended) {
guard let windowOpener else {
// **The queue carries no origin, and needs none**: it is reachable only *before any scene
// exists*, which is the cold-launch Finder/URL open and nothing else — restoration
// captures the window actions as its first act (`RestoreBootstrapView.restore`) and so can
// never queue. Everything in here is therefore attended, which is what the replay's
// default gives it.
pendingOpenURLs.append(url)
return
}
if storeRegistry.liveStore(for: url) != nil, let existing = boardRef(forBoardAt: url) {
windowOpener(id: WindowID.board, value: existing)
return
}
let ref = BoardWindowRef(url: url)
stashPendingOpen(for: ref, url: url, origin: origin)
windowOpener(id: WindowID.board, value: ref)
}
/// Stashes what the window about to appear will claim — the handoff's write half.
///
/// A method of its own rather than two lines inside `openBoard(at:origin:)` because that method
/// cannot run without SwiftUI's `OpenWindowAction`, which is not a thing a test can construct: the
/// carrier would otherwise be the one part of the attendance plumbing with no headless proof, and
/// an origin that quietly stopped travelling would look exactly like the app before this
/// milestone.
func stashPendingOpen(for ref: BoardWindowRef, url: URL, origin: OpenOrigin) {
// Replacing a stash for the same ref would strand the old scope; there is no such case today
// (an unopened window's ref is not reachable), but stopping the loser is free.
pendingOpens.removeValue(forKey: ref)?.access?.stop()
pendingOpens[ref] = PendingOpen(access: ScopedAccess(url), origin: origin)
}
/// Shows — or focuses — the welcome window. Its own scene id, so this works with no windows at
/// all, which is the Dock-reactivation case (02: "Reactivation (Dock click) with no windows shows
/// welcome").
public func showWelcome() {
windowOpener?(id: WindowID.welcome)
}
/// File ▸ New Board… (⌥⌘N) — shows, or focuses, the template chooser (09-templates.md).
///
/// The command opens a *chooser*, never a board: the location is the save panel's question and
/// the panel is the chooser's, so this method's whole job is the window.
public func showTemplateChooser() {
windowOpener?(id: WindowID.templateChooser)
}
/// The standard open panel behind File ▸ Open… ⌘O (11-command-nexus.md).
///
/// **Validation is the open attempt itself** — there is no pre-flight check that a folder is a
/// board. Fail-fast owns that verdict (01-storage-format.md § Malformed input) and it is the same
/// verdict a restored board gets, so a folder that is not a board produces one error in one
/// vocabulary rather than two near-identical rejections in two.
///
/// `treatsFilePackagesAsDirectories` is what lets a `.kanban` package be *chosen* while
/// `canChooseFiles` stays off: a package is a file to the panel otherwise, and boards are both
/// packages and plain folders (01 § Board naming). The cost is that double-clicking a package
/// navigates into it, which the welcome window's own open affordances will make moot.
public func presentOpenPanel() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.treatsFilePackagesAsDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "Open"
panel.message = "Choose a board folder."
guard panel.runModal() == .OK, let url = panel.url else { return }
openBoard(at: url)
}
/// The ref of the window already showing the board at `url`, if any — matched through the store,
/// which is identity-keyed, rather than through the path.
private func boardRef(forBoardAt url: URL) -> BoardWindowRef? {
guard let store = storeRegistry.liveStore(for: url) else { return nil }
return sessions.first { $0.value.store === store }?.key
}
// MARK: - Sessions
public func session(for ref: BoardWindowRef) -> BoardSession? {
sessions[ref]
}
/// Claims what `openBoard(at:origin:)` stashed for this window. Claiming removes it: the session
/// owns the scope's balance from here.
///
/// A window that opened by some other route — a route that never went through `openBoard` — gets
/// no scope and reads as **attended**, which is the safe direction: the worst an attended reading
/// can do to a failed open is offer the user a repair they did not ask for, where the reverse
/// would silently retire a board somebody just double-clicked.
func claimPendingOpen(for ref: BoardWindowRef) -> (access: ScopedAccess?, origin: OpenOrigin) {
guard let pending = pendingOpens.removeValue(forKey: ref) else { return (nil, .attended) }
return (pending.access, pending.origin)
}
/// Starts a board's session — the board window's host calls this once its load has succeeded.
///
/// Two bookkeeping consequences of "this board is now open" ride along. The recents list is
/// re-read, because `recordOpen` just moved this board to the top of it. And any launch failure
/// naming this board is dropped: the board demonstrably opens, so a row still captioned with the
/// old error would be reporting a condition that has stopped being true. That is not the silent
/// drop 02 forbids — it forbids a failure that was never surfaced disappearing, not one the user
/// has since fixed.
func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
// below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with
// the board session and dies at close/quit ... standard macOS behavior".
let history = makeHistoryProvider(store)
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
// store is that boundary — every app-mediated mutation goes out through one of its write
// methods — so it is the store that computes each inverse and registers it. What it cannot
// know is *which* stack, because a stack belongs to a session and a store knows nothing about
// windows; this line is where the session tells it. Weak on the store's side, so the loop
// this closes (provider → step closures → store) is not a retain cycle.
store.history = history
sessions[ref] = BoardSession(
store: store,
recordID: recordID,
history: history,
// The lock's enablement half (13-native-undo.md ▸ Rules): Undo and Redo disable with the
// other mutating commands while the board refuses writes, and the stack survives to
// resume when it clears. Weak, so the adapter is never the reason a closed board's store
// stays alive; a store that has gone answers "writable", which is moot — its stack went
// with it.
undoManager: BoardUndoManager(history: history, isReadOnly: { [weak store] in
store?.isReadOnly ?? false
}),
cardRefs: [],
access: access
)
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
refreshRecents()
}
/// **The three buttons, as a seam** — `SessionSettleStep.ask(message:)` in production.
///
/// `SessionSettleGate` already keeps the presentation behind a closure for its own reason
/// ("presenting three buttons is AppKit's job and cannot be asserted without a display … the
/// presentation is a seam and the decision is testable"), and every gate this model builds pointed
/// that closure straight at the alert — so the *composition* around the gate, which is what
/// `discardCardWindowUndoStacks(for:)` hangs off, could only be exercised by a board with nothing
/// to settle. Lifting the ask one level up is what lets a test answer Save All, Discard and Cancel
/// over real card windows without a modal on screen.
///
/// `@ObservationIgnored` because nothing renders from it, and internal because it is a test seam
/// rather than API: production never assigns it.
@ObservationIgnored
var settleAsk: @MainActor (String) async -> SessionSettleChoice = {
await SessionSettleStep.ask(message: $0)
}
/// **The save-or-discard step for one board**, built from its open card windows
/// (`SessionSettleGate`).
///
/// Built per ask rather than stored, because its whole content is "which card windows are open
/// right now" — a set that changes under any operation slow enough to need the step at all.
///
/// **No production caller today** — the git restore and branch switch were the two, and both went
/// with the git stack (`strategy/01-git-excision.md`). The composition is kept beside the gate it
/// composes, for the gate's own reason: it is what the next wholesale operation binds to.
///
/// - Parameters:
/// - message: what the step says it is about. Different operations describe different
/// consequences, and a step that described the wrong one would be a worse modal than none.
/// - didDiscard: told each card folder the Discard branch abandoned, so the operation behind the
/// gate can reconcile that folder's already-written bytes its own way.
func settleGate(
for ref: BoardWindowRef,
message: String = SessionSettleStep.message,
didDiscard: @escaping (String) -> Void = { _ in }
) -> SessionSettleGate {
SessionSettleGate(
sessions: { [weak self] in
guard let self, let session = self.sessions[ref] else { return [] }
return session.cardRefs.compactMap { cardRef in
guard let flushing = self.cardSessions[cardRef],
let settlement = flushing.settlement else { return nil }
return SettleableSession(
id: cardRef.cardID,
cardFolderName: cardRef.cardID,
needsSettling: settlement.needsSettling,
saveAll: settlement.saveAll,
discard: {
settlement.discard()
didDiscard(cardRef.cardID)
}
)
}
},
ask: { [weak self] in
guard let self else { return await SessionSettleStep.ask(message: message) }
return await self.settleAsk(message)
},
focus: { [weak self] id in
guard let self, let session = self.sessions[ref] else { return }
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
// Opening a window that is already open is how SwiftUI's value-addressed groups say
// "bring that one forward" — the same call `BoardWindowHost` makes to open a card, and
// the reason reopening a live card focuses its window rather than making a second one.
self.windowOpener?(id: WindowID.card, value: cardRef)
}
)
}
/// Registers a card window with its board's session, so the close flush can find it.
///
/// A card window whose board has no session is a card window with no board — the ownership rule
/// says that cannot exist, and the host's own check dismisses it before reaching this. Recording
/// the seam anyway would leave an entry nothing ever drains.
func registerCardWindow(_ ref: CardWindowRef, session: any CardSessionFlushing) {
guard sessions[ref.board] != nil else {
Self.logger.debug("card window registered against a board with no session — ignored")
return
}
sessions[ref.board]?.cardRefs.insert(ref)
cardSessions[ref] = session
}
func unregisterCardWindow(_ ref: CardWindowRef) {
sessions[ref.board]?.cardRefs.remove(ref)
cardSessions[ref] = nil
}
/// **A wholesale operation's settle empties every open card window's fine undo stack.**
///
/// The rule the branch switch established, kept as a rule about *any* operation that replaces the
/// tree under an open window: pre-operation steps describe a state that has gone, so Save All and
/// Discard alike end with every window's stack empty — the board-stack discard-and-reseed
/// precedent one level down; the windows stay open with fresh stacks. Cancel clears nothing, which
/// is why this hangs off a `.proceed` at the call site rather than off the ask.
///
/// **The board stack is not touched here**, and it is not an omission: an operation that replaces
/// the tree reseeds the board's stack itself, after the write that decides what to reseed *from*.
/// Doing it here would be the same discard at the wrong moment.
///
/// **A diff-shaped operation deliberately does not call this.** One that materializes only a diff
/// leaves a window's steps describing a state that is still mostly there; what protects them is
/// 13-native-undo.md's field-level staleness predicate, a per-step question rather than a
/// wholesale one.
///
/// **No production caller today** — it went with the branch switch (`strategy/01-git-excision.md`)
/// — and it is kept beside `settleGate(for:message:didDiscard:)` for that gate's reason.
///
/// The downcast is the honest shape rather than a shortcut: `CardSessionFlushing` is the *close
/// flush's* seam — end the session, say whether it holds unsaved content, offer the settle's two
/// writes — and a fine undo stack is none of those things. The one type that has one is the card
/// window's own session, which is what every registration passes.
func discardCardWindowUndoStacks(for ref: BoardWindowRef) {
for cardRef in sessions[ref]?.cardRefs ?? [] {
(cardSessions[cardRef] as? CardWindowSession)?.undo.discardSteps()
}
}
// MARK: - Launch failures
/// Records a board that could not be opened. Deliberately additive and never cleared on success:
/// welcome is showing *because* something failed, and a list that emptied itself as other boards
/// arrived would be the silent drop 02 rules out.
public func recordLaunchFailure(path: String, message: String) {
launchFailures.append(LaunchFailure(path: path, message: message))
}
/// Forgets the failures — the welcome window's dismissal of a list the user has read.
public func clearLaunchFailures() {
launchFailures.removeAll()
}
/// Forgets exactly the named failures — what the unmatched-failures list's Clear dismisses, so
/// that pressing it never also erases a message still standing on a recents row the user has
/// not looked at.
public func clearLaunchFailures(ids: Set<UUID>) {
launchFailures.removeAll { ids.contains($0.id) }
}
/// Drops every failure naming one of `paths` — the resolution path, used when a board opens
/// successfully and when its record is forgotten. Paths are compared the way the welcome row's
/// join compares them, so "this row's failure" means the same thing in both places.
private func clearLaunchFailures(naming paths: Set<String>) {
guard !paths.isEmpty else { return }
let keys = Set(paths.map(WelcomeRow.pathKey))
launchFailures.removeAll { keys.contains(WelcomeRow.pathKey($0.path)) }
}
// MARK: - Counts
/// The lane and card counts stamped into the registry at close — **live items only** (02
/// § Per-board app state, settled).
///
/// > deleted lanes and cards don't count; the row advertises the board's working size, and the
/// > trash is an errand, not inventory.
///
/// **`.trash/` is excluded by construction** (02-architecture.md § Per-board app state,
/// re-grounded 2026-07-28 for the materialized trash): this walks `snapshot.lanes`, and the
/// trash is `snapshot.trash` — a sibling container, never a lane — so no filter is needed and
/// none could be forgotten. The tombstone era's ancestor walk over `deleted:` flags is gone with
/// the flag; a board an older version wrote counts its unmigrated cards until the migration moves
/// them, which is the safe direction and lasts exactly one write.
///
/// Static and pure: it is a fact about a snapshot, and the close flush is the wrong place to
/// discover a counting bug.
public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) {
var lanes = 0
var cards = 0
for lane in snapshot.lanes {
lanes += 1
cards += lane.cards.count
}
return (lanes, cards)
}
/// The folder name, extension stripped (01-storage-format.md § Board naming) — `displayName`'s
/// own fallback, and (02-architecture.md § Per-board app state) the registry record's
/// *provisional* display name for a board recorded before its load has run: "fail-fast means the
/// frontmatter can't be trusted, and the folder name is the Finder document name the user just
/// picked". A first successful load replaces it with the cached title through the ordinary
/// `displayName(of:)` path — there is no separate provisional vocabulary, just this one fallback
/// used a moment earlier than usual.
public static func folderDisplayName(of url: URL) -> String {
url.deletingPathExtension().lastPathComponent
}
/// A board's display name: its `title`, falling back to the folder name sans extension
/// (01-storage-format.md § Board naming).
///
/// Read from `store.rootURL` rather than `snapshot.rootURL` so the fallback follows a rename the
/// moment it is absorbed, instead of lagging by one reload (see `BoardStore.rootURL`).
public static func displayName(of store: BoardStore) -> String {
if let title = store.snapshot.title.value, !title.isEmpty {
return title
}
return folderDisplayName(of: store.rootURL)
}
// MARK: - Closing
/// Runs the close flush for one board and tears its session down.
///
/// Idempotent by two guards: a board with no session has already closed, and a board already
/// mid-flush is not started again. Both matter — the window's close interception and the host's
/// disappear both call this, by design, because neither one alone fires on every path a window
/// can leave by.
public func closeBoard(ref: BoardWindowRef, cause: BoardCloseCause) async {
guard sessions[ref] != nil, !closingBoards.contains(ref) else { return }
closingBoards.insert(ref)
defer { closingBoards.remove(ref) }
await coordinator(for: ref).run(cause: cause)
// The flush stamped this board's counts and (on a user close) cleared its open-now flag, so
// the cached list is now one close out of date — and welcome is often the very next thing on
// screen.
refreshRecents()
}
/// The close flush's **pending-work step, without the teardown** — what File ▸ Duplicate runs
/// before it copies (03-board-ui.md § Welcome screen & templates: "The copy is preceded by the
/// close flush ... so neither the tree nor the copied history misses pending work").
///
/// **Not `closeBoard`**, and the design says so itself: 09-templates.md ▸ Save as Template states
/// the rule together with its exception — "with the pull-style mechanical exception committing an
/// open Edit session's on-disk saves as-is, **sessions staying open**". A duplicate leaves the
/// original on screen (03: "the original stays open too"), so what it needs is pending work
/// *landed on disk*, not a session ended: no card window is dismissed, no record is stamped
/// closed, nothing is torn down, and the board the user is looking at never blinks.
///
/// It goes through `CloseFlushCoordinator` rather than calling the store directly so that the
/// order of the three flushes — store pipeline, then editor saves, then the pending auto-commit
/// (02's own order) — keeps having exactly one definition.
public func flushPendingWork(for ref: BoardWindowRef) async {
guard sessions[ref] != nil else { return }
await coordinator(for: ref).flushPendingWork()
}
/// Whether any of this board's card windows is holding content the files do not have — a dirty
/// Edit buffer or a typed-in raw-source outlet (`CardSessionFlushing.holdsUnsavedContent`).
///
/// **One caller, one rule**: File ▸ Save as Template's carve-out from the read-only lock. Under
/// the unwritable-location lock the item stays live — "reads the board, writes Application
/// Support" — but only while no such session exists, because the lock has suspended exactly the
/// saves that would flush one and 09-templates.md's never-misses-keystrokes guarantee outranks
/// the item's availability (02-architecture.md ▸ Live-reload resilience, settled scoping).
///
/// It asks the sessions rather than the store: the content in question is in *memory*, in the
/// card windows, which is the whole reason the flush cannot reach it.
public func hasUnsavedCardContent(for ref: BoardWindowRef) -> Bool {
guard let session = sessions[ref] else { return false }
return session.cardRefs.contains { cardSessions[$0]?.holdsUnsavedContent == true }
}
/// Quit: the same sequence, once per open board, **sequentially**.
///
/// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than
/// three interleavings of it, and in a stable board order so a quit is reproducible. Nothing here
/// clears an open-now flag — that is what `.quit` means, and it is what makes the next launch
/// restore this set (§ Launch and window lifecycle).
public func flushAllBoardsForQuit() async {
for ref in sessions.keys.sorted(by: { $0.path < $1.path }) {
await closeBoard(ref: ref, cause: .quit)
}
}
/// Wires a session into `CloseFlushCoordinator`'s seams. The ordering lives over there; this is
/// only which real object each step touches.
private func coordinator(for ref: BoardWindowRef) -> CloseFlushCoordinator {
CloseFlushCoordinator(
openCardRefs: { [weak self] in
// Sorted so a board with several card windows commits and closes them in a stable
// order rather than a `Set`'s.
(self?.sessions[ref]?.cardRefs).map { $0.sorted { $0.cardID < $1.cardID } } ?? []
},
endCardSession: { [weak self] cardRef in
await self?.cardSessions[cardRef]?.endSession()
},
dismissCardWindow: { [weak self] cardRef in
self?.windowDismisser?(value: cardRef)
},
storeFlush: { [weak self] in
await self?.sessions[ref]?.store.awaitQuiescence()
},
// `editorFlush` stays nil, and now deliberately rather than for want of an editor: the
// card windows' debounced body saves flush in **step 1**, inside each window's
// `endSession()` (`CardWindowSession`), which is both earlier than this slot and where
// 02-architecture.md puts them ("each open Edit session ends with its normal session
// commit", then pending work). The slot stays for a board-level editor with no card
// window of its own — the raw-source buffer is the candidate — so that the order
// relative to `committerFlush` is already decided when one arrives.
//
// **`committerFlush` stays nil too**, and now for want of a committer rather than by
// policy: the auto-committer it drove went with the git stack
// (`strategy/01-git-excision.md`). The slot is 02's ordering statement — pending editor
// saves, then whatever records history — and it keeps that place for the successor.
recordClose: { [weak self] in
guard let self, let session = sessions[ref] else { return }
let counts = Self.liveCounts(of: session.store.snapshot)
boardRegistry.recordClose(
id: session.recordID,
displayName: Self.displayName(of: session.store),
laneCount: counts.lanes,
cardCount: counts.cards,
icon: session.store.snapshot.icon.value,
iconColor: session.store.snapshot.iconColor.value
)
},
clearOpenNow: { [weak self] in
guard let self, let session = sessions[ref] else { return }
boardRegistry.clearOpenNow(id: session.recordID)
},
tearDown: { [weak self] in
guard let self, let session = sessions.removeValue(forKey: ref) else { return }
// Session-only persistence, the other half of `beginSession` (13-native-undo.md
// ▸ Rules): "the stack ... dies at close/quit", so reopening the board starts empty.
// Cleared rather than merely dropped because the steps hold closures over the store
// this line is about to release, and a stack that outlived its board would be a
// retain cycle wearing an undo stack's clothes.
session.history?.clear()
storeRegistry.release(session.store)
session.access?.stop()
}
)
}
}