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 the group's, not `.standard` /// /// 02 § Per-board app state sends these to "the group's shared `UserDefaults` suite where a scalar /// fits" (ruled 2026-07-29; 12-editions.md), for the registry's reason exactly: a paying upgrader /// launches Pro onto their own settings rather than onto defaults. `AppGroup.defaults` is that suite, /// and it degrades to a plain named domain when the group is not provisioned — unshared, but working. /// /// **Every reader and writer of these keys must name that suite.** A `@AppStorage` left to its own /// devices reads `.standard`, which after this ruling is a *different* domain — so the two views that /// bind one of these keys pass `store:` explicitly (`SettingsView`). public enum AppPreferences { /// The domain every key here lives in. A stored `let` would capture a suite at type-load time; /// this is a lookup of an object `UserDefaults` itself caches. public static var defaults: UserDefaults { AppGroup.defaults } /// "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 { defaults.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 = defaults.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) { defaults.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: 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. /// /// Base binds the native stack — a pair of step stacks over the inverses registered at the /// Writer boundary (13-native-undo.md, `NativeHistoryProvider`) — and that is the default here /// because it is the *shared* code's implementation: both targets compile it, and Pro runs it /// too until pro-m1 replaces this closure with the git provider (06-history-undo.md). Nothing in /// this file is edition-conditional; the edition difference is which closure the root installs. /// /// 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. @ObservationIgnored public var makeHistoryProvider: (BoardStore) -> any HistoryProviding = { _ in NativeHistoryProvider() } // MARK: Sessions /// One open board window and everything hanging off it. public struct BoardSession { /// The shared store — the same object every one of this board's windows renders. public let store: BoardStore /// Which registry record this board is, so the close flush can stamp counts and clear the /// open-now flag without matching by identity a second time. public let recordID: UUID /// This board's undo/redo substrate — **one stack per board session, never per window** /// (13-native-undo.md ▸ Rules). 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 edition's answer and nobody else's /// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`. public let history: any HistoryProviding /// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's /// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows /// and the toolbar's pair resolve to *this* board through the ordinary responder chain. /// /// Built once with the session rather than per window, because a second adapter would be a /// second answer to "what is this board's undo" — and card windows share this one. let undoManager: BoardUndoManager /// This board's open card windows. The close flush's step 1 reads it; the card hosts /// maintain it. Empty is the common case. public var cardRefs: Set = [] /// 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 = [] // 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 { var paths: Set = [] 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 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", which after the 2026-07-29 ruling means **out of the shared App /// Group container** (`AppGroup`). A suite that swept the real staging root would be sweeping the /// developer's own clipboard, and now the sibling edition's too. /// /// `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 = AppGroup.defaults ) { boardRegistry = BoardRegistry(storageURL: registryStorageURL) styleRecents = StyleRecents(defaults: preferences) clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot) // Read once here rather than lazily, so File ▸ Open Recent is populated from the app's first // menu pass — a launch that restores boards never shows welcome, and a submenu that filled // in only after the first close would look broken. It costs one bookmark-resolution sweep at // launch, next to the one `restorables()` already runs. refreshRecents() } // MARK: - Launch restoration /// The launch-restoration gate, as a pure function (02-architecture.md § Launch and window /// lifecycle: "the preference gates only whether the flagged set is consulted; the flags are /// maintained regardless"). /// /// `KanbanApp.init()` is where this actually runs — read once, before any scene exists, into a /// `let` rather than a computed property, because `restorables()` costs a bookmark resolution per /// known board and nothing should pay that on every scene-graph evaluation. An `App`'s `init` is /// not itself reachable from a test, so the decision is pulled out to here: two `Bool`s in, one /// out, provable without a real `UserDefaults` domain or a live registry. /// /// `nonisolated` because it is exactly as pure as that sentence claims — it touches no stored /// state, and `LaunchPlan.decide` (which is not main-actor-bound either, for the same reason) /// composes it into the three-way launch decision. public nonisolated static func shouldRestoreAtLaunch(preference: Bool, hasRestorables: Bool) -> Bool { preference && hasRestorables } // MARK: - Opening public var hasOpenBoards: Bool { !sessions.isEmpty } /// Opens a board window for `url`, or focuses the one this board already has. /// /// **The already-open check is by file identity, not by path** — `liveStore(for:)` resolves it — /// so a board reached through a resolved bookmark and the same board reached through the open /// panel land on one window even when the two URLs are spelled differently. Only when nothing is /// open for it does a ref get minted, and `openWindow(value:)` with an equal ref focuses rather /// than duplicates, which is the second half of "one board window per root". /// /// Security-scoped access starts here, *before* the window exists, because the host's very first /// act is a tree walk: a scope started after the load would be too late. /// /// **Called before any scene has appeared, and that's fine.** A cold launch's Finder-open can /// reach here before `windowOpener` is captured; the URL joins `pendingOpenURLs` and this same /// method runs again for it once `captureWindowActions` has something to open it with. 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) } /// Opens a recents row — the one door for welcome's double-click, its Open item, and File ▸ Open /// Recent, because a row has **two** ways of leading to a board now. /// /// An ordinary available row opens its URL. A row awaiting this edition's grant /// (`RecentBoard.needsReopen` — a board the other edition minted the only bookmark for) runs the /// re-grant panel first: "the first click runs an open panel pre-anchored at the recorded path: /// one click + Grant per board, once per edition" (12-editions.md ▸ Distribution). /// /// Nothing else about the open differs. The granted URL goes through `openBoard(at:)` exactly as a /// File ▸ Open… pick would, and `BoardRegistry.recordOpen` matches the *existing* shared record and /// mints this edition's slot onto it — the other edition's grant, the frames, the counts and the /// cached title all stay where they are. /// Internal rather than `public` only because `WelcomeRow` is — the row derivation is a UI-layer /// value, and nothing outside this module opens boards by row. func open(_ row: WelcomeRow) { if let url = row.url { openBoard(at: url) return } guard let anchor = row.regrantAnchor, let granted = presentRegrantPanel(anchoredAt: anchor, boardName: row.displayName) else { return } openBoard(at: granted) } /// The re-grant panel: an ordinary open panel, pre-anchored at the board's recorded path. /// /// **A panel and not an alert**, because the panel *is* the mechanism: a sandboxed app gains access /// to a folder by the user choosing it, so there is nothing an intermediate explanation could add /// that the panel's own message does not say better while doing the job. /// /// `directoryURL` is the recorded path itself, per the ruling. For a board that is a `.kanban` /// package the panel therefore opens *inside* it — `treatsFilePackagesAsDirectories` is on for /// `presentOpenPanel`'s reason (boards are packages *and* plain folders) — and Open with nothing /// selected chooses the folder on display, which is the board. A board that has since moved leaves /// the panel at the nearest surviving ancestor, which is the Finder behaviour and the honest one: /// the user knows where their board went, and this app does not. private func presentRegrantPanel(anchoredAt anchor: URL, boardName: String) -> URL? { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false panel.treatsFilePackagesAsDirectories = true panel.allowsMultipleSelection = false panel.directoryURL = anchor panel.prompt = "Grant" panel.message = "Choose “\(boardName)” to let this app open it." guard panel.runModal() == .OK else { return nil } return panel.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?) { // The board's stack is born here, with the session that owns it, and dies in `tearDown` // below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with // the board session and dies at close/quit ... standard macOS behavior". let history = makeHistoryProvider(store) // **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the // store is that boundary — every app-mediated mutation goes out through one of its write // methods — so it is the store that computes each inverse and registers it. What it cannot // know is *which* stack, because a stack belongs to a session and a store knows nothing about // windows; this line is where the session tells it. Weak on the store's side, so the loop // this closes (provider → step closures → store) is not a retain cycle. store.history = history sessions[ref] = BoardSession( store: store, recordID: recordID, history: history, // The lock's enablement half (13-native-undo.md ▸ Rules): Undo and Redo disable with the // other mutating commands while the board refuses writes, and the stack survives to // resume when it clears. Weak, so the adapter is never the reason a closed board's store // stays alive; a store that has gone answers "writable", which is moot — its stack went // with it. undoManager: BoardUndoManager(history: history, isReadOnly: { [weak store] in store?.isReadOnly ?? false }), cardRefs: [], access: access ) clearLaunchFailures(naming: [ref.path, store.rootURL.path]) refreshRecents() } /// 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) { 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) { guard !paths.isEmpty else { return } let keys = Set(paths.map(WelcomeRow.pathKey)) launchFailures.removeAll { keys.contains(WelcomeRow.pathKey($0.path)) } } // MARK: - Counts /// The lane and card counts stamped into the registry at close — **live items only** (02 /// § Per-board app state, settled). /// /// > deleted lanes and cards don't count; the row advertises the board's working size, and the /// > trash is an errand, not inventory. /// /// **`.trash/` is excluded by construction** (02-architecture.md § Per-board app state, /// re-grounded 2026-07-28 for the materialized trash): this walks `snapshot.lanes`, and the /// trash is `snapshot.trash` — a sibling container, never a lane — so no filter is needed and /// none could be forgotten. The tombstone era's ancestor walk over `deleted:` flags is gone with /// the flag; a board an older version wrote counts its unmigrated cards until the migration moves /// them, which is the safe direction and lasts exactly one write. /// /// Static and pure: it is a fact about a snapshot, and the close flush is the wrong place to /// discover a counting bug. public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) { var lanes = 0 var cards = 0 for lane in snapshot.lanes { lanes += 1 cards += lane.cards.count } return (lanes, cards) } /// The folder name, extension stripped (01-storage-format.md § Board naming) — `displayName`'s /// own fallback, and (02-architecture.md § Per-board app state) the registry record's /// *provisional* display name for a board recorded before its load has run: "fail-fast means the /// frontmatter can't be trusted, and the folder name is the Finder document name the user just /// picked". A first successful load replaces it with the cached title through the ordinary /// `displayName(of:)` path — there is no separate provisional vocabulary, just this one fallback /// used a moment earlier than usual. public static func folderDisplayName(of url: URL) -> String { url.deletingPathExtension().lastPathComponent } /// A board's display name: its `title`, falling back to the folder name sans extension /// (01-storage-format.md § Board naming). /// /// Read from `store.rootURL` rather than `snapshot.rootURL` so the fallback follows a rename the /// moment it is absorbed, instead of lagging by one reload (see `BoardStore.rootURL`). public static func displayName(of store: BoardStore) -> String { if let title = store.snapshot.title.value, !title.isEmpty { return title } return folderDisplayName(of: store.rootURL) } // MARK: - Closing /// Runs the close flush for one board and tears its session down. /// /// Idempotent by two guards: a board with no session has already closed, and a board already /// mid-flush is not started again. Both matter — the window's close interception and the host's /// disappear both call this, by design, because neither one alone fires on every path a window /// can leave by. public func closeBoard(ref: BoardWindowRef, cause: BoardCloseCause) async { guard sessions[ref] != nil, !closingBoards.contains(ref) else { return } closingBoards.insert(ref) defer { closingBoards.remove(ref) } await coordinator(for: ref).run(cause: cause) // The flush stamped this board's counts and (on a user close) cleared its open-now flag, so // the cached list is now one close out of date — and welcome is often the very next thing on // screen. refreshRecents() } /// The close flush's **pending-work step, without the teardown** — what File ▸ Duplicate runs /// before it copies (03-board-ui.md § Welcome screen & templates: "The copy is preceded by the /// close flush ... so neither the tree nor the copied history misses pending work"). /// /// **Not `closeBoard`**, and the design says so itself: 09-templates.md ▸ Save as Template states /// the rule together with its exception — "with the pull-style mechanical exception committing an /// open Edit session's on-disk saves as-is, **sessions staying open**". A duplicate leaves the /// original on screen (03: "the original stays open too"), so what it needs is pending work /// *landed on disk*, not a session ended: no card window is dismissed, no record is stamped /// closed, nothing is torn down, and the board the user is looking at never blinks. /// /// It goes through `CloseFlushCoordinator` rather than calling the store directly so that the /// order of the three flushes — store pipeline, then editor saves, then the pending auto-commit /// (02's own order) — keeps having exactly one definition. public func flushPendingWork(for ref: BoardWindowRef) async { guard sessions[ref] != nil else { return } await coordinator(for: ref).flushPendingWork() } /// Whether any of this board's card windows is holding content the files do not have — a dirty /// Edit buffer or a typed-in raw-source outlet (`CardSessionFlushing.holdsUnsavedContent`). /// /// **One caller, one rule**: File ▸ Save as Template's carve-out from the read-only lock. Under /// the unwritable-location lock the item stays live — "reads the board, writes Application /// Support" — but only while no such session exists, because the lock has suspended exactly the /// saves that would flush one and 09-templates.md's never-misses-keystrokes guarantee outranks /// the item's availability (02-architecture.md ▸ Live-reload resilience, settled scoping). /// /// It asks the sessions rather than the store: the content in question is in *memory*, in the /// card windows, which is the whole reason the flush cannot reach it. public func hasUnsavedCardContent(for ref: BoardWindowRef) -> Bool { guard let session = sessions[ref] else { return false } return session.cardRefs.contains { cardSessions[$0]?.holdsUnsavedContent == true } } /// Quit: the same sequence, once per open board, **sequentially**. /// /// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than /// three interleavings of it, and in a stable board order so a quit is reproducible. Nothing here /// clears an open-now flag — that is what `.quit` means, and it is what makes the next launch /// restore this set (§ Launch and window lifecycle). public func flushAllBoardsForQuit() async { for ref in sessions.keys.sorted(by: { $0.path < $1.path }) { await closeBoard(ref: ref, cause: .quit) } } /// Wires a session into `CloseFlushCoordinator`'s seams. The ordering lives over there; this is /// only which real object each step touches. private func coordinator(for ref: BoardWindowRef) -> CloseFlushCoordinator { CloseFlushCoordinator( openCardRefs: { [weak self] in // Sorted so a board with several card windows commits and closes them in a stable // order rather than a `Set`'s. (self?.sessions[ref]?.cardRefs).map { $0.sorted { $0.cardID < $1.cardID } } ?? [] }, endCardSession: { [weak self] cardRef in await self?.cardSessions[cardRef]?.endSession() }, dismissCardWindow: { [weak self] cardRef in self?.windowDismisser?(value: cardRef) }, storeFlush: { [weak self] in await self?.sessions[ref]?.store.awaitQuiescence() }, // `editorFlush` stays nil, and now deliberately rather than for want of an editor: the // card windows' debounced body saves flush in **step 1**, inside each window's // `endSession()` (`CardWindowSession`), which is both earlier than this slot and where // 02-architecture.md puts them ("each open Edit session ends with its normal session // commit", then pending work). The slot stays for a board-level editor with no card // window of its own — the raw-source buffer is the candidate — so that the order // relative to `committerFlush` (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 } // 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() } ) } }