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" 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). /// /// This is the minimum that satisfies "never a silent drop". The settled shape is richer — 02 /// § Launch and window lifecycle wants the failure *on the board's recents row*, carrying fail-fast's /// specifics or the unavailable state — and that belongs with the recents list itself. // m4-welcome: row-level failure rendering lands with the full welcome window (recents, Forget, // Open Recent). Until then a plain list under the branding is the honest placeholder. 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() // 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 = [] /// 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? /// 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 } // MARK: Launch failures /// Boards that failed to restore or open, newest last — the minimal welcome's one dynamic /// section. See `LaunchFailure` for what replaces it. 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) } // 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. public func openBoard(at url: URL) { guard let windowOpener else { Self.logger.error("openBoard with no window opener captured yet — ignored") 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) } /// 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. func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) { sessions[ref] = BoardSession(store: store, recordID: recordID, cardRefs: [], access: access) } /// 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() } // 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) } /// 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 store.rootURL.deletingPathExtension().lastPathComponent } // 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) } /// 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 / committerFlush stay nil until m6 and m7 have something to flush; the // slots exist so their order is already decided when they do. 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, laneCount: counts.lanes, cardCount: counts.cards) }, 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() } ) } }