Every crossing validates its expectations before writing: each step
carries per-item HistoryExpectations — folder, effective ancestor-walked
liveness, and exactly the fields the gesture set — and a mismatch pops
the step, posts the signpost ('Undo skipped — Fix login changed outside
Lanework'), and falls through to the next. Validation reads disk, not
the in-memory snapshot: the snapshot is by construction one reload
behind every app write, so a rapid second undo would false-skip against
the pre-state — disk is what current can honestly mean at press time.
Stale and failed part ways: a stale step is one the board moved past,
so dropping it loses nothing; a failed one is refused by a usually
momentary condition, so it stays put and the crossing stops with only
performWrite's own error row — which forced the provider off
NSUndoManager onto two plain arrays, since a popped group cannot be put
back. The read-only lock disables Undo/Redo through the adapter while
the stack survives to resume on clear. Delete and restore validate
presence alone — a machine timestamp is not a decision — and a
malformed field matches nothing, since it is a shape the app never
writes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
780 lines
41 KiB
Swift
780 lines
41 KiB
Swift
import AppKit
|
|
import Observation
|
|
import SwiftUI
|
|
import os
|
|
|
|
// MARK: - Scene ids
|
|
|
|
/// The scene identifiers, in one place because they are matched by string in three unrelated
|
|
/// spots — the scene declaration, `openWindow(id:)`, and `dismissWindow(id:)` — and a typo in any
|
|
/// one of them fails silently at runtime.
|
|
public enum WindowID {
|
|
public static let welcome = "welcome"
|
|
public static let restoreBootstrap = "restore-bootstrap"
|
|
/// The template chooser (09-templates.md; File ▸ New Board… ⌥⌘N). Its own window rather than a
|
|
/// sheet on welcome because ⌥⌘N is available *everywhere* (11-command-nexus.md) — including from
|
|
/// a board window, and including when welcome is not open at all, which a sheet would have to
|
|
/// conjure a host for.
|
|
public static let templateChooser = "template-chooser"
|
|
public static let board = "board"
|
|
public static let card = "card"
|
|
}
|
|
|
|
// MARK: - App-wide preferences
|
|
|
|
/// The `UserDefaults` half of "App-wide state has the same home" (02-architecture.md § Per-board app
|
|
/// state): the app-scoped values that are scalars, kept out of the board registry because no board
|
|
/// owns them.
|
|
///
|
|
/// The keys are declared here rather than spelled at each `@AppStorage`, for the same reason
|
|
/// `WindowID` exists.
|
|
public enum AppPreferences {
|
|
|
|
/// "Restore open boards at launch" (Settings, ⌘, — 11-command-nexus.md). **Default on.**
|
|
public static let restoreOpenBoardsAtLaunchKey = "restoreOpenBoardsAtLaunch"
|
|
|
|
/// Read outside a view, where `@AppStorage` is not available — the launch flow needs it before
|
|
/// any scene exists. `object(forKey:)` rather than `bool(forKey:)` because the latter cannot
|
|
/// tell "off" from "never set", and this preference defaults to *on*.
|
|
public static var restoreOpenBoardsAtLaunch: Bool {
|
|
UserDefaults.standard.object(forKey: restoreOpenBoardsAtLaunchKey) as? Bool ?? true
|
|
}
|
|
|
|
/// The last-used card-window size (05-card-window.md; 02 files it as app-wide, not per-board —
|
|
/// "the last-used card-window size" is named there explicitly). Stored as a string because
|
|
/// `NSSize` is not a property-list type and two more keys would be worse.
|
|
public static let lastCardWindowSizeKey = "lastCardWindowSize"
|
|
|
|
public static var lastCardWindowSize: CGSize? {
|
|
guard let text = UserDefaults.standard.string(forKey: lastCardWindowSizeKey) else { return nil }
|
|
let size = NSSizeFromString(text)
|
|
guard size.width > 0, size.height > 0 else { return nil }
|
|
return size
|
|
}
|
|
|
|
public static func setLastCardWindowSize(_ size: CGSize) {
|
|
UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
|
|
}
|
|
|
|
/// The quick-style row's recently-used backgrounds — an array of palette names / hex strings,
|
|
/// most-recent-first (03-board-ui.md § Styling ▸ Controls: "Recents are app-wide and persist
|
|
/// app-side (user preference, never board data)"; 11-command-nexus.md files it under the
|
|
/// preferences that "need no UI"). Read and written by `StyleRecents`, which owns the list rule;
|
|
/// the key is declared here with its neighbours for `WindowID`'s reason.
|
|
public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"
|
|
}
|
|
|
|
// MARK: - Launch failures
|
|
|
|
/// A board that could not be restored or opened, as the welcome window renders it.
|
|
///
|
|
/// **A struct rather than the obvious tuple** only because SwiftUI needs identity to list these and
|
|
/// two failures can share a path (a board that failed, was retried, and failed again).
|
|
///
|
|
/// The join onto a recents row is `WelcomeRow.derive(recents:failures:)` — 02 § Launch and window
|
|
/// lifecycle wants the failure *on the board's row*, carrying fail-fast's specifics or the
|
|
/// unavailable state, and a failure naming no row (a first open of a folder that was never a board)
|
|
/// falls back to a list of its own. `path` is what the join matches on, which is why it is stored
|
|
/// rather than derived from the message.
|
|
public struct LaunchFailure: Identifiable, Sendable, Equatable {
|
|
public let id = UUID()
|
|
public let path: String
|
|
public let message: String
|
|
|
|
public init(path: String, message: String) {
|
|
self.path = path
|
|
self.message = message
|
|
}
|
|
|
|
/// What the row shows for a name: the folder, not the whole path. The path is the subtitle.
|
|
public var displayName: String {
|
|
URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent
|
|
}
|
|
}
|
|
|
|
// MARK: - Security-scoped access
|
|
|
|
/// One board's security-scoped access, held for the **whole session**.
|
|
///
|
|
/// `BoardRegistry.withScopedAccess(to:_:)` is the scoped-per-call form and is right for what it does
|
|
/// — resolving identities during a recents listing, where holding a scope open would be a leak. It is
|
|
/// exactly wrong for an open board: the store, the watcher, and every Writer call need access for
|
|
/// minutes or hours, and re-entering the scope per call would be both slower and racy against a
|
|
/// watcher thread that is already inside the folder.
|
|
///
|
|
/// So the pairing is explicit and its balance is the session's job: started when the board's window
|
|
/// opens, stopped in the close flush's teardown step. A class rather than a struct so the balance
|
|
/// cannot be duplicated by a copy.
|
|
///
|
|
/// **The URL matters, not the path.** A security-scoped URL is a token, not a string: a `URL`
|
|
/// rebuilt from `ref.path` grants nothing, which is why `AppModel.openBoard(at:)` stashes the
|
|
/// resolved URL for the host that is about to appear instead of letting it reconstruct one.
|
|
public final class ScopedAccess {
|
|
|
|
public let url: URL
|
|
private var started: Bool
|
|
|
|
public init(_ url: URL) {
|
|
self.url = url
|
|
// `false` for a URL that is not security-scoped — a plain bookmark's, one the open panel
|
|
// already blessed for the app's lifetime, anything inside the container. There is then
|
|
// nothing to stop, and the pairing stays balanced either way.
|
|
started = url.startAccessingSecurityScopedResource()
|
|
}
|
|
|
|
public func stop() {
|
|
guard started else { return }
|
|
started = false
|
|
url.stopAccessingSecurityScopedResource()
|
|
}
|
|
}
|
|
|
|
// MARK: - AppModel
|
|
|
|
/// The app's one piece of cross-window state: which boards are open, which card windows belong to
|
|
/// which board, and the two window actions AppKit-side code needs but cannot reach.
|
|
///
|
|
/// ### What lives here, and why it is not a singleton
|
|
///
|
|
/// The two registries (02-architecture.md § Layering ▸ Components and § Per-board app state) are
|
|
/// owned here because they are app-scoped and because "the app holds one instance, so a test can
|
|
/// hold its own without the two colliding" — `BoardStoreRegistry`'s own note. Everything else here is
|
|
/// window bookkeeping that has no other home: a `BoardStore` knows nothing about windows by design,
|
|
/// and a SwiftUI scene is a value that cannot hold state across a window's life.
|
|
///
|
|
/// ### Sessions are the join
|
|
///
|
|
/// A `BoardSession` is what makes the two halves of the app meet: the store the windows share, the
|
|
/// registry record they stamp, the card windows the close flush has to close first, and the
|
|
/// security-scoped access the whole thing runs inside. Its lifetime is exactly the board window's —
|
|
/// created when the host's load succeeds, removed by the close flush's last step. A card window with
|
|
/// no session is a card window with no board, which 02's ownership rule says cannot exist; the card
|
|
/// host reads that as "dismiss".
|
|
@MainActor
|
|
@Observable
|
|
public final class AppModel {
|
|
|
|
// MARK: Registries
|
|
|
|
public let storeRegistry = BoardStoreRegistry()
|
|
public let boardRegistry: BoardRegistry
|
|
|
|
/// The quick-style row's app-wide recents (03-board-ui.md § Styling ▸ Controls). Owned here for
|
|
/// the registries' reason — app-scoped, and a test holds its own rather than colliding with the
|
|
/// app's — and reached by the context menus through the environment, since a `BoardStore` is
|
|
/// board-scoped and this list deliberately is not.
|
|
public let styleRecents = StyleRecents()
|
|
|
|
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
|
///
|
|
/// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
|
|
/// source board hides the dragged items while any other open board's drop delegates propose a
|
|
/// landing spot for them. It lives here rather than as a global for `styleRecents`' reason — a
|
|
/// test holds its own rather than colliding with the app's — and every board window reaches it
|
|
/// through the environment.
|
|
/// Internal rather than `public`, unlike its neighbours: the drag is entirely a UI-layer
|
|
/// concern, and nothing outside this module has any business reaching into a gesture in flight.
|
|
let dragSession = DragSession()
|
|
|
|
/// The app's one clipboard (04-interactions.md ▸ Clipboard).
|
|
///
|
|
/// App-wide for the drag session's reason turned up a level: a cut/copy **outlives the board it
|
|
/// came from** — the pasteboard and the staged snapshot survive the source window closing, and
|
|
/// survive the app quitting — so nothing board-scoped could own it. It lives here rather than as
|
|
/// a singleton for `styleRecents`' reason (a test holds its own rather than colliding with the
|
|
/// app's, which for this one also means staying off the machine's real pasteboard), and every
|
|
/// board window reaches it through the environment.
|
|
///
|
|
/// Building it here is also the **launch sweep** (04: "a sweep at launch"): the store's `init`
|
|
/// reads the pasteboard once and collects every staged tree it no longer names.
|
|
public let clipboard = ClipboardStore()
|
|
|
|
// MARK: The provider seam
|
|
|
|
/// **The composition root for `HistoryProviding`** (12-editions.md ▸ The provider seam): what a
|
|
/// board session's undo stack is built by, called once per board as its session begins.
|
|
///
|
|
/// Base binds the native stack — an `NSUndoManager` over inverse `WriteOperation`s
|
|
/// (13-native-undo.md) — and that is the default here because it is the *shared* code's
|
|
/// implementation: both targets compile it, and Pro runs it too until pro-m1 replaces this
|
|
/// closure with the git provider (06-history-undo.md). Nothing in this file is
|
|
/// edition-conditional; the edition difference is which closure the root installs.
|
|
///
|
|
/// It takes the store because that is what a provider is a history *of*: the git provider needs
|
|
/// the board root it is a repository at, and the native one is about to need the same store's
|
|
/// snapshots to compute inverses from. A property rather than an initializer argument so a test
|
|
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
|
|
/// nothing renders from it.
|
|
@ObservationIgnored
|
|
public var makeHistoryProvider: (BoardStore) -> any HistoryProviding = { _ in NativeHistoryProvider() }
|
|
|
|
// MARK: Sessions
|
|
|
|
/// One open board window and everything hanging off it.
|
|
public struct BoardSession {
|
|
|
|
/// The shared store — the same object every one of this board's windows renders.
|
|
public let store: BoardStore
|
|
|
|
/// Which registry record this board is, so the close flush can stamp counts and clear the
|
|
/// open-now flag without matching by identity a second time.
|
|
public let recordID: UUID
|
|
|
|
/// This board's undo/redo substrate — **one stack per board session, never per window**
|
|
/// (13-native-undo.md ▸ Rules). It lives here for the store's reason exactly: the session is
|
|
/// what every window over this board shares, and "undo is board-local".
|
|
///
|
|
/// Which implementation it is, is the edition's answer and nobody else's
|
|
/// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`.
|
|
public let history: any HistoryProviding
|
|
|
|
/// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
|
|
/// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
|
|
/// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
|
|
///
|
|
/// Built once with the session rather than per window, because a second adapter would be a
|
|
/// second answer to "what is this board's undo" — and card windows share this one.
|
|
let undoManager: BoardUndoManager
|
|
|
|
/// This board's open card windows. The close flush's step 1 reads it; the card hosts
|
|
/// maintain it. Empty is the common case.
|
|
public var cardRefs: Set<CardWindowRef> = []
|
|
|
|
/// The scope the board is being read and written inside, released at teardown. `nil` when
|
|
/// the board was opened from a URL that needed none.
|
|
var access: ScopedAccess?
|
|
}
|
|
|
|
/// Keyed by board window, because that is the thing whose lifetime a session shares.
|
|
///
|
|
/// Observed: a card window watches for its board's session disappearing and dismisses itself when
|
|
/// it does — the safety net behind "card windows never outlive the board window".
|
|
public private(set) var sessions: [BoardWindowRef: BoardSession] = [:]
|
|
|
|
/// The end-session hooks, keyed the same way the card windows are.
|
|
///
|
|
/// Beside `BoardSession.cardRefs` rather than inside it: the set is *membership* (what the close
|
|
/// flush drains and what the safety net checks), this is the *seam table* (what it calls). They
|
|
/// are only ever written together, by the two register/unregister methods below, which is what
|
|
/// keeps them from becoming two answers to one question.
|
|
@ObservationIgnored
|
|
private var cardSessions: [CardWindowRef: any CardSessionFlushing] = [:]
|
|
|
|
/// Boards whose close flush is already running — the re-entrancy guard.
|
|
///
|
|
/// Needed because a board window can be told to close twice in quick succession: the
|
|
/// `windowShouldClose` interception starts the flush, and the host's own disappear runs a second
|
|
/// attempt as its safety net. The second must not re-enter a sequence that is mid-await.
|
|
@ObservationIgnored
|
|
private var closingBoards: Set<BoardWindowRef> = []
|
|
|
|
// MARK: Window actions
|
|
|
|
/// SwiftUI's window-opening action, captured from whatever scene view is alive.
|
|
///
|
|
/// It exists because the two things that most need to open a window are not views:
|
|
/// `AppDelegate.applicationShouldHandleReopen` (a Dock click with no windows must show welcome)
|
|
/// and the close-flush coordinator (which dismisses card windows). Neither can read
|
|
/// `@Environment`. The action stays valid after the view that supplied it is gone — it is a value
|
|
/// addressed to the app, not to a window — which is precisely the windowless case it is for.
|
|
///
|
|
/// `@ObservationIgnored` on both: nothing renders from them, and an assignment on every scene's
|
|
/// appear would otherwise invalidate every observer for no reason.
|
|
@ObservationIgnored
|
|
public var windowOpener: OpenWindowAction?
|
|
|
|
@ObservationIgnored
|
|
public var windowDismisser: DismissWindowAction?
|
|
|
|
/// URLs handed to `openBoard(at:)` before `windowOpener` existed to open them — a cold launch's
|
|
/// Finder-open (`AppDelegate.application(_:open:)`) can arrive ahead of the first scene's
|
|
/// `onAppear`. Held in order, replayed the moment `captureWindowActions` gives the app somewhere
|
|
/// to open them, then discarded — the buffer is a doorway, not a second registry of intent.
|
|
@ObservationIgnored
|
|
private var pendingOpenURLs: [URL] = []
|
|
|
|
/// What `CaptureOpenWindow` calls. A method rather than two assignments so the launch flow, which
|
|
/// needs the actions before any `onAppear` has run, has one thing to call.
|
|
func captureWindowActions(open: OpenWindowAction, dismiss: DismissWindowAction) {
|
|
windowOpener = open
|
|
windowDismisser = dismiss
|
|
|
|
guard !pendingOpenURLs.isEmpty else { return }
|
|
let urls = pendingOpenURLs
|
|
pendingOpenURLs.removeAll()
|
|
for url in urls {
|
|
openBoard(at: url)
|
|
}
|
|
}
|
|
|
|
// MARK: Recents
|
|
|
|
/// The recents list, cached: what the welcome window renders and what File ▸ Open Recent lists
|
|
/// (02-architecture.md § Per-board app state — "The recents list *is* this registry sorted by
|
|
/// last-opened").
|
|
///
|
|
/// **Cached rather than read through on demand, and both halves of that are deliberate.**
|
|
/// `BoardRegistry` is not `@Observable`, so a view reading it directly would never learn that a
|
|
/// row was forgotten; and `recents()` resolves every record's bookmark, which is filesystem work
|
|
/// no SwiftUI body should be doing on every evaluation — the File menu's command graph is
|
|
/// rebuilt far more often than this list changes. So the list lives here as observable state and
|
|
/// every path that can change the registry refreshes it explicitly (`refreshRecents()`).
|
|
///
|
|
/// The honest residual: a registry mutated behind this object's back would show stale until the
|
|
/// next refresh. There is no such path today — every writer goes through this type or through a
|
|
/// session it owns — and welcome refreshes on appearance as the cheap belt-and-braces.
|
|
public private(set) var recents: [RecentBoard] = []
|
|
|
|
/// Re-reads the registry into `recents`. Called wherever the registry changes: a board opening,
|
|
/// a board closing (the counts are stamped there), Forget, Clear Menu, and welcome appearing.
|
|
public func refreshRecents() {
|
|
recents = boardRegistry.recents()
|
|
}
|
|
|
|
/// The welcome row's Forget (11-command-nexus.md ▸ Welcome recent) — the record, plus any launch
|
|
/// failure that row was carrying, plus the refresh, in one call so no caller can do one without
|
|
/// the others.
|
|
///
|
|
/// **Forgetting the board forgets the failure too.** The row *is* the failure's surface (02
|
|
/// § Launch and window lifecycle); dropping the row while keeping the failure would relocate its
|
|
/// message into the unmatched-failures list, which reads as the app declining to forget.
|
|
public func forget(boardID: UUID) {
|
|
clearLaunchFailures(naming: knownPaths(ofBoard: boardID))
|
|
boardRegistry.forget(id: boardID)
|
|
refreshRecents()
|
|
}
|
|
|
|
/// File ▸ Open Recent ▸ Clear Menu (11-command-nexus.md).
|
|
///
|
|
/// **Finder clears the *menu*; here the registry is the menu**, so clearing removes every record
|
|
/// — there is no second list to clear, and a "menu" that still knew about the boards it had
|
|
/// stopped listing would be a distinction with no surface. What that costs is per-board settings
|
|
/// (window frames, push-on-commit) for boards the user reopens later, which is exactly what
|
|
/// Forget costs one row at a time and what 02's "its settings are conveniences" already accepts.
|
|
///
|
|
/// It is Forget applied wholesale, so it clears failures the same way — the ones naming records,
|
|
/// leaving a failure that named no row (and therefore no menu entry) standing in its own list.
|
|
///
|
|
/// A board that is open right now keeps working: its session holds a record id that no longer
|
|
/// resolves, and `BoardRegistry.update` treats an unknown id as a no-op for precisely this case.
|
|
public func clearRecents() {
|
|
clearLaunchFailures(naming: Set(recents.flatMap { recent in
|
|
[recent.record.lastKnownPath, recent.url?.path].compactMap { $0 }
|
|
}))
|
|
boardRegistry.forgetAll()
|
|
refreshRecents()
|
|
}
|
|
|
|
/// Every path a given record is known by — the one it was last seen at and, when its bookmark
|
|
/// still resolves, where it lives now. The two can differ (a bookmark follows a move), and a
|
|
/// failure recorded before the move names the older one.
|
|
private func knownPaths(ofBoard id: UUID) -> Set<String> {
|
|
var paths: Set<String> = []
|
|
if let record = boardRegistry.record(id: id) {
|
|
paths.insert(record.lastKnownPath)
|
|
}
|
|
if let url = recents.first(where: { $0.record.id == id })?.url {
|
|
paths.insert(url.path)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// MARK: Launch failures
|
|
|
|
/// Boards that failed to restore or open, newest last. Rendered on their own recents rows where
|
|
/// one exists, and in a fallback list where none does — `WelcomeRow.derive(recents:failures:)`.
|
|
public private(set) var launchFailures: [LaunchFailure] = []
|
|
|
|
// MARK: Card-window placement
|
|
|
|
/// Where the next card window cascades from (05-card-window.md, "New windows open at the
|
|
/// last-used card-window size, cascaded").
|
|
///
|
|
/// `NSWindow.cascadeTopLeft(from:)` is the whole mechanism: passing `.zero` places the window at
|
|
/// its natural position and returns the point for the next one, so this is a running cursor
|
|
/// rather than a computed grid. App-wide, not per-board: two boards' card windows cascade past
|
|
/// each other rather than landing on top of one another.
|
|
@ObservationIgnored
|
|
var cardCascadePoint: NSPoint = .zero
|
|
|
|
// MARK: Pending opens
|
|
|
|
/// The security-scoped URL a board window is about to be built from, stashed between
|
|
/// `openBoard(at:)` and the host's first appearance.
|
|
///
|
|
/// The handoff exists because a window value has to be `Codable` and a scoped URL is not a
|
|
/// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone
|
|
/// unless something carried it across. The host claims it on appear; an unclaimed entry (a window
|
|
/// that never opened) leaks one scope until quit, which is the cheapest failure available here.
|
|
@ObservationIgnored
|
|
private var pendingAccess: [BoardWindowRef: ScopedAccess] = [:]
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model")
|
|
|
|
/// The app builds one of these with the real registry file; a test passes its own path for the
|
|
/// same reason `BoardRegistry` takes one at all — "injecting it is how a test stays out of the
|
|
/// real Application Support directory".
|
|
public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) {
|
|
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
|
|
// Read once here rather than lazily, so File ▸ Open Recent is populated from the app's first
|
|
// menu pass — a launch that restores boards never shows welcome, and a submenu that filled
|
|
// in only after the first close would look broken. It costs one bookmark-resolution sweep at
|
|
// launch, next to the one `restorables()` already runs.
|
|
refreshRecents()
|
|
}
|
|
|
|
// MARK: - Launch restoration
|
|
|
|
/// The launch-restoration gate, as a pure function (02-architecture.md § Launch and window
|
|
/// lifecycle: "the preference gates only whether the flagged set is consulted; the flags are
|
|
/// maintained regardless").
|
|
///
|
|
/// `KanbanApp.init()` is where this actually runs — read once, before any scene exists, into a
|
|
/// `let` rather than a computed property, because `restorables()` costs a bookmark resolution per
|
|
/// known board and nothing should pay that on every scene-graph evaluation. An `App`'s `init` is
|
|
/// not itself reachable from a test, so the decision is pulled out to here: two `Bool`s in, one
|
|
/// out, provable without a real `UserDefaults` domain or a live registry.
|
|
public static func shouldRestoreAtLaunch(preference: Bool, hasRestorables: Bool) -> Bool {
|
|
preference && hasRestorables
|
|
}
|
|
|
|
// MARK: - Opening
|
|
|
|
public var hasOpenBoards: Bool { !sessions.isEmpty }
|
|
|
|
/// Opens a board window for `url`, or focuses the one this board already has.
|
|
///
|
|
/// **The already-open check is by file identity, not by path** — `liveStore(for:)` resolves it —
|
|
/// so a board reached through a resolved bookmark and the same board reached through the open
|
|
/// panel land on one window even when the two URLs are spelled differently. Only when nothing is
|
|
/// open for it does a ref get minted, and `openWindow(value:)` with an equal ref focuses rather
|
|
/// than duplicates, which is the second half of "one board window per root".
|
|
///
|
|
/// Security-scoped access starts here, *before* the window exists, because the host's very first
|
|
/// act is a tree walk: a scope started after the load would be too late.
|
|
///
|
|
/// **Called before any scene has appeared, and that's fine.** A cold launch's Finder-open can
|
|
/// reach here before `windowOpener` is captured; the URL joins `pendingOpenURLs` and this same
|
|
/// method runs again for it once `captureWindowActions` has something to open it with.
|
|
public func openBoard(at url: URL) {
|
|
guard let windowOpener else {
|
|
pendingOpenURLs.append(url)
|
|
return
|
|
}
|
|
|
|
if storeRegistry.liveStore(for: url) != nil, let existing = boardRef(forBoardAt: url) {
|
|
windowOpener(id: WindowID.board, value: existing)
|
|
return
|
|
}
|
|
|
|
let ref = BoardWindowRef(url: url)
|
|
// Replacing a stash for the same ref would strand the old scope; there is no such case today
|
|
// (an unopened window's ref is not reachable), but stopping the loser is free.
|
|
pendingAccess.removeValue(forKey: ref)?.stop()
|
|
pendingAccess[ref] = ScopedAccess(url)
|
|
windowOpener(id: WindowID.board, value: ref)
|
|
}
|
|
|
|
/// Shows — or focuses — the welcome window. Its own scene id, so this works with no windows at
|
|
/// all, which is the Dock-reactivation case (02: "Reactivation (Dock click) with no windows shows
|
|
/// welcome").
|
|
public func showWelcome() {
|
|
windowOpener?(id: WindowID.welcome)
|
|
}
|
|
|
|
/// File ▸ New Board… (⌥⌘N) — shows, or focuses, the template chooser (09-templates.md).
|
|
///
|
|
/// The command opens a *chooser*, never a board: the location is the save panel's question and
|
|
/// the panel is the chooser's, so this method's whole job is the window.
|
|
public func showTemplateChooser() {
|
|
windowOpener?(id: WindowID.templateChooser)
|
|
}
|
|
|
|
/// The standard open panel behind File ▸ Open… ⌘O (11-command-nexus.md).
|
|
///
|
|
/// **Validation is the open attempt itself** — there is no pre-flight check that a folder is a
|
|
/// board. Fail-fast owns that verdict (01-storage-format.md § Malformed input) and it is the same
|
|
/// verdict a restored board gets, so a folder that is not a board produces one error in one
|
|
/// vocabulary rather than two near-identical rejections in two.
|
|
///
|
|
/// `treatsFilePackagesAsDirectories` is what lets a `.kanban` package be *chosen* while
|
|
/// `canChooseFiles` stays off: a package is a file to the panel otherwise, and boards are both
|
|
/// packages and plain folders (01 § Board naming). The cost is that double-clicking a package
|
|
/// navigates into it, which the welcome window's own open affordances will make moot.
|
|
public func presentOpenPanel() {
|
|
let panel = NSOpenPanel()
|
|
panel.canChooseDirectories = true
|
|
panel.canChooseFiles = false
|
|
panel.treatsFilePackagesAsDirectories = true
|
|
panel.allowsMultipleSelection = false
|
|
panel.prompt = "Open"
|
|
panel.message = "Choose a board folder."
|
|
|
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
|
openBoard(at: url)
|
|
}
|
|
|
|
/// The ref of the window already showing the board at `url`, if any — matched through the store,
|
|
/// which is identity-keyed, rather than through the path.
|
|
private func boardRef(forBoardAt url: URL) -> BoardWindowRef? {
|
|
guard let store = storeRegistry.liveStore(for: url) else { return nil }
|
|
return sessions.first { $0.value.store === store }?.key
|
|
}
|
|
|
|
// MARK: - Sessions
|
|
|
|
public func session(for ref: BoardWindowRef) -> BoardSession? {
|
|
sessions[ref]
|
|
}
|
|
|
|
/// Claims the scoped URL `openBoard(at:)` stashed for this window, or `nil` if it opened by some
|
|
/// other route. Claiming removes it: the session owns the balance from here.
|
|
func claimPendingAccess(for ref: BoardWindowRef) -> ScopedAccess? {
|
|
pendingAccess.removeValue(forKey: ref)
|
|
}
|
|
|
|
/// Starts a board's session — the board window's host calls this once its load has succeeded.
|
|
///
|
|
/// Two bookkeeping consequences of "this board is now open" ride along. The recents list is
|
|
/// re-read, because `recordOpen` just moved this board to the top of it. And any launch failure
|
|
/// naming this board is dropped: the board demonstrably opens, so a row still captioned with the
|
|
/// old error would be reporting a condition that has stopped being true. That is not the silent
|
|
/// drop 02 forbids — it forbids a failure that was never surfaced disappearing, not one the user
|
|
/// has since fixed.
|
|
func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
|
|
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
|
|
// below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with
|
|
// the board session and dies at close/quit ... standard macOS behavior".
|
|
let history = makeHistoryProvider(store)
|
|
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
|
|
// store is that boundary — every app-mediated mutation goes out through one of its write
|
|
// methods — so it is the store that computes each inverse and registers it. What it cannot
|
|
// know is *which* stack, because a stack belongs to a session and a store knows nothing about
|
|
// windows; this line is where the session tells it. Weak on the store's side, so the loop
|
|
// this closes (provider → step closures → store) is not a retain cycle.
|
|
store.history = history
|
|
sessions[ref] = BoardSession(
|
|
store: store,
|
|
recordID: recordID,
|
|
history: history,
|
|
// The lock's enablement half (13-native-undo.md ▸ Rules): Undo and Redo disable with the
|
|
// other mutating commands while the board refuses writes, and the stack survives to
|
|
// resume when it clears. Weak, so the adapter is never the reason a closed board's store
|
|
// stays alive; a store that has gone answers "writable", which is moot — its stack went
|
|
// with it.
|
|
undoManager: BoardUndoManager(history: history, isReadOnly: { [weak store] in
|
|
store?.isReadOnly ?? false
|
|
}),
|
|
cardRefs: [],
|
|
access: access
|
|
)
|
|
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
|
|
refreshRecents()
|
|
}
|
|
|
|
/// Registers a card window with its board's session, so the close flush can find it.
|
|
///
|
|
/// A card window whose board has no session is a card window with no board — the ownership rule
|
|
/// says that cannot exist, and the host's own check dismisses it before reaching this. Recording
|
|
/// the seam anyway would leave an entry nothing ever drains.
|
|
func registerCardWindow(_ ref: CardWindowRef, session: any CardSessionFlushing) {
|
|
guard sessions[ref.board] != nil else {
|
|
Self.logger.debug("card window registered against a board with no session — ignored")
|
|
return
|
|
}
|
|
sessions[ref.board]?.cardRefs.insert(ref)
|
|
cardSessions[ref] = session
|
|
}
|
|
|
|
func unregisterCardWindow(_ ref: CardWindowRef) {
|
|
sessions[ref.board]?.cardRefs.remove(ref)
|
|
cardSessions[ref] = nil
|
|
}
|
|
|
|
// MARK: - Launch failures
|
|
|
|
/// Records a board that could not be opened. Deliberately additive and never cleared on success:
|
|
/// welcome is showing *because* something failed, and a list that emptied itself as other boards
|
|
/// arrived would be the silent drop 02 rules out.
|
|
public func recordLaunchFailure(path: String, message: String) {
|
|
launchFailures.append(LaunchFailure(path: path, message: message))
|
|
}
|
|
|
|
/// Forgets the failures — the welcome window's dismissal of a list the user has read.
|
|
public func clearLaunchFailures() {
|
|
launchFailures.removeAll()
|
|
}
|
|
|
|
/// Forgets exactly the named failures — what the unmatched-failures list's Clear dismisses, so
|
|
/// that pressing it never also erases a message still standing on a recents row the user has
|
|
/// not looked at.
|
|
public func clearLaunchFailures(ids: Set<UUID>) {
|
|
launchFailures.removeAll { ids.contains($0.id) }
|
|
}
|
|
|
|
/// Drops every failure naming one of `paths` — the resolution path, used when a board opens
|
|
/// successfully and when its record is forgotten. Paths are compared the way the welcome row's
|
|
/// join compares them, so "this row's failure" means the same thing in both places.
|
|
private func clearLaunchFailures(naming paths: Set<String>) {
|
|
guard !paths.isEmpty else { return }
|
|
let keys = Set(paths.map(WelcomeRow.pathKey))
|
|
launchFailures.removeAll { keys.contains(WelcomeRow.pathKey($0.path)) }
|
|
}
|
|
|
|
// MARK: - Counts
|
|
|
|
/// The lane and card counts stamped into the registry at close — **live items only** (02
|
|
/// § Per-board app state, settled).
|
|
///
|
|
/// > tombstoned lanes and cards — and cards hidden beneath a tombstoned lane — don't count; the
|
|
/// > row advertises the board's working size, and the trash is an errand, not inventory.
|
|
///
|
|
/// The nesting is the ancestor walk: a tombstoned lane is skipped whole, so its cards are never
|
|
/// reached whatever their own flags say. `Lane.isDeleted`/`Card.isDeleted` are presence-of-key,
|
|
/// not validity, so a malformed `deleted:` counts as deleted here exactly as it does everywhere
|
|
/// else.
|
|
///
|
|
/// Static and pure: it is a fact about a snapshot, and the close flush is the wrong place to
|
|
/// discover a counting bug.
|
|
public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) {
|
|
var lanes = 0
|
|
var cards = 0
|
|
for lane in snapshot.lanes where !lane.isDeleted {
|
|
lanes += 1
|
|
for card in lane.cards where !card.isDeleted {
|
|
cards += 1
|
|
}
|
|
}
|
|
return (lanes, cards)
|
|
}
|
|
|
|
/// The folder name, extension stripped (01-storage-format.md § Board naming) — `displayName`'s
|
|
/// own fallback, and (02-architecture.md § Per-board app state) the registry record's
|
|
/// *provisional* display name for a board recorded before its load has run: "fail-fast means the
|
|
/// frontmatter can't be trusted, and the folder name is the Finder document name the user just
|
|
/// picked". A first successful load replaces it with the cached title through the ordinary
|
|
/// `displayName(of:)` path — there is no separate provisional vocabulary, just this one fallback
|
|
/// used a moment earlier than usual.
|
|
public static func folderDisplayName(of url: URL) -> String {
|
|
url.deletingPathExtension().lastPathComponent
|
|
}
|
|
|
|
/// A board's display name: its `title`, falling back to the folder name sans extension
|
|
/// (01-storage-format.md § Board naming).
|
|
///
|
|
/// Read from `store.rootURL` rather than `snapshot.rootURL` so the fallback follows a rename the
|
|
/// moment it is absorbed, instead of lagging by one reload (see `BoardStore.rootURL`).
|
|
public static func displayName(of store: BoardStore) -> String {
|
|
if let title = store.snapshot.title.value, !title.isEmpty {
|
|
return title
|
|
}
|
|
return folderDisplayName(of: store.rootURL)
|
|
}
|
|
|
|
// MARK: - Closing
|
|
|
|
/// Runs the close flush for one board and tears its session down.
|
|
///
|
|
/// Idempotent by two guards: a board with no session has already closed, and a board already
|
|
/// mid-flush is not started again. Both matter — the window's close interception and the host's
|
|
/// disappear both call this, by design, because neither one alone fires on every path a window
|
|
/// can leave by.
|
|
public func closeBoard(ref: BoardWindowRef, cause: BoardCloseCause) async {
|
|
guard sessions[ref] != nil, !closingBoards.contains(ref) else { return }
|
|
closingBoards.insert(ref)
|
|
defer { closingBoards.remove(ref) }
|
|
|
|
await coordinator(for: ref).run(cause: cause)
|
|
// The flush stamped this board's counts and (on a user close) cleared its open-now flag, so
|
|
// the cached list is now one close out of date — and welcome is often the very next thing on
|
|
// screen.
|
|
refreshRecents()
|
|
}
|
|
|
|
/// The close flush's **pending-work step, without the teardown** — what File ▸ Duplicate runs
|
|
/// before it copies (03-board-ui.md § Welcome screen & templates: "The copy is preceded by the
|
|
/// close flush ... so neither the tree nor the copied history misses pending work").
|
|
///
|
|
/// **Not `closeBoard`**, and the design says so itself: 09-templates.md ▸ Save as Template states
|
|
/// the rule together with its exception — "with the pull-style mechanical exception committing an
|
|
/// open Edit session's on-disk saves as-is, **sessions staying open**". A duplicate leaves the
|
|
/// original on screen (03: "the original stays open too"), so what it needs is pending work
|
|
/// *landed on disk*, not a session ended: no card window is dismissed, no record is stamped
|
|
/// closed, nothing is torn down, and the board the user is looking at never blinks.
|
|
///
|
|
/// It goes through `CloseFlushCoordinator` rather than calling the store directly so that the
|
|
/// order of the three flushes — store pipeline, then editor saves, then the pending auto-commit
|
|
/// (02's own order) — keeps having exactly one definition.
|
|
public func flushPendingWork(for ref: BoardWindowRef) async {
|
|
guard sessions[ref] != nil else { return }
|
|
await coordinator(for: ref).flushPendingWork()
|
|
}
|
|
|
|
/// Quit: the same sequence, once per open board, **sequentially**.
|
|
///
|
|
/// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than
|
|
/// three interleavings of it, and in a stable board order so a quit is reproducible. Nothing here
|
|
/// clears an open-now flag — that is what `.quit` means, and it is what makes the next launch
|
|
/// restore this set (§ Launch and window lifecycle).
|
|
public func flushAllBoardsForQuit() async {
|
|
for ref in sessions.keys.sorted(by: { $0.path < $1.path }) {
|
|
await closeBoard(ref: ref, cause: .quit)
|
|
}
|
|
}
|
|
|
|
/// Wires a session into `CloseFlushCoordinator`'s seams. The ordering lives over there; this is
|
|
/// only which real object each step touches.
|
|
private func coordinator(for ref: BoardWindowRef) -> CloseFlushCoordinator {
|
|
CloseFlushCoordinator(
|
|
openCardRefs: { [weak self] in
|
|
// Sorted so a board with several card windows commits and closes them in a stable
|
|
// order rather than a `Set`'s.
|
|
(self?.sessions[ref]?.cardRefs).map { $0.sorted { $0.cardID < $1.cardID } } ?? []
|
|
},
|
|
endCardSession: { [weak self] cardRef in
|
|
await self?.cardSessions[cardRef]?.endSession()
|
|
},
|
|
dismissCardWindow: { [weak self] cardRef in
|
|
self?.windowDismisser?(value: cardRef)
|
|
},
|
|
storeFlush: { [weak self] in
|
|
await self?.sessions[ref]?.store.awaitQuiescence()
|
|
},
|
|
// `editorFlush` stays nil, and now deliberately rather than for want of an editor: the
|
|
// card windows' debounced body saves flush in **step 1**, inside each window's
|
|
// `endSession()` (`CardWindowSession`), which is both earlier than this slot and where
|
|
// 02-architecture.md puts them ("each open Edit session ends with its normal session
|
|
// commit", then pending work). The slot stays for a board-level editor with no card
|
|
// window of its own — the raw-source buffer is the candidate — so that the order
|
|
// relative to `committerFlush` (m7) is already decided when one arrives.
|
|
recordClose: { [weak self] in
|
|
guard let self, let session = sessions[ref] else { return }
|
|
let counts = Self.liveCounts(of: session.store.snapshot)
|
|
boardRegistry.recordClose(
|
|
id: session.recordID,
|
|
displayName: Self.displayName(of: session.store),
|
|
laneCount: counts.lanes,
|
|
cardCount: counts.cards,
|
|
icon: session.store.snapshot.icon.value,
|
|
iconColor: session.store.snapshot.iconColor.value
|
|
)
|
|
},
|
|
clearOpenNow: { [weak self] in
|
|
guard let self, let session = sessions[ref] else { return }
|
|
boardRegistry.clearOpenNow(id: session.recordID)
|
|
},
|
|
tearDown: { [weak self] in
|
|
guard let self, let session = sessions.removeValue(forKey: ref) else { return }
|
|
// 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()
|
|
}
|
|
)
|
|
}
|
|
}
|