Repo-nested boards bind native undo in every tier (25d2513): the no-undo case is gone, makeHistoryProvider answers git or native, and the native path provably never touches the enclosing repository's .git. Session undo steps anchor by card identity, never by path (9119aa1): HistoryAnchor carries the card UUID (plus comment/draft vocabulary) and apply-time validation resolves the current folder via the same both-container walk writeCardBody uses — a board-side lane or trash move no longer stales the coarse close step, while a genuine field collision still skips it whole. 2448 tests in 423 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
1508 lines
88 KiB
Swift
1508 lines
88 KiB
Swift
import AppKit
|
|
import Observation
|
|
import SwiftUI
|
|
import os
|
|
|
|
// MARK: - Scene ids
|
|
|
|
/// The scene identifiers, in one place because they are matched by string in three unrelated
|
|
/// spots — the scene declaration, `openWindow(id:)`, and `dismissWindow(id:)` — and a typo in any
|
|
/// one of them fails silently at runtime.
|
|
public enum WindowID {
|
|
public static let welcome = "welcome"
|
|
public static let restoreBootstrap = "restore-bootstrap"
|
|
/// The template chooser (09-templates.md; File ▸ New Board… ⌥⌘N). Its own window rather than a
|
|
/// sheet on welcome because ⌥⌘N is available *everywhere* (11-command-nexus.md) — including from
|
|
/// a board window, and including when welcome is not open at all, which a sheet would have to
|
|
/// conjure a host for.
|
|
public static let templateChooser = "template-chooser"
|
|
public static let board = "board"
|
|
public static let card = "card"
|
|
}
|
|
|
|
// MARK: - App-wide preferences
|
|
|
|
/// The `UserDefaults` half of "App-wide state has the same home" (02-architecture.md § Per-board app
|
|
/// state): the app-scoped values that are scalars, kept out of the board registry because no board
|
|
/// owns them.
|
|
///
|
|
/// The keys are declared here rather than spelled at each `@AppStorage`, for the same reason
|
|
/// `WindowID` exists.
|
|
///
|
|
/// The domain is `UserDefaults.standard`, which the sandbox already scopes to this one app — the
|
|
/// same reason `AppStateHome` needs no bundle-id subfolder. A `@AppStorage` left to its own devices
|
|
/// reads exactly this domain, so nothing here has to be named at a binding site.
|
|
public enum AppPreferences {
|
|
|
|
/// "Restore open boards at launch" (Settings, ⌘, — 11-command-nexus.md). **Default on.**
|
|
public static let restoreOpenBoardsAtLaunchKey = "restoreOpenBoardsAtLaunch"
|
|
|
|
/// Read outside a view, where `@AppStorage` is not available — the launch flow needs it before
|
|
/// any scene exists. `object(forKey:)` rather than `bool(forKey:)` because the latter cannot
|
|
/// tell "off" from "never set", and this preference defaults to *on*.
|
|
public static var restoreOpenBoardsAtLaunch: Bool {
|
|
UserDefaults.standard.object(forKey: restoreOpenBoardsAtLaunchKey) as? Bool ?? true
|
|
}
|
|
|
|
/// The last-used card-window size (05-card-window.md; 02 files it as app-wide, not per-board —
|
|
/// "the last-used card-window size" is named there explicitly). Stored as a string because
|
|
/// `NSSize` is not a property-list type and two more keys would be worse.
|
|
public static let lastCardWindowSizeKey = "lastCardWindowSize"
|
|
|
|
public static var lastCardWindowSize: CGSize? {
|
|
guard let text = UserDefaults.standard.string(forKey: lastCardWindowSizeKey) else { return nil }
|
|
let size = NSSizeFromString(text)
|
|
guard size.width > 0, size.height > 0 else { return nil }
|
|
return size
|
|
}
|
|
|
|
public static func setLastCardWindowSize(_ size: CGSize) {
|
|
UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
|
|
}
|
|
|
|
// MARK: The comments pane's three bits
|
|
|
|
/// **View ▸ Show Comments** — "a checkmark toggle à la Show Trash, and its choice is **app-wide
|
|
/// and persisted across restarts**" (05-card-window.md ▸ The comments column, re-ruled
|
|
/// 2026-07-29; 11-command-nexus.md).
|
|
///
|
|
/// **One bit, and no content-derived auto-show**: checked, every card window carries the pane —
|
|
/// a comment-less card shows the empty thread and the composer, because the invitation is the
|
|
/// point; unchecked, threads and drafts are out of sight until the user says otherwise. The
|
|
/// checkmark reads exactly this value, so the menu never lies, and deleting the last comment
|
|
/// never closes the pane because nothing but this bit does.
|
|
///
|
|
/// **Default on.** 05 does not spell a default, and the two candidate readings pull in opposite
|
|
/// directions — the Show Trash bargain (a secondary surface, default off) against "the invitation
|
|
/// is the point" (a pane whose empty state is its whole argument). The invitation wins: a
|
|
/// comments feature nobody sees until they find a View-menu row is a feature that is not there,
|
|
/// and the user who does not want it turns it off once, forever, which is what the persistence is
|
|
/// for.
|
|
public static let showCommentsKey = "showComments"
|
|
|
|
public static var showComments: Bool {
|
|
UserDefaults.standard.object(forKey: showCommentsKey) as? Bool ?? true
|
|
}
|
|
|
|
/// **View ▸ Comments Beside Body** — "checked = side-by-side (default), unchecked = body over
|
|
/// comments; app-wide, persisted" (11-command-nexus.md; 05 ▸ Composition).
|
|
///
|
|
/// Default **on**, which 05 does state: "side-by-side is the default".
|
|
public static let commentsBesideBodyKey = "commentsBesideBody"
|
|
|
|
public static var commentsBesideBody: Bool {
|
|
UserDefaults.standard.object(forKey: commentsBesideBodyKey) as? Bool ?? true
|
|
}
|
|
|
|
/// The comments header's **sort-direction control** — "chronological ascending by default,
|
|
/// flippable to newest-first (app-wide, persisted)" (05 ▸ The comments column; 11 files it under
|
|
/// Configuration controls).
|
|
///
|
|
/// Stored as "newest first" rather than as a direction so the default is `false` and the plain
|
|
/// `bool(forKey:)` reading is the right one — the one preference here that does not need to tell
|
|
/// "off" from "never set".
|
|
public static let commentsNewestFirstKey = "commentsNewestFirst"
|
|
|
|
public static var commentsNewestFirst: Bool {
|
|
UserDefaults.standard.bool(forKey: commentsNewestFirstKey)
|
|
}
|
|
|
|
/// The quick-style row's recently-used backgrounds — an array of palette names / hex strings,
|
|
/// most-recent-first (03-board-ui.md § Styling ▸ Controls: "Recents are app-wide and persist
|
|
/// app-side (user preference, never board data)"; 11-command-nexus.md files it under the
|
|
/// preferences that "need no UI"). Read and written by `StyleRecents`, which owns the list rule;
|
|
/// the key is declared here with its neighbours for `WindowID`'s reason.
|
|
public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"
|
|
|
|
/// The cached subscription facts behind the tier decision (12-editions.md ▸ The entitlement) —
|
|
/// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`.
|
|
///
|
|
/// A scalar default rather than a file in `AppStateHome` because it is two fields, which is the
|
|
/// line that type's own note draws. **Not a secret and not a receipt**: the signed transaction
|
|
/// store is StoreKit's and stays StoreKit's; this is a *cache of the last answer* whose worst
|
|
/// case if edited by hand is one wrong tier until the next refresh corrects it, which is the
|
|
/// same self-correction a fresh install already relies on.
|
|
public static let subscriptionFactsKey = "subscriptionFacts"
|
|
}
|
|
|
|
// MARK: - Launch failures
|
|
|
|
/// A board that could not be restored or opened, as the welcome window renders it.
|
|
///
|
|
/// **A struct rather than the obvious tuple** only because SwiftUI needs identity to list these and
|
|
/// two failures can share a path (a board that failed, was retried, and failed again).
|
|
///
|
|
/// The join onto a recents row is `WelcomeRow.derive(recents:failures:)` — 02 § Launch and window
|
|
/// lifecycle wants the failure *on the board's row*, carrying fail-fast's specifics or the
|
|
/// unavailable state, and a failure naming no row (a first open of a folder that was never a board)
|
|
/// falls back to a list of its own. `path` is what the join matches on, which is why it is stored
|
|
/// rather than derived from the message.
|
|
public struct LaunchFailure: Identifiable, Sendable, Equatable {
|
|
public let id = UUID()
|
|
public let path: String
|
|
public let message: String
|
|
|
|
public init(path: String, message: String) {
|
|
self.path = path
|
|
self.message = message
|
|
}
|
|
|
|
/// What the row shows for a name: the folder, not the whole path. The path is the subtitle.
|
|
public var displayName: String {
|
|
URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent
|
|
}
|
|
}
|
|
|
|
// MARK: - Security-scoped access
|
|
|
|
/// One board's security-scoped access, held for the **whole session**.
|
|
///
|
|
/// `BoardRegistry.withScopedAccess(to:_:)` is the scoped-per-call form and is right for what it does
|
|
/// — resolving identities during a recents listing, where holding a scope open would be a leak. It is
|
|
/// exactly wrong for an open board: the store, the watcher, and every Writer call need access for
|
|
/// minutes or hours, and re-entering the scope per call would be both slower and racy against a
|
|
/// watcher thread that is already inside the folder.
|
|
///
|
|
/// So the pairing is explicit and its balance is the session's job: started when the board's window
|
|
/// opens, stopped in the close flush's teardown step. A class rather than a struct so the balance
|
|
/// cannot be duplicated by a copy.
|
|
///
|
|
/// **The URL matters, not the path.** A security-scoped URL is a token, not a string: a `URL`
|
|
/// rebuilt from `ref.path` grants nothing, which is why `AppModel.openBoard(at:)` stashes the
|
|
/// resolved URL for the host that is about to appear instead of letting it reconstruct one.
|
|
public final class ScopedAccess {
|
|
|
|
public let url: URL
|
|
private var started: Bool
|
|
|
|
public init(_ url: URL) {
|
|
self.url = url
|
|
// `false` for a URL that is not security-scoped — a plain bookmark's, one the open panel
|
|
// already blessed for the app's lifetime, anything inside the container. There is then
|
|
// nothing to stop, and the pairing stays balanced either way.
|
|
started = url.startAccessingSecurityScopedResource()
|
|
}
|
|
|
|
public func stop() {
|
|
guard started else { return }
|
|
started = false
|
|
url.stopAccessingSecurityScopedResource()
|
|
}
|
|
}
|
|
|
|
// MARK: - AppModel
|
|
|
|
/// The app's one piece of cross-window state: which boards are open, which card windows belong to
|
|
/// which board, and the two window actions AppKit-side code needs but cannot reach.
|
|
///
|
|
/// ### What lives here, and why it is not a singleton
|
|
///
|
|
/// The two registries (02-architecture.md § Layering ▸ Components and § Per-board app state) are
|
|
/// owned here because they are app-scoped and because "the app holds one instance, so a test can
|
|
/// hold its own without the two colliding" — `BoardStoreRegistry`'s own note. Everything else here is
|
|
/// window bookkeeping that has no other home: a `BoardStore` knows nothing about windows by design,
|
|
/// and a SwiftUI scene is a value that cannot hold state across a window's life.
|
|
///
|
|
/// ### Sessions are the join
|
|
///
|
|
/// A `BoardSession` is what makes the two halves of the app meet: the store the windows share, the
|
|
/// registry record they stamp, the card windows the close flush has to close first, and the
|
|
/// security-scoped access the whole thing runs inside. Its lifetime is exactly the board window's —
|
|
/// created when the host's load succeeds, removed by the close flush's last step. A card window with
|
|
/// no session is a card window with no board, which 02's ownership rule says cannot exist; the card
|
|
/// host reads that as "dismiss".
|
|
@MainActor
|
|
@Observable
|
|
public final class AppModel {
|
|
|
|
// MARK: Registries
|
|
|
|
public let storeRegistry = BoardStoreRegistry()
|
|
public let boardRegistry: BoardRegistry
|
|
|
|
/// The quick-style row's app-wide recents (03-board-ui.md § Styling ▸ Controls). Owned here for
|
|
/// the registries' reason — app-scoped, and a test holds its own rather than colliding with the
|
|
/// app's — and reached by the context menus through the environment, since a `BoardStore` is
|
|
/// board-scoped and this list deliberately is not.
|
|
public let styleRecents: StyleRecents
|
|
|
|
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
|
///
|
|
/// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
|
|
/// source board hides the dragged items while any other open board's drop delegates propose a
|
|
/// landing spot for them. It lives here rather than as a global for `styleRecents`' reason — a
|
|
/// test holds its own rather than colliding with the app's — and every board window reaches it
|
|
/// through the environment.
|
|
/// Internal rather than `public`, unlike its neighbours: the drag is entirely a UI-layer
|
|
/// concern, and nothing outside this module has any business reaching into a gesture in flight.
|
|
let dragSession = DragSession()
|
|
|
|
/// The app's one clipboard (04-interactions.md ▸ Clipboard).
|
|
///
|
|
/// App-wide for the drag session's reason turned up a level: a cut/copy **outlives the board it
|
|
/// came from** — the pasteboard and the staged snapshot survive the source window closing, and
|
|
/// survive the app quitting — so nothing board-scoped could own it. It lives here rather than as
|
|
/// a singleton for `styleRecents`' reason (a test holds its own rather than colliding with the
|
|
/// app's, which for this one also means staying off the machine's real pasteboard), and every
|
|
/// board window reaches it through the environment.
|
|
///
|
|
/// Building it here is also the **launch sweep** (04: "a sweep at launch"): the store's `init`
|
|
/// reads the pasteboard once and collects every staged tree it no longer names.
|
|
public let clipboard: ClipboardStore
|
|
|
|
// MARK: The entitlement
|
|
|
|
/// **Lanework Pro's entitlement** (12-editions.md ▸ The entitlement) — the cached, local answer
|
|
/// to "is this a subscriber?", owned here for the registries' reason: it is app-scoped, and a
|
|
/// test holds its own over its own defaults rather than colliding with the app's.
|
|
///
|
|
/// Nothing on the board-open path awaits anything through this object. See `ProEntitlement` for
|
|
/// why that is a property of its shape rather than a rule somebody has to remember.
|
|
public let entitlement: ProEntitlement
|
|
|
|
/// **The tier a board session composes under**, as an injectable seam.
|
|
///
|
|
/// Defaulted to the real entitlement's local read and separated from it for `makeHistoryProvider`'s
|
|
/// reason exactly: a test binds a tier without needing a StoreKit transaction, an App Store
|
|
/// account, or a second `AppModel` initializer. `@MainActor` on the closure type because the
|
|
/// entitlement it reads is main-actor state, and `@ObservationIgnored` because nothing renders
|
|
/// from it — the tier reaches the UI, where it reaches it at all, through `entitlement`.
|
|
///
|
|
/// **Read once per session, at composition, and never again** (12 ▸ The entitlement: "a lapse
|
|
/// never interrupts an open session"). `beginSession` is the only caller.
|
|
@ObservationIgnored
|
|
public var currentTier: @MainActor () -> Tier = { .free }
|
|
|
|
// MARK: The provider seam
|
|
|
|
/// **The composition root for `HistoryProviding`** (12-editions.md ▸ The provider seam): what a
|
|
/// board session's undo stack is built by, called once per board as its session begins.
|
|
///
|
|
/// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12 ▸ The
|
|
/// provider seam; 13-native-undo.md's header; 06 ▸ Rules): a board's substrate is decided by what
|
|
/// the board *is*, and the tier only decides whether git is on the table at all. The rule it
|
|
/// replaced bound the native stack free-tier-wide and nothing at all on Pro's gitless boards,
|
|
/// which made subscribing *remove* undo from a mode-none board — an upgrade that takes a feature
|
|
/// away.
|
|
///
|
|
/// It takes the store because that is what a provider is a history *of*: the git provider needs
|
|
/// the board root it is a repository at, and the native one's steps are computed from the same
|
|
/// store's snapshots. A property rather than an initializer argument so a test
|
|
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
|
|
/// nothing renders from it.
|
|
///
|
|
/// ### The two answers, and the `nil` that is no longer one of them
|
|
///
|
|
/// - **No `HistoryStore` at all** — the free tier, where `HistoryStore.compose` returns `nil`
|
|
/// without so much as a `stat`: the **native stack, on every board**. "The free tier binds it
|
|
/// everywhere (any `.git` inert)" (13), and 12 ▸ The free tier and `.git` names the boards that
|
|
/// covers by hand — "a formerly-subscribed user's board, a 1.x board, a repo-nested board …
|
|
/// native undo runs". The absent git state *is* the tier test; nothing here reads a flag.
|
|
/// - **Mode `git`** (Pro only — no other tier composes a git state) — the git provider: undo as
|
|
/// forward restore commits over HEAD's first-parent ancestry (06).
|
|
/// - **Mode `none` and mode `repoNested` alike** — the **native stack**, exactly as in the free
|
|
/// tier. "Boards without app-managed git — repo-nested included — bind 13-native-undo.md's
|
|
/// native stack in **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog, re-ruled 2026-07-31
|
|
/// twice; 12 ▸ The provider seam; 13's header).
|
|
///
|
|
/// **The repo-nested no-undo case is gone** (re-ruled 2026-07-31): 06's leave-strictly-alone
|
|
/// stance "concerns *git*, and this stack never touches git — memory-only, journal-free,
|
|
/// session-scoped — so what repo-nested denies is app-managed history, never ⌘Z" (13's header).
|
|
/// It also made the Pro upgrade story exceptional, which was the other half of the same defect:
|
|
/// the free tier could not tell such a board from a plain one and bound the native stack anyway,
|
|
/// so subscribing *removed* undo from exactly the boards it left alone. Edit ▸ Undo/Redo and the
|
|
/// toolbar pair now disable only under a lock and on an empty stack.
|
|
///
|
|
/// This closure therefore never answers `nil`, and the seam stays optional for the seam's own
|
|
/// reason: a test binds a substrate-less board through it (`BoardUndoManager.history`).
|
|
///
|
|
/// The `HistoryStore` argument is what makes the git/gitless split decidable here, and it is why
|
|
/// `beginSession` composes the git state *before* the provider: which substrate a board gets is a
|
|
/// question about its repository, and a root that had to ask the disk itself would be a second
|
|
/// detection.
|
|
///
|
|
/// ### Consumers
|
|
///
|
|
/// `beginSession` calls it once per board, and `bindHistoryProvider(for:)` calls it again on the
|
|
/// one event that changes a board's answer under an open session — add-git's commanded flip,
|
|
/// which swaps `none`'s native stack for `git`'s trail.
|
|
@ObservationIgnored
|
|
public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in
|
|
guard let git else { return NativeHistoryProvider() }
|
|
switch git.mode {
|
|
case .git: return GitHistoryProvider(boardRoot: store.rootURL)
|
|
case .none, .repoNested: return NativeHistoryProvider()
|
|
}
|
|
}
|
|
|
|
// MARK: Sessions
|
|
|
|
/// One open board window and everything hanging off it.
|
|
public struct BoardSession {
|
|
|
|
/// The shared store — the same object every one of this board's windows renders.
|
|
public let store: BoardStore
|
|
|
|
/// Which registry record this board is, so the close flush can stamp counts and clear the
|
|
/// open-now flag without matching by identity a second time.
|
|
public let recordID: UUID
|
|
|
|
/// This board's undo/redo substrate — **the board half of 13-native-undo.md ▸ Rules' two
|
|
/// levels** (re-ruled 2026-07-31): one stack per board session, carrying board-surface
|
|
/// gestures and the one coarse step each card window's close registers. A card window's own
|
|
/// fine-grained stack is not here and never was the session's (`CardWindowUndo`, held by the
|
|
/// window). It lives here for the store's reason exactly: the session is what every window
|
|
/// over this board shares, and "undo is board-local".
|
|
///
|
|
/// Which implementation it is, is the tier's answer and nobody else's
|
|
/// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`.
|
|
///
|
|
/// **`nil` is a board with no undo at all, and no board the app composes is one any more**
|
|
/// (re-ruled 2026-07-31 — see `AppModel.makeHistoryProvider`): boards without app-managed git,
|
|
/// repo-nested included, bind the native stack in every tier, and git boards bind the git
|
|
/// provider. What keeps the optionality is the seam rather than a board: a test binds a
|
|
/// substrate-less session through `makeHistoryProvider`, and a store with no session at all
|
|
/// registers nothing (`BoardStore.registerStep`). The command surface disables through
|
|
/// `undoManager`, which answers the empty way over an absent substrate.
|
|
///
|
|
/// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single
|
|
/// sanctioned mid-session mode flip, *swaps* the substrate here on the board it flips —
|
|
/// native out, git in, the in-session steps discarded with it
|
|
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it — `tier` has no setter.
|
|
public var history: (any HistoryProviding)?
|
|
|
|
/// **The tier this board composed under** (12-editions.md ▸ The entitlement).
|
|
///
|
|
/// A `let`, on a value type, set once by `beginSession` — which is the entire mechanism
|
|
/// behind "a lapse never interrupts an open session: an open board finishes with the provider
|
|
/// it composed; the next open composes the native stack over inert `.git`". There is no
|
|
/// setter, no observation, and nothing anywhere that re-evaluates a live session's tier: a
|
|
/// subscription ending mid-session is a fact about the *next* open and about nothing that is
|
|
/// already on screen.
|
|
///
|
|
/// It is recorded rather than merely used-and-discarded because the provider it selects is
|
|
/// not the only thing that will ever ask. pro-m1's surfaces — the card window's History
|
|
/// section, View ▸ History (12 ▸ Tier matrix) — are per-board questions asked long after
|
|
/// composition, and they must get the answer this board actually opened with rather than
|
|
/// whatever the entitlement happens to say when the sidebar renders.
|
|
public let tier: Tier
|
|
|
|
/// **This board's git state** (06-history-undo.md ▸ Rules; 02-architecture.md ▸ Components
|
|
/// ▸ HistoryStore) — the detected mode, the repository behind it in git mode, and the
|
|
/// add-git action the popover offers on a board that has none.
|
|
///
|
|
/// `nil` under the free tier, and that is the inert posture made structural rather than
|
|
/// remembered: with no object there is nothing to consult, nothing to detect with, and no
|
|
/// path by which a free-tier session could touch `.git` (12-editions.md ▸ The free tier and
|
|
/// `.git`). `HistoryStore.compose` is the one place the tier decides it.
|
|
///
|
|
/// A `let` beside `tier`, for `tier`'s reason: which board this is a git story *of* is
|
|
/// settled at composition and cannot change under an open session. What can change is the
|
|
/// mode *inside* it, by add-git alone — the one commanded mid-session flip 06 allows.
|
|
public let git: HistoryStore?
|
|
|
|
/// The mode this board is being edited in, `none` when there is no git state at all — which
|
|
/// is every free-tier session ("The free tier ships exactly one mode: `none`",
|
|
/// 12-editions.md ▸ Tier matrix).
|
|
///
|
|
/// **Its first consumer is the provider seam** — `makeHistoryProvider` reads exactly this to
|
|
/// know whether the board has a repository to be an undo stack for, and it is the *mode*
|
|
/// rather than the tier that decides (re-ruled 2026-07-31): `git` binds the git provider,
|
|
/// `none` and `repoNested` alike the native stack. The popover's git section is the other
|
|
/// reader — and the one place the two gitless modes still differ, since add-git is offered on
|
|
/// one and explained away on the other.
|
|
///
|
|
/// `@MainActor` because the state it reads is: a nested type does not inherit its enclosing
|
|
/// type's isolation, and everything that asks a session what mode it is in is main-actor
|
|
/// work anyway (a menu, a popover, a provider being composed).
|
|
@MainActor
|
|
public var gitMode: BoardGitMode { git?.mode ?? .none }
|
|
|
|
/// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
|
|
/// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
|
|
/// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
|
|
///
|
|
/// Built once with the session rather than per window, because a second adapter would be a
|
|
/// second answer to "what is this board's undo" — and card windows share this one.
|
|
let undoManager: BoardUndoManager
|
|
|
|
/// This board's open card windows. The close flush's step 1 reads it; the card hosts
|
|
/// maintain it. Empty is the common case.
|
|
public var cardRefs: Set<CardWindowRef> = []
|
|
|
|
/// The scope the board is being read and written inside, released at teardown. `nil` when
|
|
/// the board was opened from a URL that needed none.
|
|
var access: ScopedAccess?
|
|
}
|
|
|
|
/// Keyed by board window, because that is the thing whose lifetime a session shares.
|
|
///
|
|
/// Observed: a card window watches for its board's session disappearing and dismisses itself when
|
|
/// it does — the safety net behind "card windows never outlive the board window".
|
|
public private(set) var sessions: [BoardWindowRef: BoardSession] = [:]
|
|
|
|
/// The end-session hooks, keyed the same way the card windows are.
|
|
///
|
|
/// Beside `BoardSession.cardRefs` rather than inside it: the set is *membership* (what the close
|
|
/// flush drains and what the safety net checks), this is the *seam table* (what it calls). They
|
|
/// are only ever written together, by the two register/unregister methods below, which is what
|
|
/// keeps them from becoming two answers to one question.
|
|
@ObservationIgnored
|
|
private var cardSessions: [CardWindowRef: any CardSessionFlushing] = [:]
|
|
|
|
/// Boards whose close flush is already running — the re-entrancy guard.
|
|
///
|
|
/// Needed because a board window can be told to close twice in quick succession: the
|
|
/// `windowShouldClose` interception starts the flush, and the host's own disappear runs a second
|
|
/// attempt as its safety net. The second must not re-enter a sequence that is mid-await.
|
|
@ObservationIgnored
|
|
private var closingBoards: Set<BoardWindowRef> = []
|
|
|
|
// MARK: Window actions
|
|
|
|
/// SwiftUI's window-opening action, captured from whatever scene view is alive.
|
|
///
|
|
/// It exists because the two things that most need to open a window are not views:
|
|
/// `AppDelegate.applicationShouldHandleReopen` (a Dock click with no windows must show welcome)
|
|
/// and the close-flush coordinator (which dismisses card windows). Neither can read
|
|
/// `@Environment`. The action stays valid after the view that supplied it is gone — it is a value
|
|
/// addressed to the app, not to a window — which is precisely the windowless case it is for.
|
|
///
|
|
/// `@ObservationIgnored` on both: nothing renders from them, and an assignment on every scene's
|
|
/// appear would otherwise invalidate every observer for no reason.
|
|
@ObservationIgnored
|
|
public var windowOpener: OpenWindowAction?
|
|
|
|
@ObservationIgnored
|
|
public var windowDismisser: DismissWindowAction?
|
|
|
|
/// URLs handed to `openBoard(at:)` before `windowOpener` existed to open them — a cold launch's
|
|
/// Finder-open (`AppDelegate.application(_:open:)`) can arrive ahead of the first scene's
|
|
/// `onAppear`. Held in order, replayed the moment `captureWindowActions` gives the app somewhere
|
|
/// to open them, then discarded — the buffer is a doorway, not a second registry of intent.
|
|
@ObservationIgnored
|
|
private var pendingOpenURLs: [URL] = []
|
|
|
|
/// What `CaptureOpenWindow` calls. A method rather than two assignments so the launch flow, which
|
|
/// needs the actions before any `onAppear` has run, has one thing to call.
|
|
///
|
|
/// Returns how many buffered Finder-open URLs it replayed — the restore bootstrap's input: a
|
|
/// launch that already opened a document's board must not put welcome up beside it, and only this
|
|
/// method knows the buffer wasn't empty.
|
|
@discardableResult
|
|
func captureWindowActions(open: OpenWindowAction, dismiss: DismissWindowAction) -> Int {
|
|
windowOpener = open
|
|
windowDismisser = dismiss
|
|
|
|
guard !pendingOpenURLs.isEmpty else { return 0 }
|
|
let urls = pendingOpenURLs
|
|
pendingOpenURLs.removeAll()
|
|
for url in urls {
|
|
openBoard(at: url)
|
|
}
|
|
return urls.count
|
|
}
|
|
|
|
// MARK: Recents
|
|
|
|
/// The recents list, cached: what the welcome window renders and what File ▸ Open Recent lists
|
|
/// (02-architecture.md § Per-board app state — "The recents list *is* this registry sorted by
|
|
/// last-opened").
|
|
///
|
|
/// **Cached rather than read through on demand, and both halves of that are deliberate.**
|
|
/// `BoardRegistry` is not `@Observable`, so a view reading it directly would never learn that a
|
|
/// row was forgotten; and `recents()` resolves every record's bookmark, which is filesystem work
|
|
/// no SwiftUI body should be doing on every evaluation — the File menu's command graph is
|
|
/// rebuilt far more often than this list changes. So the list lives here as observable state and
|
|
/// every path that can change the registry refreshes it explicitly (`refreshRecents()`).
|
|
///
|
|
/// The honest residual: a registry mutated behind this object's back would show stale until the
|
|
/// next refresh. There is no such path today — every writer goes through this type or through a
|
|
/// session it owns — and welcome refreshes on appearance as the cheap belt-and-braces.
|
|
public private(set) var recents: [RecentBoard] = []
|
|
|
|
/// Re-reads the registry into `recents`. Called wherever the registry changes: a board opening,
|
|
/// a board closing (the counts are stamped there), Forget, Clear Menu, and welcome appearing.
|
|
public func refreshRecents() {
|
|
recents = boardRegistry.recents()
|
|
}
|
|
|
|
/// The welcome row's Forget (11-command-nexus.md ▸ Welcome recent) — the record, plus any launch
|
|
/// failure that row was carrying, plus the refresh, in one call so no caller can do one without
|
|
/// the others.
|
|
///
|
|
/// **Forgetting the board forgets the failure too.** The row *is* the failure's surface (02
|
|
/// § Launch and window lifecycle); dropping the row while keeping the failure would relocate its
|
|
/// message into the unmatched-failures list, which reads as the app declining to forget.
|
|
public func forget(boardID: UUID) {
|
|
clearLaunchFailures(naming: knownPaths(ofBoard: boardID))
|
|
boardRegistry.forget(id: boardID)
|
|
refreshRecents()
|
|
}
|
|
|
|
/// File ▸ Open Recent ▸ Clear Menu (11-command-nexus.md).
|
|
///
|
|
/// **Finder clears the *menu*; here the registry is the menu**, so clearing removes every record
|
|
/// — there is no second list to clear, and a "menu" that still knew about the boards it had
|
|
/// stopped listing would be a distinction with no surface. What that costs is per-board settings
|
|
/// (window frames, push-on-commit) for boards the user reopens later, which is exactly what
|
|
/// Forget costs one row at a time and what 02's "its settings are conveniences" already accepts.
|
|
///
|
|
/// It is Forget applied wholesale, so it clears failures the same way — the ones naming records,
|
|
/// leaving a failure that named no row (and therefore no menu entry) standing in its own list.
|
|
///
|
|
/// A board that is open right now keeps working: its session holds a record id that no longer
|
|
/// resolves, and `BoardRegistry.update` treats an unknown id as a no-op for precisely this case.
|
|
public func clearRecents() {
|
|
clearLaunchFailures(naming: Set(recents.flatMap { recent in
|
|
[recent.record.lastKnownPath, recent.url?.path].compactMap { $0 }
|
|
}))
|
|
boardRegistry.forgetAll()
|
|
refreshRecents()
|
|
}
|
|
|
|
/// Every path a given record is known by — the one it was last seen at and, when its bookmark
|
|
/// still resolves, where it lives now. The two can differ (a bookmark follows a move), and a
|
|
/// failure recorded before the move names the older one.
|
|
private func knownPaths(ofBoard id: UUID) -> Set<String> {
|
|
var paths: Set<String> = []
|
|
if let record = boardRegistry.record(id: id) {
|
|
paths.insert(record.lastKnownPath)
|
|
}
|
|
if let url = recents.first(where: { $0.record.id == id })?.url {
|
|
paths.insert(url.path)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// MARK: Launch failures
|
|
|
|
/// Boards that failed to restore or open, newest last. Rendered on their own recents rows where
|
|
/// one exists, and in a fallback list where none does — `WelcomeRow.derive(recents:failures:)`.
|
|
public private(set) var launchFailures: [LaunchFailure] = []
|
|
|
|
// MARK: Card-window placement
|
|
|
|
/// Where the next card window cascades from (05-card-window.md, "New windows open at the
|
|
/// last-used card-window size, cascaded").
|
|
///
|
|
/// `NSWindow.cascadeTopLeft(from:)` is the whole mechanism: passing `.zero` places the window at
|
|
/// its natural position and returns the point for the next one, so this is a running cursor
|
|
/// rather than a computed grid. App-wide, not per-board: two boards' card windows cascade past
|
|
/// each other rather than landing on top of one another.
|
|
@ObservationIgnored
|
|
var cardCascadePoint: NSPoint = .zero
|
|
|
|
// MARK: Pending opens
|
|
|
|
/// 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" (`AppStateHome`). A suite that swept the real staging root
|
|
/// would be sweeping the developer's own clipboard.
|
|
///
|
|
/// `clipboardStagingRoot` is a separate parameter rather than derived from `registryStorageURL`'s
|
|
/// folder because the two are injected for different reasons and by different callers: the UI-test
|
|
/// fixture launch redirects both into one scratch root (`UITestLaunch`), a unit test usually wants
|
|
/// only one of them, and deriving would silently move a test's staging directory the day it moved
|
|
/// its registry file.
|
|
public init(
|
|
registryStorageURL: URL = BoardRegistry.defaultStorageURL,
|
|
clipboardStagingRoot: URL = ClipboardStore.defaultStagingRoot,
|
|
preferences: UserDefaults = .standard
|
|
) {
|
|
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
|
|
styleRecents = StyleRecents(defaults: preferences)
|
|
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
|
|
// Reads the cached facts and nothing else — no StoreKit API is touched until
|
|
// `ProEntitlement.start()`, which the app's launch calls and a test host never does.
|
|
let entitlement = ProEntitlement(defaults: preferences)
|
|
self.entitlement = entitlement
|
|
// Bound after the stored properties are in place, so the closure captures the object rather
|
|
// than a half-built `self`. This is the app's default wiring; a test that wants a tier
|
|
// assigns over it.
|
|
currentTier = { entitlement.tier }
|
|
// Read once here rather than lazily, so File ▸ Open Recent is populated from the app's first
|
|
// menu pass — a launch that restores boards never shows welcome, and a submenu that filled
|
|
// in only after the first close would look broken. It costs one bookmark-resolution sweep at
|
|
// launch, next to the one `restorables()` already runs.
|
|
refreshRecents()
|
|
}
|
|
|
|
// MARK: - Launch restoration
|
|
|
|
/// The launch-restoration gate, as a pure function (02-architecture.md § Launch and window
|
|
/// lifecycle: "the preference gates only whether the flagged set is consulted; the flags are
|
|
/// maintained regardless").
|
|
///
|
|
/// `KanbanApp.init()` is where this actually runs — read once, before any scene exists, into a
|
|
/// `let` rather than a computed property, because `restorables()` costs a bookmark resolution per
|
|
/// known board and nothing should pay that on every scene-graph evaluation. An `App`'s `init` is
|
|
/// not itself reachable from a test, so the decision is pulled out to here: two `Bool`s in, one
|
|
/// out, provable without a real `UserDefaults` domain or a live registry.
|
|
///
|
|
/// `nonisolated` because it is exactly as pure as that sentence claims — it touches no stored
|
|
/// state, and `LaunchPlan.decide` (which is not main-actor-bound either, for the same reason)
|
|
/// composes it into the three-way launch decision.
|
|
public nonisolated static func shouldRestoreAtLaunch(preference: Bool, hasRestorables: Bool) -> Bool {
|
|
preference && hasRestorables
|
|
}
|
|
|
|
// MARK: - Opening
|
|
|
|
public var hasOpenBoards: Bool { !sessions.isEmpty }
|
|
|
|
/// Opens a board window for `url`, or focuses the one this board already has.
|
|
///
|
|
/// **The already-open check is by file identity, not by path** — `liveStore(for:)` resolves it —
|
|
/// so a board reached through a resolved bookmark and the same board reached through the open
|
|
/// panel land on one window even when the two URLs are spelled differently. Only when nothing is
|
|
/// open for it does a ref get minted, and `openWindow(value:)` with an equal ref focuses rather
|
|
/// than duplicates, which is the second half of "one board window per root".
|
|
///
|
|
/// Security-scoped access starts here, *before* the window exists, because the host's very first
|
|
/// act is a tree walk: a scope started after the load would be too late.
|
|
///
|
|
/// **Called before any scene has appeared, and that's fine.** A cold launch's Finder-open can
|
|
/// reach here before `windowOpener` is captured; the URL joins `pendingOpenURLs` and this same
|
|
/// method runs again for it once `captureWindowActions` has something to open it with.
|
|
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?) {
|
|
// **The entitlement read** (12-editions.md ▸ The entitlement): "Pro state is read from
|
|
// StoreKit's signed on-device transaction store at board-session composition — the open path
|
|
// gains no network dependency." Synchronous, over facts already in memory, on the same line
|
|
// as the provider it selects — which is the shape that makes "the open path never waits on
|
|
// the App Store" checkable by reading four lines rather than by auditing a call graph. It is
|
|
// also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing
|
|
// re-derives it.
|
|
let tier = currentTier()
|
|
// **Mode detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board
|
|
// open"), on the same line as the tier that gates it. Under `.free` this returns `nil`
|
|
// without looking at the disk at all — the inert posture is unconditional there — and under
|
|
// `.pro` it is one `stat` per open, freshly, so a board that gained or lost a `.git` since
|
|
// its last open opens in the mode it now has.
|
|
//
|
|
// Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The
|
|
// running session keeps its mode, and the watcher does not scan for `.git` appearing."
|
|
//
|
|
// **Before the provider**, which is new in pro-m1: which substrate a board's undo is depends
|
|
// on the mode this line detects (`makeHistoryProvider`), and a root that had to look at the
|
|
// disk itself would be a second detection able to disagree with this one.
|
|
//
|
|
// **The ledger is the store's own** (06 ▸ Interaction with external writers: "the Writer/echo
|
|
// machinery — the EchoLedger — lets the auto-committer classify every observed change, per
|
|
// file, as app-mediated or foreign"). `compose` defaults to a fresh one for the store-less
|
|
// callers (the add-git surface, unit tests), and a session that took that default would hand
|
|
// the committer a ledger nothing ever writes to: every commit this app made would classify
|
|
// foreign and be authored `Lanework External`. The default is a fallback, never this path's.
|
|
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier, ledger: store.echoes)
|
|
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
|
|
// below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with
|
|
// the board session and dies at close/quit ... standard macOS behavior". On Pro's git boards
|
|
// it is instead the repository's own trail, which survives everything (06 ▸ Rules ▸ Undo
|
|
// survives relaunch) — the seam's whole point.
|
|
let history = makeHistoryProvider(store, tier, git)
|
|
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md ▸ Fractal
|
|
// layout ▸ Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
|
|
// everything else keeps injecting nothing. A *provider* rather than a ranker because each
|
|
// load wants its own — see `BoardStore.makeIdentityHistoryRanker` — and because add-git
|
|
// flips the mode mid-session, which this closure picks up for free by asking the git state
|
|
// at the moment of each load rather than at composition.
|
|
if let git {
|
|
store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker }
|
|
// **The auto-commit engine, wired into the session it commits for** (06-history-undo.md
|
|
// ▸ Rules ▸ Auto-commit). Called on every Pro session and not only on git-mode ones,
|
|
// because add-git can flip a board mid-session and the committer it builds then must land
|
|
// in exactly this shape — `activateAutoCommit` remembers the wiring for that.
|
|
git.activateAutoCommit { [weak store] committer in
|
|
guard let store else { return }
|
|
committer.currentSnapshot = { [weak store] in store?.snapshot }
|
|
// 02-architecture.md ▸ Write-failure surfacing, through the strip the board window
|
|
// already renders: a genuine commit failure means "your edits are saved, history has
|
|
// stopped advancing", which is exactly what the standing suspension row says. Lock
|
|
// contention and a held repository never reach here — neither is a failure.
|
|
committer.reportFailure = { [weak store] failure in
|
|
store?.banners.suspendHistory(reason: failure.message)
|
|
}
|
|
committer.reportRecovery = { [weak store] in
|
|
store?.banners.clearHistorySuspension()
|
|
}
|
|
store.commitSeam = .binding(to: committer)
|
|
// **The undo stack's ear on the committer** — every commit this engine lands, and
|
|
// which of it was heal work (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry,
|
|
// live; ▸ Heal commits are transparent to undo). Bound here rather than in
|
|
// `wireGitUndo` because add-git builds a *new* committer, and this wiring is what
|
|
// `activateAutoCommit` remembers on its behalf.
|
|
committer.reportLanded = { [weak self, ref] window in
|
|
guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return }
|
|
provider.noteLanded(window)
|
|
}
|
|
}
|
|
// **Add-git swaps the undo substrate too** (06 ▸ Rules ▸ Detection — the one commanded
|
|
// mid-session mode flip; 13-native-undo.md's header — "discards the in-session native
|
|
// stack and seeds the git trail from the root commit"). See `bindHistoryProvider(for:)`.
|
|
git.didAddGit = { [weak self] in
|
|
self?.bindHistoryProvider(for: ref)
|
|
}
|
|
}
|
|
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
|
|
// store is that boundary — every app-mediated mutation goes out through one of its write
|
|
// methods — so it is the store that computes each inverse and registers it. What it cannot
|
|
// know is *which* stack, because a stack belongs to a session and a store knows nothing about
|
|
// windows; this line is where the session tells it. Weak on the store's side, so the loop
|
|
// this closes (provider → step closures → store) is not a retain cycle.
|
|
store.history = history
|
|
sessions[ref] = BoardSession(
|
|
store: store,
|
|
recordID: recordID,
|
|
history: history,
|
|
tier: tier,
|
|
git: git,
|
|
// The lock's enablement half (13-native-undo.md ▸ Rules): Undo and Redo disable with the
|
|
// other mutating commands while the board refuses writes, and the stack survives to
|
|
// resume when it clears. Weak, so the adapter is never the reason a closed board's store
|
|
// stays alive; a store that has gone answers "writable", which is moot — its stack went
|
|
// with it.
|
|
undoManager: BoardUndoManager(history: history, isReadOnly: { [weak store] in
|
|
store?.isReadOnly ?? false
|
|
}),
|
|
cardRefs: [],
|
|
access: access
|
|
)
|
|
wireGitUndo(history, store: store, git: git, ref: ref)
|
|
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
|
|
refreshRecents()
|
|
}
|
|
|
|
// MARK: - The git provider's wiring
|
|
|
|
/// Fills a `GitHistoryProvider`'s seams with the session it is the history of — and does nothing
|
|
/// at all for any other substrate.
|
|
///
|
|
/// Everything the git provider needs is a fact about *this* board that neither a repository nor a
|
|
/// protocol could supply: which committer's debounce to settle first, whether the git surface is
|
|
/// held, which card windows a restore's diff would disturb, and the bracket a wholesale tree
|
|
/// change runs inside. Each arrives as a closure for `HistoryCommitSeam`'s reason — the provider
|
|
/// stays a thing that knows about commits, and the model stays the only object that knows what a
|
|
/// window is.
|
|
private func wireGitUndo(
|
|
_ history: (any HistoryProviding)?,
|
|
store: BoardStore,
|
|
git: HistoryStore?,
|
|
ref: BoardWindowRef
|
|
) {
|
|
guard let provider = history as? GitHistoryProvider, let git else { return }
|
|
|
|
provider.flushPendingCommit = { [weak git] in
|
|
await git?.committer?.flushNow()
|
|
}
|
|
provider.isHeld = { [weak git] in git?.committer?.pause != nil }
|
|
provider.suspendCommitting = { [weak git] in git?.committer?.stop() }
|
|
// The stage-around the settle released comes back with the committer: a card window still open
|
|
// after the restore is still a session (`resumeCardSessionStaging(for:)`).
|
|
provider.resumeCommitting = { [weak self, weak git] in
|
|
git?.committer?.start()
|
|
self?.resumeCardSessionStaging(for: ref)
|
|
}
|
|
// **A restore that failed cleanly** (06 ▸ Interaction with external writers: "surfaces as a
|
|
// one-shot banner failure naming the operation and the error, the tree left as it was").
|
|
//
|
|
// Posted as a **loss row**, and the compromise is recorded rather than hidden: the true
|
|
// failure class (`OneShotBanner`) carries a `BoardWriteError`, whose `operation` is the closed
|
|
// `WriteOperation` vocabulary — and a git operation is deliberately not one of those
|
|
// (`BoardStore.performWholesale`'s own note says so). The loss row is the nearest honest
|
|
// class: warning tone, one-shot lifecycle, never auto-expires, and a free-form message that
|
|
// can name both halves 06 asks for. A message-carrying failure class is the right fix and is
|
|
// a banner-surface change, not this card's.
|
|
provider.reportFailure = { [weak store] failure in
|
|
store?.banners.postLoss(failure.description)
|
|
}
|
|
provider.runBracketed = { [weak store] subject, work in
|
|
guard let store else { return await work() }
|
|
// The completion phrase 10-accessibility.md gives a bracketed operation is the restore's
|
|
// own subject — the sentence the trail now carries, spoken once when the reload lands.
|
|
try? await store.performWholesale(announcing: subject) { await work() }
|
|
}
|
|
provider.settleSessions = { [weak self, weak provider] paths in
|
|
guard let self, let provider else { return .proceed }
|
|
let gate = self.settleGate(for: ref) { [weak provider] folder in
|
|
// The card's uncommitted on-disk saves are reverted by the restore itself, which
|
|
// compares this folder against the working tree rather than against HEAD — see
|
|
// `GitRestoreOperation.plan`.
|
|
provider?.noteDiscarded(cardFolderName: folder)
|
|
}
|
|
let outcome = await gate.settle(touching: paths)
|
|
// **Only on `.proceed`** — a cancelled or failed settle leaves the board exactly as it
|
|
// was, sessions and their staging included.
|
|
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
|
|
return outcome
|
|
}
|
|
provider.seed()
|
|
|
|
wireBranchSwitching(git: git, store: store, provider: provider, ref: ref)
|
|
}
|
|
|
|
/// **The branch controls' seams** (06-history-undo.md ▸ Branch switching) — the five things the
|
|
/// switch's sequence needs that a repository cannot supply, plus the per-board stamp that makes an
|
|
/// interrupted switch recognizable as this app's.
|
|
///
|
|
/// Wired beside the undo provider's rather than in a place of its own, because the two are the
|
|
/// same board's git session seen from two sides — and because both must be re-wired on exactly the
|
|
/// same event, add-git's commanded mid-session flip (`bindHistoryProvider(for:)`).
|
|
private func wireBranchSwitching(
|
|
git: HistoryStore,
|
|
store: BoardStore,
|
|
provider: GitHistoryProvider,
|
|
ref: BoardWindowRef
|
|
) {
|
|
guard let switcher = git.switcher else { return }
|
|
let recordID = sessions[ref]?.recordID
|
|
|
|
switcher.flushPendingCommit = { [weak git] in await git?.committer?.flushNow() }
|
|
switcher.isHeld = { [weak git] in git?.committer?.pause != nil }
|
|
switcher.suspendCommitting = { [weak git] in git?.committer?.stop() }
|
|
// As on the restore path: what the settle released is a session that has not ended, and the
|
|
// window is still open on the other side of the checkout.
|
|
switcher.resumeCommitting = { [weak self, weak git] in
|
|
git?.committer?.start()
|
|
self?.resumeCardSessionStaging(for: ref)
|
|
}
|
|
// **The undo/redo reseed** — the provider's own API, which is the relaunch reseed by
|
|
// construction: "discarded and reseeded from the new HEAD's first-parent ancestry … redo
|
|
// starts empty".
|
|
switcher.reseedUndo = { [weak provider] in await provider?.reseed() }
|
|
switcher.didSwitch = { [weak git] in await git?.refreshBranch() }
|
|
switcher.runBracketed = { [weak store] announcement, work in
|
|
guard let store else { return await work() }
|
|
try? await store.performWholesale(announcing: announcement) { await work() }
|
|
}
|
|
switcher.beginProgress = { [weak store] label in
|
|
store?.banners.beginOperation(label: label) ?? UUID()
|
|
}
|
|
switcher.updateProgress = { [weak store] id, label in
|
|
store?.banners.updateOperation(id, label: label)
|
|
}
|
|
switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) }
|
|
// The loss row, on `GitHistoryProvider.reportFailure`'s recorded compromise — see it for why
|
|
// a git failure cannot be a `OneShotBanner` today.
|
|
switcher.reportFailure = { [weak store] failure in
|
|
store?.banners.postLoss(failure.description)
|
|
}
|
|
switcher.reportRecovery = { [weak store] message in
|
|
store?.banners.postLoss(message)
|
|
}
|
|
// **The per-board registry is the stamp's home** (`GitOperationStamp`). A session with no
|
|
// record — a store-level test — simply carries no stamp, and recovery then has nothing to
|
|
// recognize, which is the honest answer for a board the app has no state for.
|
|
switcher.readStamp = { [weak self] in
|
|
guard let self, let recordID else { return nil }
|
|
return self.boardRegistry.gitOperationStamp(id: recordID)
|
|
}
|
|
switcher.writeStamp = { [weak self] stamp in
|
|
guard let self, let recordID else { return }
|
|
self.boardRegistry.setGitOperationStamp(id: recordID, stamp)
|
|
}
|
|
switcher.settleSessions = { [weak self, weak switcher] in
|
|
guard let self, let switcher else { return .proceed }
|
|
let gate = self.settleGate(
|
|
for: ref,
|
|
message: SessionSettleStep.branchSwitchMessage
|
|
) { [weak switcher] folder in
|
|
switcher?.noteDiscarded(cardFolderName: folder)
|
|
}
|
|
// Every open session, not the ones a diff reaches — see `SessionSettleGate.settleAll`.
|
|
let outcome = await gate.settleAll()
|
|
// The switch's flush runs next and must find a tree it can settle whole — see
|
|
// `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough.
|
|
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
|
|
return outcome
|
|
}
|
|
|
|
// **The own-leftovers check, at open** (06 ▸ Rules ▸ Abnormal repo states). Beside the
|
|
// committer's start, which is where a pause first becomes knowable, and before anything the
|
|
// user does can land on top of a half-finished checkout.
|
|
Task { await switcher.recoverInterruptedOperation() }
|
|
}
|
|
|
|
/// **The save-or-discard step for one board**, built from its open card windows
|
|
/// (06-history-undo.md ▸ Rules ▸ Undo restore vs open Edit sessions; ▸ Branch switching).
|
|
///
|
|
/// Built per ask rather than stored, because its whole content is "which card windows are open
|
|
/// right now" — a set that changes under any operation slow enough to need the step at all.
|
|
///
|
|
/// - Parameters:
|
|
/// - message: what the step says it is about. The two callers describe different consequences —
|
|
/// a restore changes the cards being edited, a switch replaces them — and 06 gives the step to
|
|
/// both without giving either the other's wording.
|
|
/// - didDiscard: told each card folder the Discard branch abandoned, so the operation behind the
|
|
/// gate can put that folder's uncommitted saves back to HEAD its own way (the restore folds it
|
|
/// into its plan; the switch reverts before it flushes).
|
|
func settleGate(
|
|
for ref: BoardWindowRef,
|
|
message: String = SessionSettleStep.message,
|
|
didDiscard: @escaping (String) -> Void = { _ in }
|
|
) -> SessionSettleGate {
|
|
SessionSettleGate(
|
|
sessions: { [weak self] in
|
|
guard let self, let session = self.sessions[ref] else { return [] }
|
|
return session.cardRefs.compactMap { cardRef in
|
|
guard let flushing = self.cardSessions[cardRef],
|
|
let settlement = flushing.settlement else { return nil }
|
|
return SettleableSession(
|
|
id: cardRef.cardID,
|
|
cardFolderName: cardRef.cardID,
|
|
needsSettling: settlement.needsSettling,
|
|
saveAll: settlement.saveAll,
|
|
discard: {
|
|
settlement.discard()
|
|
didDiscard(cardRef.cardID)
|
|
}
|
|
)
|
|
}
|
|
},
|
|
ask: { await SessionSettleStep.ask(message: message) },
|
|
focus: { [weak self] id in
|
|
guard let self, let session = self.sessions[ref] else { return }
|
|
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
|
|
// Opening a window that is already open is how SwiftUI's value-addressed groups say
|
|
// "bring that one forward" — the same call `BoardWindowHost` makes to open a card, and
|
|
// the reason reopening a live card focuses its window rather than making a second one.
|
|
self.windowOpener?(id: WindowID.card, value: cardRef)
|
|
}
|
|
)
|
|
}
|
|
|
|
/// **Swaps the board's undo substrate onto an already-open session** — add-git's one caller.
|
|
///
|
|
/// 06 ▸ Rules ▸ Detection sanctions exactly one mid-session mode flip, the app's own add-git:
|
|
/// "clicking it flips the open board into git mode immediately — the popover flows straight into
|
|
/// the git controls, the first auto-commit follows". 13-native-undo.md's header spells out what
|
|
/// that does to undo: "**Add-git swaps the substrate mid-session** — the commanded flip discards
|
|
/// the in-session native stack and seeds the git trail from the root commit, the branch-switch
|
|
/// discard-and-reseed precedent applied".
|
|
///
|
|
/// ### The discard is the whole of it — there is no migration
|
|
///
|
|
/// The board opened mode-none under any tier now carries a live `NativeHistoryProvider` with real
|
|
/// steps on it (`makeHistoryProvider`), and those steps **die with the substrate**: they are
|
|
/// in-memory inverse operations against a board that has just acquired a commit trail, and
|
|
/// replaying one after the swap would walk the board back across a change the root commit already
|
|
/// records as the baseline. The branch-switch precedent says the same thing about the same
|
|
/// question — "the undo/redo stack does not survive a switch. It is discarded and reseeded from
|
|
/// the new HEAD's first-parent ancestry … redo starts empty" (06 ▸ Branch switching) — so the old
|
|
/// stack is cleared rather than merely dropped, and the git provider's `seed()` (in `wireGitUndo`)
|
|
/// walks a trail whose only commit is the root, which is the stack's floor and not a step: ⌘Z is
|
|
/// correctly empty the instant the flip lands.
|
|
///
|
|
/// **Nothing is announced.** 06 gives the flip the popover's own flow ("straight into the git
|
|
/// controls") and 13 gives the discard no surface at all, exactly as the branch switch's discard
|
|
/// has none; what the user sees is the Edit rows and the toolbar pair revalidating through
|
|
/// `BoardUndoManager` on AppKit's own cadence, which is the same machinery every other enablement
|
|
/// change on this board rides. Nothing here posts a banner, and nothing should.
|
|
///
|
|
/// ### Why live rather than at the next open
|
|
///
|
|
/// A judgment call, recorded when the free-tier matrix still left this board with no provider at
|
|
/// all: the mode flip already carries the *committer* through (`HistoryStore.activateAutoCommit`
|
|
/// remembers its wiring for precisely this board); 12-editions.md's "an open board finishes with
|
|
/// the provider it composed" is a rule about a **tier** lapsing, which cannot change a running
|
|
/// session at all; and a board that visibly starts accumulating commits while ⌘Z answers from a
|
|
/// stack the repository knows nothing about would read as a defect rather than as a policy.
|
|
///
|
|
/// Called exactly once per board, structurally: `HistoryStore.addGit` refuses any mode but
|
|
/// `none`, and flips to `.git` before it fires `didAddGit`.
|
|
func bindHistoryProvider(for ref: BoardWindowRef) {
|
|
guard var session = sessions[ref], let git = session.git, git.mode == .git else { return }
|
|
guard let history = makeHistoryProvider(session.store, session.tier, git) else { return }
|
|
guard history !== session.history else { return }
|
|
// Before the reassignment, while `session.history` is still the substrate being replaced: the
|
|
// in-flight native steps go with it, and any closure that outlives this line finds an empty
|
|
// stack rather than inverses against a pre-repository board.
|
|
session.history?.clear()
|
|
session.history = history
|
|
sessions[ref] = session
|
|
session.store.history = history
|
|
session.undoManager.history = history
|
|
wireGitUndo(history, store: session.store, git: git, ref: ref)
|
|
}
|
|
|
|
/// Registers a card window with its board's session, so the close flush can find it.
|
|
///
|
|
/// A card window whose board has no session is a card window with no board — the ownership rule
|
|
/// says that cannot exist, and the host's own check dismisses it before reaching this. Recording
|
|
/// the seam anyway would leave an entry nothing ever drains.
|
|
func registerCardWindow(_ ref: CardWindowRef, session: any CardSessionFlushing) {
|
|
guard sessions[ref.board] != nil else {
|
|
Self.logger.debug("card window registered against a board with no session — ignored")
|
|
return
|
|
}
|
|
sessions[ref.board]?.cardRefs.insert(ref)
|
|
cardSessions[ref] = session
|
|
// **The window *is* the commit unit** (06 ▸ Rules ▸ Auto-commit, widened 2026-07-31), so the
|
|
// stage-around opens here — with the window — rather than at the body's first Edit→Preview
|
|
// flip. From this line to `unregisterCardWindow` nothing this card's folder receives can land
|
|
// in an interim commit.
|
|
setCardSession(true, for: ref)
|
|
}
|
|
|
|
func unregisterCardWindow(_ ref: CardWindowRef) {
|
|
sessions[ref.board]?.cardRefs.remove(ref)
|
|
cardSessions[ref] = nil
|
|
// **The close flush's release** — and, for a window that left without its session ending (a
|
|
// crash-shaped teardown, a dismissal that raced the flush), the backstop that must not leave a
|
|
// card folder excluded from staging forever. Both are the same line because both mean the same
|
|
// thing: this window is no longer holding its folder back.
|
|
setCardSession(false, for: ref)
|
|
}
|
|
|
|
/// Tokens the committer knows each card window's session by. Beside `cardSessions` for its
|
|
/// reason: this is the seam table's third column, written only here.
|
|
@ObservationIgnored
|
|
private var cardSessionTokens: [CardWindowRef: UUID] = [:]
|
|
|
|
/// **A card window's session opened or closed** (06-history-undo.md ▸ Rules ▸ Auto-commit: the
|
|
/// committer "stages around the whole open card folder").
|
|
///
|
|
/// This is the honest seam between the two halves of the rule: the host knows a window exists, the
|
|
/// committer knows what staging is, and only the app model knows which board a card window belongs
|
|
/// to and how to reach its committer. A board with no committer — the free tier, a Pro board with
|
|
/// no repository — records nothing, which is the same `nil` every other git seam takes.
|
|
///
|
|
/// The card's folder is handed over as a **closure**, not a URL: a card can change lane, or be
|
|
/// moved into the trash, in the middle of a session, and what must be staged around is wherever
|
|
/// it is at the moment of the commit. `BoardStore.cardBodyTarget` is the resolution that spans
|
|
/// both containers, which is exactly why the body save uses it too.
|
|
///
|
|
/// Idempotent both ways: re-opening reuses the token (a settle that released it, then a resume),
|
|
/// and closing an already-closed session reaches a committer that has nothing to remove.
|
|
func setCardSession(_ isOpen: Bool, for ref: CardWindowRef) {
|
|
guard isOpen else {
|
|
// **The token goes whether or not there is anyone left to tell.** A card window's own
|
|
// teardown can land after its board's, and a token kept past the board it names would
|
|
// outlive everything that could ever release it.
|
|
guard let token = cardSessionTokens.removeValue(forKey: ref) else { return }
|
|
sessions[ref.board]?.git?.committer?.endCardSession(token)
|
|
return
|
|
}
|
|
guard let session = sessions[ref.board], let committer = session.git?.committer else { return }
|
|
let token = cardSessionTokens[ref] ?? UUID()
|
|
cardSessionTokens[ref] = token
|
|
let cardID = ref.cardIdentity
|
|
committer.beginCardSession(token) { [weak store = session.store] in
|
|
guard let store,
|
|
let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil }
|
|
return path.folder(under: store.rootURL)
|
|
}
|
|
}
|
|
|
|
/// **The stage-around releases at the settle step** (06 ▸ Branch switching; ▸ Rules ▸ Undo restore
|
|
/// vs open Edit sessions) — every open card window on this board, unconditionally.
|
|
///
|
|
/// ### Why unconditionally, rather than through the modal
|
|
///
|
|
/// The save-or-discard step asks about *buffers* — "unsaved keystrokes, or on-disk ~700 ms saves
|
|
/// the session hasn't committed" — and a window that is merely open, with a comment posted an hour
|
|
/// ago and a clean editor, answers `needsSettling` with `false`. Under the widened stage-around
|
|
/// that window is still holding its whole folder out of every commit, so leaving it held would
|
|
/// walk a checkout onto a dirty tree and break the one guarantee the settle exists to buy: "with
|
|
/// sessions settled the restore runs on a settled tree … it cannot fail dirty".
|
|
///
|
|
/// Releasing is therefore structural and silent, and the modal keeps its own narrower predicate:
|
|
/// Save All's flush then carries the session's commit, and Discard's reverted bytes are
|
|
/// reconciled by the operation itself (`GitRestoreOperation.plan`, `GitBranchSwitcher`), which is
|
|
/// why this runs *after* the gate has answered rather than before it.
|
|
func releaseCardSessionStaging(for ref: BoardWindowRef) {
|
|
for cardRef in sessions[ref]?.cardRefs ?? [] {
|
|
setCardSession(false, for: cardRef)
|
|
}
|
|
}
|
|
|
|
/// **The next session begins** — the other half of `releaseCardSessionStaging(for:)`, run when the
|
|
/// operation behind the settle has finished with the tree.
|
|
///
|
|
/// A window that is still open after a restore or a branch switch is still a session, and its
|
|
/// folder must go back to being staged around. Idempotent, so the paths that resume without ever
|
|
/// having released (a cancelled switch, the open-time leftover check) cost a dictionary lookup.
|
|
func resumeCardSessionStaging(for ref: BoardWindowRef) {
|
|
for cardRef in sessions[ref]?.cardRefs ?? [] {
|
|
setCardSession(true, for: cardRef)
|
|
}
|
|
}
|
|
|
|
// MARK: - Launch failures
|
|
|
|
/// Records a board that could not be opened. Deliberately additive and never cleared on success:
|
|
/// welcome is showing *because* something failed, and a list that emptied itself as other boards
|
|
/// arrived would be the silent drop 02 rules out.
|
|
public func recordLaunchFailure(path: String, message: String) {
|
|
launchFailures.append(LaunchFailure(path: path, message: message))
|
|
}
|
|
|
|
/// Forgets the failures — the welcome window's dismissal of a list the user has read.
|
|
public func clearLaunchFailures() {
|
|
launchFailures.removeAll()
|
|
}
|
|
|
|
/// Forgets exactly the named failures — what the unmatched-failures list's Clear dismisses, so
|
|
/// that pressing it never also erases a message still standing on a recents row the user has
|
|
/// not looked at.
|
|
public func clearLaunchFailures(ids: Set<UUID>) {
|
|
launchFailures.removeAll { ids.contains($0.id) }
|
|
}
|
|
|
|
/// Drops every failure naming one of `paths` — the resolution path, used when a board opens
|
|
/// successfully and when its record is forgotten. Paths are compared the way the welcome row's
|
|
/// join compares them, so "this row's failure" means the same thing in both places.
|
|
private func clearLaunchFailures(naming paths: Set<String>) {
|
|
guard !paths.isEmpty else { return }
|
|
let keys = Set(paths.map(WelcomeRow.pathKey))
|
|
launchFailures.removeAll { keys.contains(WelcomeRow.pathKey($0.path)) }
|
|
}
|
|
|
|
// MARK: - Counts
|
|
|
|
/// The lane and card counts stamped into the registry at close — **live items only** (02
|
|
/// § Per-board app state, settled).
|
|
///
|
|
/// > deleted lanes and cards don't count; the row advertises the board's working size, and the
|
|
/// > trash is an errand, not inventory.
|
|
///
|
|
/// **`.trash/` is excluded by construction** (02-architecture.md § Per-board app state,
|
|
/// re-grounded 2026-07-28 for the materialized trash): this walks `snapshot.lanes`, and the
|
|
/// trash is `snapshot.trash` — a sibling container, never a lane — so no filter is needed and
|
|
/// none could be forgotten. The tombstone era's ancestor walk over `deleted:` flags is gone with
|
|
/// the flag; a board an older version wrote counts its unmigrated cards until the migration moves
|
|
/// them, which is the safe direction and lasts exactly one write.
|
|
///
|
|
/// Static and pure: it is a fact about a snapshot, and the close flush is the wrong place to
|
|
/// discover a counting bug.
|
|
public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) {
|
|
var lanes = 0
|
|
var cards = 0
|
|
for lane in snapshot.lanes {
|
|
lanes += 1
|
|
cards += lane.cards.count
|
|
}
|
|
return (lanes, cards)
|
|
}
|
|
|
|
/// The folder name, extension stripped (01-storage-format.md § Board naming) — `displayName`'s
|
|
/// own fallback, and (02-architecture.md § Per-board app state) the registry record's
|
|
/// *provisional* display name for a board recorded before its load has run: "fail-fast means the
|
|
/// frontmatter can't be trusted, and the folder name is the Finder document name the user just
|
|
/// picked". A first successful load replaces it with the cached title through the ordinary
|
|
/// `displayName(of:)` path — there is no separate provisional vocabulary, just this one fallback
|
|
/// used a moment earlier than usual.
|
|
public static func folderDisplayName(of url: URL) -> String {
|
|
url.deletingPathExtension().lastPathComponent
|
|
}
|
|
|
|
/// A board's display name: its `title`, falling back to the folder name sans extension
|
|
/// (01-storage-format.md § Board naming).
|
|
///
|
|
/// Read from `store.rootURL` rather than `snapshot.rootURL` so the fallback follows a rename the
|
|
/// moment it is absorbed, instead of lagging by one reload (see `BoardStore.rootURL`).
|
|
public static func displayName(of store: BoardStore) -> String {
|
|
if let title = store.snapshot.title.value, !title.isEmpty {
|
|
return title
|
|
}
|
|
return folderDisplayName(of: store.rootURL)
|
|
}
|
|
|
|
// MARK: - Closing
|
|
|
|
/// Runs the close flush for one board and tears its session down.
|
|
///
|
|
/// Idempotent by two guards: a board with no session has already closed, and a board already
|
|
/// mid-flush is not started again. Both matter — the window's close interception and the host's
|
|
/// disappear both call this, by design, because neither one alone fires on every path a window
|
|
/// can leave by.
|
|
public func closeBoard(ref: BoardWindowRef, cause: BoardCloseCause) async {
|
|
guard sessions[ref] != nil, !closingBoards.contains(ref) else { return }
|
|
closingBoards.insert(ref)
|
|
defer { closingBoards.remove(ref) }
|
|
|
|
await coordinator(for: ref).run(cause: cause)
|
|
// The flush stamped this board's counts and (on a user close) cleared its open-now flag, so
|
|
// the cached list is now one close out of date — and welcome is often the very next thing on
|
|
// screen.
|
|
refreshRecents()
|
|
}
|
|
|
|
/// The close flush's **pending-work step, without the teardown** — what File ▸ Duplicate runs
|
|
/// before it copies (03-board-ui.md § Welcome screen & templates: "The copy is preceded by the
|
|
/// close flush ... so neither the tree nor the copied history misses pending work").
|
|
///
|
|
/// **Not `closeBoard`**, and the design says so itself: 09-templates.md ▸ Save as Template states
|
|
/// the rule together with its exception — "with the pull-style mechanical exception committing an
|
|
/// open Edit session's on-disk saves as-is, **sessions staying open**". A duplicate leaves the
|
|
/// original on screen (03: "the original stays open too"), so what it needs is pending work
|
|
/// *landed on disk*, not a session ended: no card window is dismissed, no record is stamped
|
|
/// closed, nothing is torn down, and the board the user is looking at never blinks.
|
|
///
|
|
/// It goes through `CloseFlushCoordinator` rather than calling the store directly so that the
|
|
/// order of the three flushes — store pipeline, then editor saves, then the pending auto-commit
|
|
/// (02's own order) — keeps having exactly one definition.
|
|
public func flushPendingWork(for ref: BoardWindowRef) async {
|
|
guard sessions[ref] != nil else { return }
|
|
await coordinator(for: ref).flushPendingWork()
|
|
}
|
|
|
|
/// Whether any of this board's card windows is holding content the files do not have — a dirty
|
|
/// Edit buffer or a typed-in raw-source outlet (`CardSessionFlushing.holdsUnsavedContent`).
|
|
///
|
|
/// **One caller, one rule**: File ▸ Save as Template's carve-out from the read-only lock. Under
|
|
/// the unwritable-location lock the item stays live — "reads the board, writes Application
|
|
/// Support" — but only while no such session exists, because the lock has suspended exactly the
|
|
/// saves that would flush one and 09-templates.md's never-misses-keystrokes guarantee outranks
|
|
/// the item's availability (02-architecture.md ▸ Live-reload resilience, settled scoping).
|
|
///
|
|
/// It asks the sessions rather than the store: the content in question is in *memory*, in the
|
|
/// card windows, which is the whole reason the flush cannot reach it.
|
|
public func hasUnsavedCardContent(for ref: BoardWindowRef) -> Bool {
|
|
guard let session = sessions[ref] else { return false }
|
|
return session.cardRefs.contains { cardSessions[$0]?.holdsUnsavedContent == true }
|
|
}
|
|
|
|
/// **The purchase flow's reopen offer, carried out** (12-editions.md ▸ The entitlement:
|
|
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
|
|
/// boards so the upgrade feels immediate").
|
|
///
|
|
/// ### Close and open, through the ordinary paths
|
|
///
|
|
/// There is no reopen-in-place mechanism here and deliberately so: the provider binding is a
|
|
/// composition-time fact, so "apply the new tier to this board" *means* end its session and
|
|
/// compose a new one. Doing that through `closeBoard` and `openBoard` — the same two calls ⌘W and
|
|
/// welcome make — is what keeps every guarantee those paths carry: the close flush runs in its
|
|
/// fixed order (card windows, pending work, registry stamp, teardown), the reopen resolves and
|
|
/// re-scopes the board's URL exactly as a fresh open does, and the registry records both.
|
|
///
|
|
/// ### Why the window is dismissed rather than reused
|
|
///
|
|
/// A board window's identity is its root path (`BoardWindowRef`), so reopening the same board
|
|
/// hands `openWindow(value:)` a ref it already has a window for — which *focuses* that window
|
|
/// instead of building a new one, and the window it would focus is one whose host has already run
|
|
/// its one-shot load. Dismissing first is what makes the reopen an open. The dismissals are all
|
|
/// issued before any reopen, then given a run-loop turn to land: SwiftUI processes a window's
|
|
/// teardown asynchronously, and asking for a value's window in the same turn it was dismissed is
|
|
/// the one way this sequence can produce a focused corpse.
|
|
///
|
|
/// ### Declining costs nothing, today least of all
|
|
///
|
|
/// A board that says Not Now keeps the substrate it composed with, and since the provider follows
|
|
/// the board (`makeHistoryProvider`), a gitless board's undo is the *same* native stack either
|
|
/// way — declining costs undo nothing at all, and costs a git board only the trail it would have
|
|
/// gained at its next open. The offer exists because "subscribe takes effect at each board's next
|
|
/// open" (12 ▸ The entitlement) needs one, not because anything breaks without it.
|
|
public func reopenOpenBoards() async {
|
|
// Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s.
|
|
let refs = sessions.keys.sorted { $0.path < $1.path }
|
|
guard !refs.isEmpty else { return }
|
|
|
|
// The store's `rootURL` rather than the ref's path: a board renamed while open keeps the
|
|
// path it was opened with, and reopening it there would open nothing (`BoardStore.rootURL`).
|
|
var roots: [URL] = []
|
|
for ref in refs {
|
|
guard let session = sessions[ref] else { continue }
|
|
roots.append(session.store.rootURL)
|
|
await closeBoard(ref: ref, cause: .userClose)
|
|
windowDismisser?(value: ref)
|
|
}
|
|
|
|
// One run-loop turn for the dismissals — see the note above. `Task.sleep` rather than
|
|
// `Task.yield` because the main run loop, not the cooperative pool, is what has to advance.
|
|
try? await Task.sleep(for: .milliseconds(150))
|
|
|
|
for root in roots {
|
|
openBoard(at: root)
|
|
}
|
|
}
|
|
|
|
/// Quit: the same sequence, once per open board, **sequentially**.
|
|
///
|
|
/// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than
|
|
/// three interleavings of it, and in a stable board order so a quit is reproducible. Nothing here
|
|
/// clears an open-now flag — that is what `.quit` means, and it is what makes the next launch
|
|
/// restore this set (§ Launch and window lifecycle).
|
|
public func flushAllBoardsForQuit() async {
|
|
for ref in sessions.keys.sorted(by: { $0.path < $1.path }) {
|
|
await closeBoard(ref: ref, cause: .quit)
|
|
}
|
|
}
|
|
|
|
/// Wires a session into `CloseFlushCoordinator`'s seams. The ordering lives over there; this is
|
|
/// only which real object each step touches.
|
|
private func coordinator(for ref: BoardWindowRef) -> CloseFlushCoordinator {
|
|
CloseFlushCoordinator(
|
|
openCardRefs: { [weak self] in
|
|
// Sorted so a board with several card windows commits and closes them in a stable
|
|
// order rather than a `Set`'s.
|
|
(self?.sessions[ref]?.cardRefs).map { $0.sorted { $0.cardID < $1.cardID } } ?? []
|
|
},
|
|
endCardSession: { [weak self] cardRef in
|
|
await self?.cardSessions[cardRef]?.endSession()
|
|
// **The release, here rather than only at the host's unregister** (06 ▸ Rules ▸
|
|
// Auto-commit). The unregister does release it — that is what closes a window on its
|
|
// own — but it arrives from the *window's* teardown, which this sequence waits for
|
|
// only up to `cardDrainDeadline` and then proceeds anyway. A quit whose last window
|
|
// was slow to disappear would then flush with the folder still staged around and leave
|
|
// a settled session uncommitted, which is precisely what "nothing settled is ever left
|
|
// ... uncommitted by closing" forbids. Ending the session is this step's own act, so
|
|
// releasing what the session held is too. Idempotent with the unregister.
|
|
self?.setCardSession(false, for: cardRef)
|
|
},
|
|
dismissCardWindow: { [weak self] cardRef in
|
|
self?.windowDismisser?(value: cardRef)
|
|
},
|
|
storeFlush: { [weak self] in
|
|
await self?.sessions[ref]?.store.awaitQuiescence()
|
|
},
|
|
// `editorFlush` stays nil, and now deliberately rather than for want of an editor: the
|
|
// card windows' debounced body saves flush in **step 1**, inside each window's
|
|
// `endSession()` (`CardWindowSession`), which is both earlier than this slot and where
|
|
// 02-architecture.md puts them ("each open Edit session ends with its normal session
|
|
// commit", then pending work). The slot stays for a board-level editor with no card
|
|
// window of its own — the raw-source buffer is the candidate — so that the order
|
|
// relative to `committerFlush` is already decided when one arrives.
|
|
//
|
|
// **`committerFlush` is filled now** (06-history-undo.md ▸ Rules ▸ Auto-commit: "Board
|
|
// window close and app quit flush the pipeline — any pending editor save, then the
|
|
// pending auto-commit — before teardown; nothing settled is ever left unsaved or
|
|
// uncommitted by closing"). By the time it runs, step 1 has ended every card window's
|
|
// session *and released its stage-around*, so each session — body, comments, purge and
|
|
// all — lands in exactly one commit. `nil` on every board with no committer, which is the
|
|
// whole free tier.
|
|
committerFlush: { [weak self] in
|
|
await self?.sessions[ref]?.git?.committer?.flushNow()
|
|
},
|
|
recordClose: { [weak self] in
|
|
guard let self, let session = sessions[ref] else { return }
|
|
let counts = Self.liveCounts(of: session.store.snapshot)
|
|
boardRegistry.recordClose(
|
|
id: session.recordID,
|
|
displayName: Self.displayName(of: session.store),
|
|
laneCount: counts.lanes,
|
|
cardCount: counts.cards,
|
|
icon: session.store.snapshot.icon.value,
|
|
iconColor: session.store.snapshot.iconColor.value
|
|
)
|
|
},
|
|
clearOpenNow: { [weak self] in
|
|
guard let self, let session = sessions[ref] else { return }
|
|
boardRegistry.clearOpenNow(id: session.recordID)
|
|
},
|
|
tearDown: { [weak self] in
|
|
guard let self, let session = sessions.removeValue(forKey: ref) else { return }
|
|
// The committer dies with the session it commits for, `history.clear()`'s reason
|
|
// exactly: its debounce holds a closure over the store this line is about to release,
|
|
// and a timer that outlived its board would fire against a repository nobody is
|
|
// looking at. Its pending work has already been flushed by `committerFlush` above.
|
|
session.git?.stopAutoCommit()
|
|
// Session-only persistence, the other half of `beginSession` (13-native-undo.md
|
|
// ▸ Rules): "the stack ... dies at close/quit", so reopening the board starts empty.
|
|
// Cleared rather than merely dropped because the steps hold closures over the store
|
|
// this line is about to release, and a stack that outlived its board would be a
|
|
// retain cycle wearing an undo stack's clothes.
|
|
session.history?.clear()
|
|
storeRegistry.release(session.store)
|
|
session.access?.stop()
|
|
}
|
|
)
|
|
}
|
|
}
|