Files
lanework/Kanban/App/AppModel.swift
T
rzen e989c1f26e Build Edit mode with debounced, byte-honest saves
The editing surface: the same hosted TextKit-1 text view gains an
editable branch with a per-keystroke line-scanner highlighter — chosen
over a parser re-parse because a mid-typing buffer is usually invalid
Markdown and 05 wants the delimiters themselves dimmed; apply only sets
attributes, so presentation-never-transforms is structural. Saves ride
a ~700ms injectable debounce through BoardWriter.writeBody —
toggleTaskMarker's idiom widened to the body span, frontmatter bytes
untouched, refusing to write when disk already holds that body, which
enforces all three gates (untouched, reverted, echo) at the layer that
owns the bytes with one isDirty predicate above it. Mode grammar lands
whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape
returns, and every flip flushes first; window close flushes through
the existing retry/save-copy/discard modal, and the dismissal flush
deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always
follows the snapshot, the buffer only when clean, both surfaces render
the buffer. Undo is the editor's own session-scoped NSUndoManager;
endEditSession names the pro-m1 one-commit-per-session boundary.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 10:46:02 -04:00

714 lines
37 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.
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)
}
/// 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"
}
// 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: - 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: 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 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.
func captureWindowActions(open: OpenWindowAction, dismiss: DismissWindowAction) {
windowOpener = open
windowDismisser = dismiss
guard !pendingOpenURLs.isEmpty else { return }
let urls = pendingOpenURLs
pendingOpenURLs.removeAll()
for url in urls {
openBoard(at: url)
}
}
// 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
/// The security-scoped URL a board window is about to be built from, stashed between
/// `openBoard(at:)` and the host's first appearance.
///
/// The handoff exists because a window value has to be `Codable` and a scoped URL is not a
/// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone
/// unless something carried it across. The host claims it 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 pendingAccess: [BoardWindowRef: ScopedAccess] = [:]
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model")
/// The app builds one of these with the real registry file; a test passes its own path for the
/// same reason `BoardRegistry` takes one at all — "injecting it is how a test stays out of the
/// real Application Support directory".
public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) {
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
// 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.
public 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.
public func openBoard(at url: URL) {
guard let windowOpener else {
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)
// 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.
pendingAccess.removeValue(forKey: ref)?.stop()
pendingAccess[ref] = ScopedAccess(url)
windowOpener(id: WindowID.board, value: ref)
}
/// 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 the scoped URL `openBoard(at:)` stashed for this window, or `nil` if it opened by some
/// other route. Claiming removes it: the session owns the balance from here.
func claimPendingAccess(for ref: BoardWindowRef) -> ScopedAccess? {
pendingAccess.removeValue(forKey: ref)
}
/// 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?) {
sessions[ref] = BoardSession(store: store, recordID: recordID, cardRefs: [], access: access)
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
refreshRecents()
}
/// 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
}
// 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).
///
/// > tombstoned lanes and cards — and cards hidden beneath a tombstoned lane — don't count; the
/// > row advertises the board's working size, and the trash is an errand, not inventory.
///
/// The nesting is the ancestor walk: a tombstoned lane is skipped whole, so its cards are never
/// reached whatever their own flags say. `Lane.isDeleted`/`Card.isDeleted` are presence-of-key,
/// not validity, so a malformed `deleted:` counts as deleted here exactly as it does everywhere
/// else.
///
/// 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 where !lane.isDeleted {
lanes += 1
for card in lane.cards where !card.isDeleted {
cards += 1
}
}
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()
}
/// 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` (m7) is already decided when one arrives.
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 }
storeRegistry.release(session.store)
session.access?.stop()
}
)
}
}