Files
lanework/Kanban/App/AppModel.swift
T

1672 lines
99 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 cached subscription facts behind the tier decision (12-editions.md ▸ The entitlement) —
/// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`.
///
/// A scalar default rather than a file in `AppStateHome` because it is two fields, which is the
/// line that type's own note draws. **Not a secret and not a receipt**: the signed transaction
/// store is StoreKit's and stays StoreKit's; this is a *cache of the last answer* whose worst
/// case if edited by hand is one wrong tier until the next refresh corrects it, which is the
/// same self-correction a fresh install already relies on.
public static let subscriptionFactsKey = "subscriptionFacts"
}
// 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 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 entitlement
/// **Lanework Pro's entitlement** (12-editions.md ▸ The entitlement) — the cached, local answer
/// to "is this a subscriber?", owned here for the registries' reason: it is app-scoped, and a
/// test holds its own over its own defaults rather than colliding with the app's.
///
/// Nothing on the board-open path awaits anything through this object. See `ProEntitlement` for
/// why that is a property of its shape rather than a rule somebody has to remember.
public let entitlement: ProEntitlement
/// **The tier a board session composes under**, as an injectable seam.
///
/// Defaulted to the real entitlement's local read and separated from it for `makeHistoryProvider`'s
/// reason exactly: a test binds a tier without needing a StoreKit transaction, an App Store
/// account, or a second `AppModel` initializer. `@MainActor` on the closure type because the
/// entitlement it reads is main-actor state, and `@ObservationIgnored` because nothing renders
/// from it — the tier reaches the UI, where it reaches it at all, through `entitlement`.
///
/// **Read once per session, at composition, and never again** (12 ▸ The entitlement: "a lapse
/// never interrupts an open session"). `beginSession` is the only caller.
@ObservationIgnored
public var currentTier: @MainActor () -> Tier = { .free }
// 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.
///
/// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12 ▸ The
/// provider seam; 13-native-undo.md's header; 06 ▸ Rules): a board's substrate is decided by what
/// the board *is*, and the tier only decides whether git is on the table at all. The rule it
/// replaced bound the native stack free-tier-wide and nothing at all on Pro's gitless boards,
/// which made subscribing *remove* undo from a mode-none board — an upgrade that takes a feature
/// away.
///
/// It takes the store because that is what a provider is a history *of*: the git provider needs
/// the board root it is a repository at, and the native one's steps are computed from the same
/// store's snapshots. A property rather than an initializer argument so a test
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
/// nothing renders from it.
///
/// ### The two answers, and the `nil` that is no longer one of them
///
/// - **No `HistoryStore` at all** — the free tier, where `HistoryStore.compose` returns `nil`
/// without so much as a `stat`: the **native stack, on every board**. "The free tier binds it
/// everywhere (any `.git` inert)" (13), and 12 ▸ The free tier and `.git` names the boards that
/// covers by hand — "a formerly-subscribed user's board, a 1.x board, a repo-nested board …
/// native undo runs". The absent git state *is* the tier test; nothing here reads a flag.
/// - **Mode `git`** (Pro only — no other tier composes a git state) — the git provider: undo as
/// forward restore commits over HEAD's first-parent ancestry (06).
/// - **Mode `none`, `repoNested`, and `unverifiable` alike** — the **native stack**, exactly as
/// in the free tier. "Boards without app-managed git — repo-nested included — bind
/// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog,
/// re-ruled 2026-07-31 twice; 12 ▸ The provider seam; 13's header). `unverifiable` joins the
/// same branch structurally — a denial can never be told apart from a repository actually
/// being there, so it takes `repoNested`'s posture, undo included (06 ▸ Rules ▸ Detection).
///
/// **The repo-nested no-undo case is gone** (re-ruled 2026-07-31): 06's leave-strictly-alone
/// stance "concerns *git*, and this stack never touches git — memory-only, journal-free,
/// session-scoped — so what repo-nested denies is app-managed history, never ⌘Z" (13's header).
/// It also made the Pro upgrade story exceptional, which was the other half of the same defect:
/// the free tier could not tell such a board from a plain one and bound the native stack anyway,
/// so subscribing *removed* undo from exactly the boards it left alone. Edit ▸ Undo/Redo and the
/// toolbar pair now disable only under a lock and on an empty stack.
///
/// This closure therefore never answers `nil`, and the seam stays optional for the seam's own
/// reason: a test binds a substrate-less board through it (`BoardUndoManager.history`).
///
/// The `HistoryStore` argument is what makes the git/gitless split decidable here, and it is why
/// `beginSession` composes the git state *before* the provider: which substrate a board gets is a
/// question about its repository, and a root that had to ask the disk itself would be a second
/// detection.
///
/// ### Consumers
///
/// `beginSession` calls it once per board, and `bindHistoryProvider(for:)` calls it again on the
/// one event that changes a board's answer under an open session — add-git's commanded flip,
/// which swaps `none`'s native stack for `git`'s trail.
@ObservationIgnored
public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in
guard let git else { return NativeHistoryProvider() }
switch git.mode {
case .git: return GitHistoryProvider(boardRoot: store.rootURL)
case .none, .repoNested, .unverifiable: return 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, is the tier's answer and nobody else's
/// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`.
///
/// **`nil` is a board with no undo at all, and no board the app composes is one any more**
/// (re-ruled 2026-07-31 — see `AppModel.makeHistoryProvider`): boards without app-managed git,
/// repo-nested included, bind the native stack in every tier, and git boards bind the git
/// provider. 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`, unlike `tier` beside it, and for one event only: **add-git**, the design's single
/// sanctioned mid-session mode flip, *swaps* the substrate here on the board it flips —
/// native out, git in, the in-session steps discarded with it
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it — `tier` has no setter.
public var history: (any HistoryProviding)?
/// **The tier this board composed under** (12-editions.md ▸ The entitlement).
///
/// A `let`, on a value type, set once by `beginSession` — which is the entire mechanism
/// behind "a lapse never interrupts an open session: an open board finishes with the provider
/// it composed; the next open composes the native stack over inert `.git`". There is no
/// setter, no observation, and nothing anywhere that re-evaluates a live session's tier: a
/// subscription ending mid-session is a fact about the *next* open and about nothing that is
/// already on screen.
///
/// It is recorded rather than merely used-and-discarded because the provider it selects is
/// not the only thing that will ever ask. pro-m1's surfaces — the card window's History
/// section, View ▸ History (12 ▸ Tier matrix) — are per-board questions asked long after
/// composition, and they must get the answer this board actually opened with rather than
/// whatever the entitlement happens to say when the sidebar renders.
public let tier: Tier
/// **This board's git state** (06-history-undo.md ▸ Rules; 02-architecture.md ▸ Components
/// ▸ HistoryStore) — the detected mode, the repository behind it in git mode, and the
/// add-git action the popover offers on a board that has none.
///
/// `nil` under the free tier, and that is the inert posture made structural rather than
/// remembered: with no object there is nothing to consult, nothing to detect with, and no
/// path by which a free-tier session could touch `.git` (12-editions.md ▸ The free tier and
/// `.git`). `HistoryStore.compose` is the one place the tier decides it.
///
/// A `let` beside `tier`, for `tier`'s reason: which board this is a git story *of* is
/// settled at composition and cannot change under an open session. What can change is the
/// mode *inside* it, by add-git alone — the one commanded mid-session flip 06 allows.
public let git: HistoryStore?
/// The mode this board is being edited in, `none` when there is no git state at all — which
/// is every free-tier session ("The free tier ships exactly one mode: `none`",
/// 12-editions.md ▸ Tier matrix).
///
/// **Its first consumer is the provider seam** — `makeHistoryProvider` reads exactly this to
/// know whether the board has a repository to be an undo stack for, and it is the *mode*
/// rather than the tier that decides (re-ruled 2026-07-31): `git` binds the git provider,
/// `none` and `repoNested` alike the native stack. The popover's git section is the other
/// reader — and the one place the two gitless modes still differ, since add-git is offered on
/// one and explained away on the other.
///
/// `@MainActor` because the state it reads is: a nested type does not inherit its enclosing
/// type's isolation, and everything that asks a session what mode it is in is main-actor
/// work anyway (a menu, a popover, a provider being composed).
@MainActor
public var gitMode: BoardGitMode { git?.mode ?? .none }
/// 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)
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
// Reads the cached facts and nothing else — no StoreKit API is touched until
// `ProEntitlement.start()`, which the app's launch calls and a test host never does.
let entitlement = ProEntitlement(defaults: preferences)
self.entitlement = entitlement
// Bound after the stored properties are in place, so the closure captures the object rather
// than a half-built `self`. This is the app's default wiring; a test that wants a tier
// assigns over it.
currentTier = { entitlement.tier }
// 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 entitlement read** (12-editions.md ▸ The entitlement): "Pro state is read from
// StoreKit's signed on-device transaction store at board-session composition — the open path
// gains no network dependency." Synchronous, over facts already in memory, on the same line
// as the provider it selects — which is the shape that makes "the open path never waits on
// the App Store" checkable by reading four lines rather than by auditing a call graph. It is
// also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing
// re-derives it.
let tier = currentTier()
// **Mode detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board
// open"), on the same line as the tier that gates it. Under `.free` this returns `nil`
// without looking at the disk at all — the inert posture is unconditional there — and under
// `.pro` it is one `stat` per open, freshly, so a board that gained or lost a `.git` since
// its last open opens in the mode it now has.
//
// Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The
// running session keeps its mode, and the watcher does not scan for `.git` appearing."
//
// **Before the provider**, which is new in pro-m1: which substrate a board's undo is depends
// on the mode this line detects (`makeHistoryProvider`), and a root that had to look at the
// disk itself would be a second detection able to disagree with this one.
//
// **The ledger is the store's own** (06 ▸ Interaction with external writers: "the Writer/echo
// machinery — the EchoLedger — lets the auto-committer classify every observed change, per
// file, as app-mediated or foreign"). `compose` defaults to a fresh one for the store-less
// callers (the add-git surface, unit tests), and a session that took that default would hand
// the committer a ledger nothing ever writes to: every commit this app made would classify
// foreign and be authored `Lanework External`. The default is a fallback, never this path's.
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier, ledger: store.echoes)
// 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". On Pro's git boards
// it is instead the repository's own trail, which survives everything (06 ▸ Rules ▸ Undo
// survives relaunch) — the seam's whole point.
let history = makeHistoryProvider(store, tier, git)
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md ▸ Fractal
// layout ▸ Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
// everything else keeps injecting nothing. A *provider* rather than a ranker because each
// load wants its own — see `BoardStore.makeIdentityHistoryRanker` — and because add-git
// flips the mode mid-session, which this closure picks up for free by asking the git state
// at the moment of each load rather than at composition.
if let git {
store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker }
// **The auto-commit engine, wired into the session it commits for** (06-history-undo.md
// ▸ Rules ▸ Auto-commit). Called on every Pro session and not only on git-mode ones,
// because add-git can flip a board mid-session and the committer it builds then must land
// in exactly this shape — `activateAutoCommit` remembers the wiring for that.
git.activateAutoCommit { [weak store] committer in
guard let store else { return }
committer.currentSnapshot = { [weak store] in store?.snapshot }
// **The flush awaits the snapshot that covers it** (06 ▸ Rules ▸ Auto-commit, ruled
// 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush
// awaits a snapshot generation covering its changed paths before the committer runs —
// the commit's subject can never be outrun by its own reload". Both halves of that
// await are reads of the store the composer is already diffing, which is why they are
// wired here rather than reached for: the engine holds the *policy* (when to wait, how
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
//
// The generation the gate counts is `landedReloads` — completed *walks* rather than
// applied snapshots — because a value-equal reload skips the assignment and its
// counter since 2026-07-31, and a walk covers a flush's paths whether or not it found
// anything to change (`BoardStore.landedReloads`).
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
committer.landedReloads = { [weak store] in store?.landedReloads }
// 02-architecture.md ▸ Write-failure surfacing, through the strip the board window
// already renders: a genuine commit failure means "your edits are saved, history has
// stopped advancing", which is exactly what the standing suspension row says. Lock
// contention and a held repository never reach here — neither is a failure.
committer.reportFailure = { [weak store] failure in
store?.banners.suspendHistory(reason: failure.message)
}
committer.reportRecovery = { [weak store] in
store?.banners.clearHistorySuspension()
}
store.commitSeam = .binding(to: committer)
// **The undo stack's ear on the committer** — every commit this engine lands, and
// which of it was heal work (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry,
// live; ▸ Heal commits are transparent to undo). Bound here rather than in
// `wireGitUndo` because add-git builds a *new* committer, and this wiring is what
// `activateAutoCommit` remembers on its behalf.
committer.reportLanded = { [weak self, ref] window in
guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return }
provider.noteLanded(window)
}
}
// **Add-git swaps the undo substrate too** (06 ▸ Rules ▸ Detection — the one commanded
// mid-session mode flip; 13-native-undo.md's header — "discards the in-session native
// stack and seeds the git trail from the root commit"). See `bindHistoryProvider(for:)`.
git.didAddGit = { [weak self] in
self?.bindHistoryProvider(for: ref)
}
// **The form-anchored posture's fallback half** (06 ▸ Interaction with external writers,
// ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead
// when that form has been dismissed before the answer arrived — "inline is the primary
// surface, never a silence trap". The banner enumeration is the same one branch switch and
// undo restore post into, one row per failure.
git.reportFailure = { [weak store] failure in
store?.banners.postGitFailure(.addGit, reason: failure.message)
}
}
// **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,
tier: tier,
git: git,
// 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
)
wireGitUndo(history, store: store, git: git, ref: ref)
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
refreshRecents()
}
// MARK: - The git provider's wiring
/// Fills a `GitHistoryProvider`'s seams with the session it is the history of — and does nothing
/// at all for any other substrate.
///
/// Everything the git provider needs is a fact about *this* board that neither a repository nor a
/// protocol could supply: which committer's debounce to settle first, whether the git surface is
/// held, which card windows a restore's diff would disturb, and the bracket a wholesale tree
/// change runs inside. Each arrives as a closure for `HistoryCommitSeam`'s reason — the provider
/// stays a thing that knows about commits, and the model stays the only object that knows what a
/// window is.
private func wireGitUndo(
_ history: (any HistoryProviding)?,
store: BoardStore,
git: HistoryStore?,
ref: BoardWindowRef
) {
guard let provider = history as? GitHistoryProvider, let git else { return }
provider.flushPendingCommit = { [weak git] in
await git?.committer?.flushNow()
}
provider.isHeld = { [weak git] in git?.committer?.pause != nil }
provider.suspendCommitting = { [weak git] in git?.committer?.stop() }
// The stage-around the settle released comes back with the committer: a card window still open
// after the restore is still a session (`resumeCardSessionStaging(for:)`).
provider.resumeCommitting = { [weak self, weak git] in
git?.committer?.start()
self?.resumeCardSessionStaging(for: ref)
}
// **A restore that failed cleanly** (06 ▸ Interaction with external writers: "surfaces as a
// one-shot banner failure naming the operation and the error, the tree left as it was") —
// now literally that, at the failure rank in the error tone (02 ▸ The banner surface, settled
// 2026-07-31: the one-shot class's second, message-carrying shape). The loss-row compromise
// this line used to carry is retired: a ⌘Z that didn't happen is an action that didn't
// happen, not content that didn't arrive.
//
// The closure passes the *direction* and libgit2's own message and stops there — "Undo
// failed — …" is BannerCenter's sentence, from the closed `GitOperation` vocabulary.
provider.reportFailure = { [weak store] direction, failure in
store?.banners.postGitFailure(.restore(direction), reason: failure.message)
}
provider.runBracketed = { [weak store] subject, work in
guard let store else { return await work() }
// The completion phrase 10-accessibility.md gives a bracketed operation is the restore's
// own subject — the sentence the trail now carries, spoken once when the reload lands.
try? await store.performWholesale(announcing: subject) { await work() }
}
provider.settleSessions = { [weak self, weak provider] paths in
guard let self, let provider else { return .proceed }
let gate = self.settleGate(for: ref) { [weak provider] folder in
// The card's uncommitted on-disk saves are reverted by the restore itself, which
// compares this folder against the working tree rather than against HEAD — see
// `GitRestoreOperation.plan`.
provider?.noteDiscarded(cardFolderName: folder)
}
let outcome = await gate.settle(touching: paths)
// **Only on `.proceed`** — a cancelled or failed settle leaves the board exactly as it
// was, sessions and their staging included.
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
return outcome
}
provider.seed()
wireBranchSwitching(git: git, store: store, provider: provider, ref: ref)
}
/// **The branch controls' seams** (06-history-undo.md ▸ Branch switching) — the five things the
/// switch's sequence needs that a repository cannot supply, plus the per-board stamp that makes an
/// interrupted switch recognizable as this app's.
///
/// Wired beside the undo provider's rather than in a place of its own, because the two are the
/// same board's git session seen from two sides — and because both must be re-wired on exactly the
/// same event, add-git's commanded mid-session flip (`bindHistoryProvider(for:)`).
private func wireBranchSwitching(
git: HistoryStore,
store: BoardStore,
provider: GitHistoryProvider,
ref: BoardWindowRef
) {
guard let switcher = git.switcher else { return }
let recordID = sessions[ref]?.recordID
switcher.flushPendingCommit = { [weak git] in await git?.committer?.flushNow() }
switcher.isHeld = { [weak git] in git?.committer?.pause != nil }
switcher.suspendCommitting = { [weak git] in git?.committer?.stop() }
// As on the restore path: what the settle released is a session that has not ended, and the
// window is still open on the other side of the checkout.
switcher.resumeCommitting = { [weak self, weak git] in
git?.committer?.start()
self?.resumeCardSessionStaging(for: ref)
}
// **The undo/redo reseed** — the provider's own API, which is the relaunch reseed by
// construction: "discarded and reseeded from the new HEAD's first-parent ancestry … redo
// starts empty".
switcher.reseedUndo = { [weak provider] in await provider?.reseed() }
switcher.didSwitch = { [weak git] in await git?.refreshBranch() }
switcher.runBracketed = { [weak store] announcement, work in
guard let store else { return await work() }
try? await store.performWholesale(announcing: announcement) { await work() }
}
switcher.beginProgress = { [weak store] label in
store?.banners.beginOperation(label: label) ?? UUID()
}
switcher.updateProgress = { [weak store] id, label in
store?.banners.updateOperation(id, label: label)
}
switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) }
// The failure rank's git shape, as on the restore path above: a switch that didn't happen is
// an action that didn't happen ("Couldn't switch branches — …", BannerCenter's words from
// the operation alone).
switcher.reportFailure = { [weak store] failure in
store?.banners.postGitFailure(.branchSwitch, reason: failure.message)
}
// **The recovery notice stays a loss row**, and the ruling is explicit about why (02 ▸ The
// banner surface): "recovery notices report a success, not a failure, and stay warning-tone".
// "A branch switch was interrupted — the previous state is restored" is the app tidying up
// after itself, with nothing for the user to do — the loss class's own register.
switcher.reportRecovery = { [weak store] message in
store?.banners.postLoss(message)
}
// **The per-board registry is the stamp's home** (`GitOperationStamp`). A session with no
// record — a store-level test — simply carries no stamp, and recovery then has nothing to
// recognize, which is the honest answer for a board the app has no state for.
switcher.readStamp = { [weak self] in
guard let self, let recordID else { return nil }
return self.boardRegistry.gitOperationStamp(id: recordID)
}
switcher.writeStamp = { [weak self] stamp in
guard let self, let recordID else { return }
self.boardRegistry.setGitOperationStamp(id: recordID, stamp)
}
switcher.settleSessions = { [weak self, weak switcher] in
guard let self, let switcher else { return .proceed }
let gate = self.settleGate(
for: ref,
message: SessionSettleStep.branchSwitchMessage
) { [weak switcher] folder in
switcher?.noteDiscarded(cardFolderName: folder)
}
// Every open session, not the ones a diff reaches — see `SessionSettleGate.settleAll`.
let outcome = await gate.settleAll()
// The switch's flush runs next and must find a tree it can settle whole — see
// `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough.
//
// **And the fine undo stacks go with it** (06 ▸ Branch switching, ruled 2026-07-31): the
// same `.proceed`, the same seam, for the same reason one rung up — what a window is
// holding describes the branch being left. "Cancel keeps the current branch and the
// sessions" is this `if`, unchanged: a cancelled or failed settle clears nothing, exactly
// as it releases nothing.
if outcome == .proceed {
self.releaseCardSessionStaging(for: ref)
self.discardCardWindowUndoStacks(for: ref)
}
return outcome
}
// **The own-leftovers check, at open** (06 ▸ Rules ▸ Abnormal repo states). Beside the
// committer's start, which is where a pause first becomes knowable, and before anything the
// user does can land on top of a half-finished checkout.
Task { await switcher.recoverInterruptedOperation() }
}
/// **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
/// `releaseCardSessionStaging(for:)` and `discardCardWindowUndoStacks(for:)` hang 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
/// (06-history-undo.md ▸ Rules ▸ Undo restore vs open Edit sessions; ▸ Branch switching).
///
/// 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.
///
/// - Parameters:
/// - message: what the step says it is about. The two callers describe different consequences —
/// a restore changes the cards being edited, a switch replaces them — and 06 gives the step to
/// both without giving either the other's wording.
/// - didDiscard: told each card folder the Discard branch abandoned, so the operation behind the
/// gate can put that folder's uncommitted saves back to HEAD its own way (the restore folds it
/// into its plan; the switch reverts before it flushes).
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)
}
)
}
/// **Swaps the board's undo substrate onto an already-open session** — add-git's one caller.
///
/// 06 ▸ Rules ▸ Detection sanctions exactly one mid-session mode flip, the app's own add-git:
/// "clicking it flips the open board into git mode immediately — the popover flows straight into
/// the git controls, the first auto-commit follows". 13-native-undo.md's header spells out what
/// that does to undo: "**Add-git swaps the substrate mid-session** — the commanded flip discards
/// the in-session native stack and seeds the git trail from the root commit, the branch-switch
/// discard-and-reseed precedent applied".
///
/// ### The discard is the whole of it — there is no migration
///
/// The board opened mode-none under any tier now carries a live `NativeHistoryProvider` with real
/// steps on it (`makeHistoryProvider`), and those steps **die with the substrate**: they are
/// in-memory inverse operations against a board that has just acquired a commit trail, and
/// replaying one after the swap would walk the board back across a change the root commit already
/// records as the baseline. The branch-switch precedent says the same thing about the same
/// question — "the undo/redo stack does not survive a switch. It is discarded and reseeded from
/// the new HEAD's first-parent ancestry … redo starts empty" (06 ▸ Branch switching) — so the old
/// stack is cleared rather than merely dropped, and the git provider's `seed()` (in `wireGitUndo`)
/// walks a trail whose only commit is the root, which is the stack's floor and not a step: ⌘Z is
/// correctly empty the instant the flip lands.
///
/// **Nothing is announced.** 06 gives the flip the popover's own flow ("straight into the git
/// controls") and 13 gives the discard no surface at all, exactly as the branch switch's discard
/// has none; what the user sees is the Edit rows and the toolbar pair revalidating through
/// `BoardUndoManager` on AppKit's own cadence, which is the same machinery every other enablement
/// change on this board rides. Nothing here posts a banner, and nothing should.
///
/// ### Why live rather than at the next open
///
/// A judgment call, recorded when the free-tier matrix still left this board with no provider at
/// all: the mode flip already carries the *committer* through (`HistoryStore.activateAutoCommit`
/// remembers its wiring for precisely this board); 12-editions.md's "an open board finishes with
/// the provider it composed" is a rule about a **tier** lapsing, which cannot change a running
/// session at all; and a board that visibly starts accumulating commits while ⌘Z answers from a
/// stack the repository knows nothing about would read as a defect rather than as a policy.
///
/// Called exactly once per board, structurally: `HistoryStore.addGit` refuses any mode but
/// `none`, and flips to `.git` before it fires `didAddGit`.
func bindHistoryProvider(for ref: BoardWindowRef) {
guard var session = sessions[ref], let git = session.git, git.mode == .git else { return }
guard let history = makeHistoryProvider(session.store, session.tier, git) else { return }
guard history !== session.history else { return }
// Before the reassignment, while `session.history` is still the substrate being replaced: the
// in-flight native steps go with it, and any closure that outlives this line finds an empty
// stack rather than inverses against a pre-repository board.
session.history?.clear()
session.history = history
sessions[ref] = session
session.store.history = history
session.undoManager.history = history
wireGitUndo(history, store: session.store, git: git, ref: ref)
}
/// 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
// **The window *is* the commit unit** (06 ▸ Rules ▸ Auto-commit, widened 2026-07-31), so the
// stage-around opens here — with the window — rather than at the body's first Edit→Preview
// flip. From this line to `unregisterCardWindow` nothing this card's folder receives can land
// in an interim commit.
setCardSession(true, for: ref)
}
func unregisterCardWindow(_ ref: CardWindowRef) {
sessions[ref.board]?.cardRefs.remove(ref)
cardSessions[ref] = nil
// **The close flush's release** — and, for a window that left without its session ending (a
// crash-shaped teardown, a dismissal that raced the flush), the backstop that must not leave a
// card folder excluded from staging forever. Both are the same line because both mean the same
// thing: this window is no longer holding its folder back.
setCardSession(false, for: ref)
}
/// Tokens the committer knows each card window's session by. Beside `cardSessions` for its
/// reason: this is the seam table's third column, written only here.
@ObservationIgnored
private var cardSessionTokens: [CardWindowRef: UUID] = [:]
/// **A card window's session opened or closed** (06-history-undo.md ▸ Rules ▸ Auto-commit: the
/// committer "stages around the whole open card folder").
///
/// This is the honest seam between the two halves of the rule: the host knows a window exists, the
/// committer knows what staging is, and only the app model knows which board a card window belongs
/// to and how to reach its committer. A board with no committer — the free tier, a Pro board with
/// no repository — records nothing, which is the same `nil` every other git seam takes.
///
/// The card's folder is handed over as a **closure**, not a URL: a card can change lane, or be
/// moved into the trash, in the middle of a session, and what must be staged around is wherever
/// it is at the moment of the commit. `BoardStore.cardBodyTarget` is the resolution that spans
/// both containers, which is exactly why the body save uses it too.
///
/// Idempotent both ways: re-opening reuses the token (a settle that released it, then a resume),
/// and closing an already-closed session reaches a committer that has nothing to remove.
func setCardSession(_ isOpen: Bool, for ref: CardWindowRef) {
guard isOpen else {
// **The token goes whether or not there is anyone left to tell.** A card window's own
// teardown can land after its board's, and a token kept past the board it names would
// outlive everything that could ever release it.
guard let token = cardSessionTokens.removeValue(forKey: ref) else { return }
sessions[ref.board]?.git?.committer?.endCardSession(token)
return
}
guard let session = sessions[ref.board], let committer = session.git?.committer else { return }
let token = cardSessionTokens[ref] ?? UUID()
cardSessionTokens[ref] = token
let cardID = ref.cardIdentity
committer.beginCardSession(token) { [weak store = session.store] in
guard let store,
let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil }
return path.folder(under: store.rootURL)
}
}
/// **The stage-around releases at the settle step** (06 ▸ Branch switching; ▸ Rules ▸ Undo restore
/// vs open Edit sessions) — every open card window on this board, unconditionally.
///
/// ### Why unconditionally, rather than through the modal
///
/// The save-or-discard step asks about *buffers* — "unsaved keystrokes, or on-disk ~700 ms saves
/// the session hasn't committed" — and a window that is merely open, with a comment posted an hour
/// ago and a clean editor, answers `needsSettling` with `false`. Under the widened stage-around
/// that window is still holding its whole folder out of every commit, so leaving it held would
/// walk a checkout onto a dirty tree and break the one guarantee the settle exists to buy: "with
/// sessions settled the restore runs on a settled tree … it cannot fail dirty".
///
/// Releasing is therefore structural and silent, and the modal keeps its own narrower predicate:
/// Save All's flush then carries the session's commit, and Discard's reverted bytes are
/// reconciled by the operation itself (`GitRestoreOperation.plan`, `GitBranchSwitcher`), which is
/// why this runs *after* the gate has answered rather than before it.
func releaseCardSessionStaging(for ref: BoardWindowRef) {
for cardRef in sessions[ref]?.cardRefs ?? [] {
setCardSession(false, for: cardRef)
}
}
/// **The branch switch's settle empties every open card window's fine undo stack**
/// (06-history-undo.md ▸ Branch switching, ruled 2026-07-31).
///
/// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe
/// > the branch being left — 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, following
/// > their cards onto the new branch with fresh stacks."
///
/// ### Why here, beside the staging release
///
/// Because it is the same fact about the same moment. `releaseCardSessionStaging(for:)` lets go of
/// what a window is holding *on disk*; this lets go of what it is holding *in memory*, and both
/// are true of a session whose branch is about to be replaced under it. Running them from one
/// `.proceed` is also what makes "Cancel clears nothing" a property of one `if` rather than a rule
/// two call sites have to keep in step (`wireBranchSwitching`).
///
/// **The board stack is not touched**, and it is not an omission: the switch discards and reseeds
/// it from the new HEAD's first-parent ancestry a few steps later, inside the bracket
/// (`GitBranchSwitcher.reseedUndo` → `GitHistoryProvider.reseed`). Doing it here would be the same
/// discard, one level up, at the wrong moment — before the checkout that decides what to reseed
/// *from*.
///
/// **The restore path deliberately does not call this.** An undo restore materializes a diff and
/// leaves the branch where it is, so a window's steps still describe the branch they were made on;
/// what protects them there is 13-native-undo.md's field-level staleness predicate, which is a
/// per-step question rather than a wholesale one.
///
/// 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()
}
}
/// **The next session begins** — the other half of `releaseCardSessionStaging(for:)`, run when the
/// operation behind the settle has finished with the tree.
///
/// A window that is still open after a restore or a branch switch is still a session, and its
/// folder must go back to being staged around. Idempotent, so the paths that resume without ever
/// having released (a cancelled switch, the open-time leftover check) cost a dictionary lookup.
func resumeCardSessionStaging(for ref: BoardWindowRef) {
for cardRef in sessions[ref]?.cardRefs ?? [] {
setCardSession(true, for: cardRef)
}
}
// 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 }
}
/// **The purchase flow's reopen offer, carried out** (12-editions.md ▸ The entitlement:
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
/// boards so the upgrade feels immediate").
///
/// ### Close and open, through the ordinary paths
///
/// There is no reopen-in-place mechanism here and deliberately so: the provider binding is a
/// composition-time fact, so "apply the new tier to this board" *means* end its session and
/// compose a new one. Doing that through `closeBoard` and `openBoard` — the same two calls ⌘W and
/// welcome make — is what keeps every guarantee those paths carry: the close flush runs in its
/// fixed order (card windows, pending work, registry stamp, teardown), the reopen resolves and
/// re-scopes the board's URL exactly as a fresh open does, and the registry records both.
///
/// ### Why the window is dismissed rather than reused
///
/// A board window's identity is its root path (`BoardWindowRef`), so reopening the same board
/// hands `openWindow(value:)` a ref it already has a window for — which *focuses* that window
/// instead of building a new one, and the window it would focus is one whose host has already run
/// its one-shot load. Dismissing first is what makes the reopen an open. The dismissals are all
/// issued before any reopen, then given a run-loop turn to land: SwiftUI processes a window's
/// teardown asynchronously, and asking for a value's window in the same turn it was dismissed is
/// the one way this sequence can produce a focused corpse.
///
/// ### Declining costs nothing, today least of all
///
/// A board that says Not Now keeps the substrate it composed with, and since the provider follows
/// the board (`makeHistoryProvider`), a gitless board's undo is the *same* native stack either
/// way — declining costs undo nothing at all, and costs a git board only the trail it would have
/// gained at its next open. The offer exists because "subscribe takes effect at each board's next
/// open" (12 ▸ The entitlement) needs one, not because anything breaks without it.
public func reopenOpenBoards() async {
// Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s.
let refs = sessions.keys.sorted { $0.path < $1.path }
guard !refs.isEmpty else { return }
// The store's `rootURL` rather than the ref's path: a board renamed while open keeps the
// path it was opened with, and reopening it there would open nothing (`BoardStore.rootURL`).
var roots: [URL] = []
for ref in refs {
guard let session = sessions[ref] else { continue }
roots.append(session.store.rootURL)
await closeBoard(ref: ref, cause: .userClose)
windowDismisser?(value: ref)
}
// One run-loop turn for the dismissals — see the note above. `Task.sleep` rather than
// `Task.yield` because the main run loop, not the cooperative pool, is what has to advance.
try? await Task.sleep(for: .milliseconds(150))
for root in roots {
openBoard(at: root)
}
}
/// 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()
// **The release, here rather than only at the host's unregister** (06 ▸ Rules ▸
// Auto-commit). The unregister does release it — that is what closes a window on its
// own — but it arrives from the *window's* teardown, which this sequence waits for
// only up to `cardDrainDeadline` and then proceeds anyway. A quit whose last window
// was slow to disappear would then flush with the folder still staged around and leave
// a settled session uncommitted, which is precisely what "nothing settled is ever left
// ... uncommitted by closing" forbids. Ending the session is this step's own act, so
// releasing what the session held is too. Idempotent with the unregister.
self?.setCardSession(false, for: cardRef)
},
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` is filled now** (06-history-undo.md ▸ Rules ▸ Auto-commit: "Board
// window close and app quit flush the pipeline — any pending editor save, then the
// pending auto-commit — before teardown; nothing settled is ever left unsaved or
// uncommitted by closing"). By the time it runs, step 1 has ended every card window's
// session *and released its stage-around*, so each session — body, comments, purge and
// all — lands in exactly one commit. `nil` on every board with no committer, which is the
// whole free tier.
committerFlush: { [weak self] in
await self?.sessions[ref]?.git?.committer?.flushNow()
},
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 }
// The committer dies with the session it commits for, `history.clear()`'s reason
// exactly: its debounce holds a closure over the store this line is about to release,
// and a timer that outlived its board would fire against a repository nobody is
// looking at. Its pending work has already been flushed by `committerFlush` above.
session.git?.stopAutoCommit()
// 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()
}
)
}
}