Stand up the window architecture — welcome, board, card

Four scenes (welcome, restore bootstrap, board group, card group) with
system restoration disabled in favor of the registry's open-now flags:
set when a window actually opens, cleared only on user close, so quit —
and crash — leave exactly the restoration set behind. AppModel joins
windows to sessions (shared store, registry record, card refs, held
security scope); CloseFlushCoordinator pins 02's strict close order as
a seam-injected machine (card sessions end, windows drain, store
flushes, record stamps, teardown) with named slots where m6/m7 flushes
land. HostedWindowController proxies — never replaces — SwiftUI's
window delegate to intercept windowShouldClose for the flush, report
frames, and place saved frames onto live screens. Card windows are
(board path, case-folded card id) values: reopen focuses, and a
snapshot-pure fate function dismisses on delete, tombstone, tombstoned
lane, or cross-board move.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 07:47:11 -04:00
parent 61e18c3dfa
commit fccdf56cf4
19 changed files with 2767 additions and 6 deletions
+58
View File
@@ -0,0 +1,58 @@
import AppKit
import os
/// The three window-lifecycle answers SwiftUI has no modifier for (02-architecture.md § Launch and
/// window lifecycle, § Windows).
///
/// It holds the `AppModel` rather than reaching for a singleton: `KanbanApp` creates the model and
/// hands it over in its own `init`, so there is exactly one and no global to accidentally build a
/// second registry behind.
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
/// Set by `KanbanApp.init()`. Optional only because the adaptor constructs this object before the
/// model exists; it is non-`nil` from the first run-loop turn onward.
var appModel: AppModel?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-delegate")
/// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar
/// alive)" a document-shaped app whose windows are boards has no business quitting because the
/// user tidied one away, and welcome is one Dock click or one menu item back.
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
}
/// A Dock click with nothing on screen shows welcome the other half of the rule above.
///
/// `false` means "handled, do nothing further"; `true` lets AppKit run its default (unminiaturize,
/// open an untitled document), which is right when windows do exist and wrong when they do not
/// this app has no untitled document to make.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
guard !hasVisibleWindows, let appModel else { return true }
appModel.showWelcome()
return false
}
/// Quit runs the close flush for **every** open board before the app goes away.
///
/// The same `CloseFlushCoordinator` sequence as a user close, once per board, in the same fixed
/// order card windows and their sessions, then pending debounced work, then the registry stamp,
/// then teardown (02 § Windows: "closing a board window (**and app quit**) first closes the
/// board's card windows "). The one difference is the cause: quit does not clear the open-now
/// flags, which is what makes the next launch reopen exactly this set.
///
/// `.terminateLater` plus a deferred reply is the only way to await anything here the delegate
/// method is synchronous and the flush is not. With no boards open there is nothing to flush and
/// the app exits immediately rather than taking a run-loop turn to discover that.
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard let appModel, appModel.hasOpenBoards else { return .terminateNow }
Task { @MainActor in
await appModel.flushAllBoardsForQuit()
Self.logger.debug("quit flush complete")
sender.reply(toApplicationShouldTerminate: true)
}
return .terminateLater
}
}
+482
View File
@@ -0,0 +1,482 @@
import AppKit
import Observation
import SwiftUI
import os
// MARK: - Scene ids
/// The scene identifiers, in one place because they are matched by string in three unrelated
/// spots the scene declaration, `openWindow(id:)`, and `dismissWindow(id:)` and a typo in any
/// one of them fails silently at runtime.
public enum WindowID {
public static let welcome = "welcome"
public static let restoreBootstrap = "restore-bootstrap"
public static let board = "board"
public static let card = "card"
}
// MARK: - App-wide preferences
/// The `UserDefaults` half of "App-wide state has the same home" (02-architecture.md § Per-board app
/// state): the app-scoped values that are scalars, kept out of the board registry because no board
/// owns them.
///
/// The keys are declared here rather than spelled at each `@AppStorage`, for the same reason
/// `WindowID` exists.
public enum AppPreferences {
/// "Restore open boards at launch" (Settings, , 11-command-nexus.md). **Default on.**
public static let restoreOpenBoardsAtLaunchKey = "restoreOpenBoardsAtLaunch"
/// Read outside a view, where `@AppStorage` is not available the launch flow needs it before
/// any scene exists. `object(forKey:)` rather than `bool(forKey:)` because the latter cannot
/// tell "off" from "never set", and this preference defaults to *on*.
public static var restoreOpenBoardsAtLaunch: Bool {
UserDefaults.standard.object(forKey: restoreOpenBoardsAtLaunchKey) as? Bool ?? true
}
/// The last-used card-window size (05-card-window.md; 02 files it as app-wide, not per-board
/// "the last-used card-window size" is named there explicitly). Stored as a string because
/// `NSSize` is not a property-list type and two more keys would be worse.
public static let lastCardWindowSizeKey = "lastCardWindowSize"
public static var lastCardWindowSize: CGSize? {
guard let text = UserDefaults.standard.string(forKey: lastCardWindowSizeKey) else { return nil }
let size = NSSizeFromString(text)
guard size.width > 0, size.height > 0 else { return nil }
return size
}
public static func setLastCardWindowSize(_ size: CGSize) {
UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
}
}
// MARK: - Launch failures
/// A board that could not be restored or opened, as the welcome window renders it.
///
/// **A struct rather than the obvious tuple** only because SwiftUI needs identity to list these and
/// two failures can share a path (a board that failed, was retried, and failed again).
///
/// This is the minimum that satisfies "never a silent drop". The settled shape is richer 02
/// § Launch and window lifecycle wants the failure *on the board's recents row*, carrying fail-fast's
/// specifics or the unavailable state and that belongs with the recents list itself.
// m4-welcome: row-level failure rendering lands with the full welcome window (recents, Forget,
// Open Recent). Until then a plain list under the branding is the honest placeholder.
public struct LaunchFailure: Identifiable, Sendable, Equatable {
public let id = UUID()
public let path: String
public let message: String
public init(path: String, message: String) {
self.path = path
self.message = message
}
/// What the row shows for a name: the folder, not the whole path. The path is the subtitle.
public var displayName: String {
URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent
}
}
// MARK: - Security-scoped access
/// One board's security-scoped access, held for the **whole session**.
///
/// `BoardRegistry.withScopedAccess(to:_:)` is the scoped-per-call form and is right for what it does
/// resolving identities during a recents listing, where holding a scope open would be a leak. It is
/// exactly wrong for an open board: the store, the watcher, and every Writer call need access for
/// minutes or hours, and re-entering the scope per call would be both slower and racy against a
/// watcher thread that is already inside the folder.
///
/// So the pairing is explicit and its balance is the session's job: started when the board's window
/// opens, stopped in the close flush's teardown step. A class rather than a struct so the balance
/// cannot be duplicated by a copy.
///
/// **The URL matters, not the path.** A security-scoped URL is a token, not a string: a `URL`
/// rebuilt from `ref.path` grants nothing, which is why `AppModel.openBoard(at:)` stashes the
/// resolved URL for the host that is about to appear instead of letting it reconstruct one.
public final class ScopedAccess {
public let url: URL
private var started: Bool
public init(_ url: URL) {
self.url = url
// `false` for a URL that is not security-scoped a plain bookmark's, one the open panel
// already blessed for the app's lifetime, anything inside the container. There is then
// nothing to stop, and the pairing stays balanced either way.
started = url.startAccessingSecurityScopedResource()
}
public func stop() {
guard started else { return }
started = false
url.stopAccessingSecurityScopedResource()
}
}
// MARK: - AppModel
/// The app's one piece of cross-window state: which boards are open, which card windows belong to
/// which board, and the two window actions AppKit-side code needs but cannot reach.
///
/// ### What lives here, and why it is not a singleton
///
/// The two registries (02-architecture.md § Layering Components and § Per-board app state) are
/// owned here because they are app-scoped and because "the app holds one instance, so a test can
/// hold its own without the two colliding" `BoardStoreRegistry`'s own note. Everything else here is
/// window bookkeeping that has no other home: a `BoardStore` knows nothing about windows by design,
/// and a SwiftUI scene is a value that cannot hold state across a window's life.
///
/// ### Sessions are the join
///
/// A `BoardSession` is what makes the two halves of the app meet: the store the windows share, the
/// registry record they stamp, the card windows the close flush has to close first, and the
/// security-scoped access the whole thing runs inside. Its lifetime is exactly the board window's
/// created when the host's load succeeds, removed by the close flush's last step. A card window with
/// no session is a card window with no board, which 02's ownership rule says cannot exist; the card
/// host reads that as "dismiss".
@MainActor
@Observable
public final class AppModel {
// MARK: Registries
public let storeRegistry = BoardStoreRegistry()
public let boardRegistry: BoardRegistry
// MARK: Sessions
/// One open board window and everything hanging off it.
public struct BoardSession {
/// The shared store the same object every one of this board's windows renders.
public let store: BoardStore
/// Which registry record this board is, so the close flush can stamp counts and clear the
/// open-now flag without matching by identity a second time.
public let recordID: UUID
/// This board's open card windows. The close flush's step 1 reads it; the card hosts
/// maintain it. Empty is the common case.
public var cardRefs: Set<CardWindowRef> = []
/// The scope the board is being read and written inside, released at teardown. `nil` when
/// the board was opened from a URL that needed none.
var access: ScopedAccess?
}
/// Keyed by board window, because that is the thing whose lifetime a session shares.
///
/// Observed: a card window watches for its board's session disappearing and dismisses itself when
/// it does the safety net behind "card windows never outlive the board window".
public private(set) var sessions: [BoardWindowRef: BoardSession] = [:]
/// The end-session hooks, keyed the same way the card windows are.
///
/// Beside `BoardSession.cardRefs` rather than inside it: the set is *membership* (what the close
/// flush drains and what the safety net checks), this is the *seam table* (what it calls). They
/// are only ever written together, by the two register/unregister methods below, which is what
/// keeps them from becoming two answers to one question.
@ObservationIgnored
private var cardSessions: [CardWindowRef: any CardSessionFlushing] = [:]
/// Boards whose close flush is already running the re-entrancy guard.
///
/// Needed because a board window can be told to close twice in quick succession: the
/// `windowShouldClose` interception starts the flush, and the host's own disappear runs a second
/// attempt as its safety net. The second must not re-enter a sequence that is mid-await.
@ObservationIgnored
private var closingBoards: Set<BoardWindowRef> = []
// MARK: Window actions
/// SwiftUI's window-opening action, captured from whatever scene view is alive.
///
/// It exists because the two things that most need to open a window are not views:
/// `AppDelegate.applicationShouldHandleReopen` (a Dock click with no windows must show welcome)
/// and the close-flush coordinator (which dismisses card windows). Neither can read
/// `@Environment`. The action stays valid after the view that supplied it is gone it is a value
/// addressed to the app, not to a window which is precisely the windowless case it is for.
///
/// `@ObservationIgnored` on both: nothing renders from them, and an assignment on every scene's
/// appear would otherwise invalidate every observer for no reason.
@ObservationIgnored
public var windowOpener: OpenWindowAction?
@ObservationIgnored
public var windowDismisser: DismissWindowAction?
/// What `CaptureOpenWindow` calls. A method rather than two assignments so the launch flow, which
/// needs the actions before any `onAppear` has run, has one thing to call.
func captureWindowActions(open: OpenWindowAction, dismiss: DismissWindowAction) {
windowOpener = open
windowDismisser = dismiss
}
// MARK: Launch failures
/// Boards that failed to restore or open, newest last the minimal welcome's one dynamic
/// section. See `LaunchFailure` for what replaces it.
public private(set) var launchFailures: [LaunchFailure] = []
// MARK: Card-window placement
/// Where the next card window cascades from (05-card-window.md, "New windows open at the
/// last-used card-window size, cascaded").
///
/// `NSWindow.cascadeTopLeft(from:)` is the whole mechanism: passing `.zero` places the window at
/// its natural position and returns the point for the next one, so this is a running cursor
/// rather than a computed grid. App-wide, not per-board: two boards' card windows cascade past
/// each other rather than landing on top of one another.
@ObservationIgnored
var cardCascadePoint: NSPoint = .zero
// MARK: Pending opens
/// The security-scoped URL a board window is about to be built from, stashed between
/// `openBoard(at:)` and the host's first appearance.
///
/// The handoff exists because a window value has to be `Codable` and a scoped URL is not a
/// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone
/// unless something carried it across. The host claims it on appear; an unclaimed entry (a window
/// that never opened) leaks one scope until quit, which is the cheapest failure available here.
@ObservationIgnored
private var pendingAccess: [BoardWindowRef: ScopedAccess] = [:]
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model")
/// The app builds one of these with the real registry file; a test passes its own path for the
/// same reason `BoardRegistry` takes one at all "injecting it is how a test stays out of the
/// real Application Support directory".
public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) {
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
}
// MARK: - Opening
public var hasOpenBoards: Bool { !sessions.isEmpty }
/// Opens a board window for `url`, or focuses the one this board already has.
///
/// **The already-open check is by file identity, not by path** `liveStore(for:)` resolves it
/// so a board reached through a resolved bookmark and the same board reached through the open
/// panel land on one window even when the two URLs are spelled differently. Only when nothing is
/// open for it does a ref get minted, and `openWindow(value:)` with an equal ref focuses rather
/// than duplicates, which is the second half of "one board window per root".
///
/// Security-scoped access starts here, *before* the window exists, because the host's very first
/// act is a tree walk: a scope started after the load would be too late.
public func openBoard(at url: URL) {
guard let windowOpener else {
Self.logger.error("openBoard with no window opener captured yet — ignored")
return
}
if storeRegistry.liveStore(for: url) != nil, let existing = boardRef(forBoardAt: url) {
windowOpener(id: WindowID.board, value: existing)
return
}
let ref = BoardWindowRef(url: url)
// Replacing a stash for the same ref would strand the old scope; there is no such case today
// (an unopened window's ref is not reachable), but stopping the loser is free.
pendingAccess.removeValue(forKey: ref)?.stop()
pendingAccess[ref] = ScopedAccess(url)
windowOpener(id: WindowID.board, value: ref)
}
/// Shows or focuses the welcome window. Its own scene id, so this works with no windows at
/// all, which is the Dock-reactivation case (02: "Reactivation (Dock click) with no windows shows
/// welcome").
public func showWelcome() {
windowOpener?(id: WindowID.welcome)
}
/// The standard open panel behind File Open O (11-command-nexus.md).
///
/// **Validation is the open attempt itself** there is no pre-flight check that a folder is a
/// board. Fail-fast owns that verdict (01-storage-format.md § Malformed input) and it is the same
/// verdict a restored board gets, so a folder that is not a board produces one error in one
/// vocabulary rather than two near-identical rejections in two.
///
/// `treatsFilePackagesAsDirectories` is what lets a `.kanban` package be *chosen* while
/// `canChooseFiles` stays off: a package is a file to the panel otherwise, and boards are both
/// packages and plain folders (01 § Board naming). The cost is that double-clicking a package
/// navigates into it, which the welcome window's own open affordances will make moot.
public func presentOpenPanel() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.treatsFilePackagesAsDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "Open"
panel.message = "Choose a board folder."
guard panel.runModal() == .OK, let url = panel.url else { return }
openBoard(at: url)
}
/// The ref of the window already showing the board at `url`, if any matched through the store,
/// which is identity-keyed, rather than through the path.
private func boardRef(forBoardAt url: URL) -> BoardWindowRef? {
guard let store = storeRegistry.liveStore(for: url) else { return nil }
return sessions.first { $0.value.store === store }?.key
}
// MARK: - Sessions
public func session(for ref: BoardWindowRef) -> BoardSession? {
sessions[ref]
}
/// Claims the scoped URL `openBoard(at:)` stashed for this window, or `nil` if it opened by some
/// other route. Claiming removes it: the session owns the balance from here.
func claimPendingAccess(for ref: BoardWindowRef) -> ScopedAccess? {
pendingAccess.removeValue(forKey: ref)
}
/// Starts a board's session the board window's host calls this once its load has succeeded.
func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
sessions[ref] = BoardSession(store: store, recordID: recordID, cardRefs: [], access: access)
}
/// Registers a card window with its board's session, so the close flush can find it.
///
/// A card window whose board has no session is a card window with no board the ownership rule
/// says that cannot exist, and the host's own check dismisses it before reaching this. Recording
/// the seam anyway would leave an entry nothing ever drains.
func registerCardWindow(_ ref: CardWindowRef, session: any CardSessionFlushing) {
guard sessions[ref.board] != nil else {
Self.logger.debug("card window registered against a board with no session — ignored")
return
}
sessions[ref.board]?.cardRefs.insert(ref)
cardSessions[ref] = session
}
func unregisterCardWindow(_ ref: CardWindowRef) {
sessions[ref.board]?.cardRefs.remove(ref)
cardSessions[ref] = nil
}
// MARK: - Launch failures
/// Records a board that could not be opened. Deliberately additive and never cleared on success:
/// welcome is showing *because* something failed, and a list that emptied itself as other boards
/// arrived would be the silent drop 02 rules out.
public func recordLaunchFailure(path: String, message: String) {
launchFailures.append(LaunchFailure(path: path, message: message))
}
/// Forgets the failures the welcome window's dismissal of a list the user has read.
public func clearLaunchFailures() {
launchFailures.removeAll()
}
// MARK: - Counts
/// The lane and card counts stamped into the registry at close **live items only** (02
/// § Per-board app state, settled).
///
/// > tombstoned lanes and cards and cards hidden beneath a tombstoned lane don't count; the
/// > row advertises the board's working size, and the trash is an errand, not inventory.
///
/// The nesting is the ancestor walk: a tombstoned lane is skipped whole, so its cards are never
/// reached whatever their own flags say. `Lane.isDeleted`/`Card.isDeleted` are presence-of-key,
/// not validity, so a malformed `deleted:` counts as deleted here exactly as it does everywhere
/// else.
///
/// Static and pure: it is a fact about a snapshot, and the close flush is the wrong place to
/// discover a counting bug.
public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) {
var lanes = 0
var cards = 0
for lane in snapshot.lanes where !lane.isDeleted {
lanes += 1
for card in lane.cards where !card.isDeleted {
cards += 1
}
}
return (lanes, cards)
}
/// A board's display name: its `title`, falling back to the folder name sans extension
/// (01-storage-format.md § Board naming).
///
/// Read from `store.rootURL` rather than `snapshot.rootURL` so the fallback follows a rename the
/// moment it is absorbed, instead of lagging by one reload (see `BoardStore.rootURL`).
public static func displayName(of store: BoardStore) -> String {
if let title = store.snapshot.title.value, !title.isEmpty {
return title
}
return store.rootURL.deletingPathExtension().lastPathComponent
}
// MARK: - Closing
/// Runs the close flush for one board and tears its session down.
///
/// Idempotent by two guards: a board with no session has already closed, and a board already
/// mid-flush is not started again. Both matter the window's close interception and the host's
/// disappear both call this, by design, because neither one alone fires on every path a window
/// can leave by.
public func closeBoard(ref: BoardWindowRef, cause: BoardCloseCause) async {
guard sessions[ref] != nil, !closingBoards.contains(ref) else { return }
closingBoards.insert(ref)
defer { closingBoards.remove(ref) }
await coordinator(for: ref).run(cause: cause)
}
/// Quit: the same sequence, once per open board, **sequentially**.
///
/// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than
/// three interleavings of it, and in a stable board order so a quit is reproducible. Nothing here
/// clears an open-now flag that is what `.quit` means, and it is what makes the next launch
/// restore this set (§ Launch and window lifecycle).
public func flushAllBoardsForQuit() async {
for ref in sessions.keys.sorted(by: { $0.path < $1.path }) {
await closeBoard(ref: ref, cause: .quit)
}
}
/// Wires a session into `CloseFlushCoordinator`'s seams. The ordering lives over there; this is
/// only which real object each step touches.
private func coordinator(for ref: BoardWindowRef) -> CloseFlushCoordinator {
CloseFlushCoordinator(
openCardRefs: { [weak self] in
// Sorted so a board with several card windows commits and closes them in a stable
// order rather than a `Set`'s.
(self?.sessions[ref]?.cardRefs).map { $0.sorted { $0.cardID < $1.cardID } } ?? []
},
endCardSession: { [weak self] cardRef in
await self?.cardSessions[cardRef]?.endSession()
},
dismissCardWindow: { [weak self] cardRef in
self?.windowDismisser?(value: cardRef)
},
storeFlush: { [weak self] in
await self?.sessions[ref]?.store.awaitQuiescence()
},
// editorFlush / committerFlush stay nil until m6 and m7 have something to flush; the
// slots exist so their order is already decided when they do.
recordClose: { [weak self] in
guard let self, let session = sessions[ref] else { return }
let counts = Self.liveCounts(of: session.store.snapshot)
boardRegistry.recordClose(id: session.recordID, laneCount: counts.lanes, cardCount: counts.cards)
},
clearOpenNow: { [weak self] in
guard let self, let session = sessions[ref] else { return }
boardRegistry.clearOpenNow(id: session.recordID)
},
tearDown: { [weak self] in
guard let self, let session = sessions.removeValue(forKey: ref) else { return }
storeRegistry.release(session.store)
session.access?.stop()
}
)
}
}
+215
View File
@@ -0,0 +1,215 @@
import AppKit
import SwiftUI
import os
// MARK: - BoardWindowHost
/// One board window: the thing that owns a board's session for as long as it is on screen
/// (02-architecture.md § Windows, § Launch and window lifecycle).
///
/// ### It is a lifecycle, not a layout
///
/// Almost everything here is about beginning and ending: acquiring the shared store, stamping the
/// registry, holding the board's security-scoped access, remembering the window's frame, and running
/// the close flush before any of it is let go. The board *itself* lanes, cards, drag, the whole of
/// 03-board-ui.md is a placeholder below, deliberately throwaway and confined to one small view so
/// the milestone that builds the real thing replaces exactly that and nothing else.
///
/// ### Failure opens welcome
///
/// A board that will not load has nothing to show, so its window never appears: the failure joins
/// `AppModel.launchFailures`, welcome comes up, and this window dismisses itself. That is the same
/// path a failed restoration takes "welcome appears alongside whatever did restore, the failed
/// board's recents row carrying fail-fast's specifics" with the row-level rendering still owed.
struct BoardWindowHost: View {
let ref: BoardWindowRef
@Environment(AppModel.self) private var appModel
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
/// The window's own controller `@State` so it outlives body evaluations and so SwiftUI keeps it
/// alive for exactly as long as this window exists.
@State private var windowController = HostedWindowController()
@State private var phase: Phase = .opening
private enum Phase {
case opening
case open(BoardStore)
/// The load failed; this window is on its way out and must not try again.
case failed
}
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-window")
var body: some View {
content
.frame(minWidth: 640, minHeight: 400)
.background(WindowAccessor(controller: windowController))
.navigationTitle(windowTitle)
.task { await start() }
.onDisappear { endSessionIfStillOpen() }
}
@ViewBuilder
private var content: some View {
switch phase {
case .opening, .failed:
// Nothing to render and nothing worth animating: this window either becomes a board in a
// moment or dismisses itself.
Color.clear
case let .open(store):
VStack(spacing: 0) {
BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) }
PlaceholderBoardView(store: store)
}
}
}
private var windowTitle: String {
guard case let .open(store) = phase else { return "" }
return AppModel.displayName(of: store)
}
// MARK: - Opening
/// Acquires the board and starts its session, or fails it out to welcome.
///
/// The order is load-bearing. Security-scoped access is claimed **before** `acquire`, because
/// acquire's first act is a full tree walk and a sandboxed read outside the scope is exactly the
/// one that gets refused. `setOpenNow` comes **after** the record exists and after the window has
/// demonstrably opened a flag set on a board that never appeared would hand the next launch a
/// restoration set describing a failure.
private func start() async {
guard case .opening = phase else { return }
// Claimed even on the failure path: an unclaimed stash is a scope nobody balances.
let access = appModel.claimPendingAccess(for: ref)
let url = access?.url ?? ref.url
let store: BoardStore
do throws(BoardLoadError) {
store = try appModel.storeRegistry.acquire(url)
} catch {
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
access?.stop()
phase = .failed
appModel.recordLaunchFailure(path: ref.path, message: error.description)
openWindow(id: WindowID.welcome)
dismissWindow(id: WindowID.board, value: ref)
return
}
let recordID = appModel.boardRegistry.recordOpen(of: url, displayName: AppModel.displayName(of: store))
appModel.boardRegistry.setOpenNow(id: recordID)
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
phase = .open(store)
configureWindow(recordID: recordID)
// "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
// when welcome is not open, which is the ordinary case.
dismissWindow(id: WindowID.welcome)
}
/// Wires the window: the saved frame on the way in, frame changes on the way back out, and the
/// close interception that makes the flush unavoidable.
private func configureWindow(recordID: UUID) {
windowController.onAttach = { window in
guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return }
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
}
// The window may already be attached `viewDidMoveToWindow` fires well before this task's
// load returns so the placement is applied directly too rather than waiting for a callback
// that has already happened.
if let window = windowController.window {
windowController.onAttach?(window)
}
windowController.onFrameChanged = { frame in
appModel.boardRegistry.updateWindowFrame(
id: recordID,
frame: WindowFrame(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height)
)
}
windowController.onCloseRequested = {
Task { @MainActor in
await appModel.closeBoard(ref: ref, cause: .userClose)
windowController.closeAfterFlush()
}
}
}
// MARK: - Closing
/// The safety net behind the close interception.
///
/// `windowShouldClose` covers W, File Close and the red button every way a *user* closes a
/// window. It does not cover a window torn down some other way (a programmatic dismiss, a scene
/// SwiftUI decides to end), and a board whose session outlived its window would leave a watcher
/// running over nothing. So the disappear runs the same sequence; `AppModel.closeBoard` is
/// idempotent precisely so these two can both fire without the flush running twice.
///
/// Deliberately **not** the quit path: quit is `AppDelegate`'s, and it must complete before the
/// app exits rather than in a task nobody waits for.
private func endSessionIfStillOpen() {
guard appModel.session(for: ref) != nil else { return }
Task { @MainActor in
await appModel.closeBoard(ref: ref, cause: .userClose)
}
}
}
// MARK: - The placeholder board
/// Stand-in for the board (03-board-ui.md): the title and a list of lane titles, and nothing else.
///
/// **Deliberately throwaway.** The next milestone builds the full-visibility lane layout masonry
/// cards, drag, the trash quasi-lane, the toolbar and replaces this view wholesale. It is kept in
/// one small view with no state of its own so that replacement is a deletion rather than an
/// untangling. What it does prove today is that the window renders the *store's* snapshot: an
/// external edit shows up here through the watcher like it will in the real thing.
private struct PlaceholderBoardView: View {
let store: BoardStore
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(AppModel.displayName(of: store))
.font(.largeTitle)
if liveLanes.isEmpty {
Text("No lanes yet")
.foregroundStyle(.secondary)
} else {
ForEach(liveLanes) { lane in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(lane.title.value ?? "Untitled")
.font(.headline)
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
Text("\(liveCardCount(in: lane))")
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
}
}
/// Tombstoned lanes render nowhere on the board (03-board-ui.md collapses them into the trash
/// quasi-lane) true of the placeholder as much as of the real layout.
private var liveLanes: [Lane] {
store.snapshot.lanes.filter { !$0.isDeleted }
}
private func liveCardCount(in lane: Lane) -> Int {
lane.cards.filter { !$0.isDeleted }.count
}
}
+36
View File
@@ -0,0 +1,36 @@
import SwiftUI
/// Lifts SwiftUI's window actions out of the environment and into `AppModel`, from whatever scene
/// happens to be on screen.
///
/// `openWindow` and `dismissWindow` are only readable from a view, and the two places that most need
/// them are not views: `AppDelegate` (a Dock click with no windows must bring up welcome 02
/// § Launch and window lifecycle) and the close flush (which dismisses a board's card windows before
/// anything else happens). Both are reached from AppKit, with no environment in sight.
///
/// **The captured actions outlive the view that supplied them.** They are values addressed to the
/// app, not to a window, so the last scene to appear leaves behind actions that still work after
/// every window is gone which is exactly the windowless reactivation case. That is why this is
/// applied to *every* scene root: whichever one exists, the app has its actions.
///
/// Both are captured together despite the name: they are one capability with two halves, and a
/// second modifier for the second half would be ceremony.
struct CaptureOpenWindow: ViewModifier {
let appModel: AppModel
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
func body(content: Content) -> some View {
content.onAppear {
appModel.captureWindowActions(open: openWindow, dismiss: dismissWindow)
}
}
}
extension View {
func captureWindowActions(into appModel: AppModel) -> some View {
modifier(CaptureOpenWindow(appModel: appModel))
}
}
+236
View File
@@ -0,0 +1,236 @@
import AppKit
import SwiftUI
import os
// MARK: - Fate
/// What the current snapshot says about a card window: render this card, or go away.
///
/// A named decision rather than a scattering of `if`s, because 05-card-window.md Deletion &
/// lifecycle and 02-architecture.md § Live-reload resilience state the same rule from two directions
/// and both have to be true of one piece of code. Making it a value also makes it a *pure* function
/// of a snapshot, which is the only way the tombstoned-lane case gets tested without a window.
public enum CardWindowFate: Equatable {
case shows(Card)
case dismisses
}
// MARK: - The session seam
/// A card window's editor session m4's no-op stand-in for the thing 05-card-window.md will build.
///
/// It exists so the close flush has something real to call and something real to be *ordered against*
/// (see `CardSessionFlushing`). The only behaviour it has is the one the ordering depends on: ending
/// twice does nothing the second time, which matters because two paths legitimately end a session
/// the board's close flush drives it for every card window, and a card window closed on its own runs
/// it from its disappear.
@MainActor
final class CardWindowSession: CardSessionFlushing {
private var hasEnded = false
func endSession() async {
guard !hasEnded else { return }
hasEnded = true
// m6: commit the open Edit session here (06-history-undo.md's session granularity), flushing
// the debounced body save first.
}
}
// MARK: - CardWindowHost
/// One card window (05-card-window.md).
///
/// ### Its whole identity is `(board, card)`
///
/// Which is why this host is mostly a set of dismissal rules. The window follows its card between
/// lanes for free the key names neither and it dismisses in the three cases where the key stops
/// naming anything: the card is tombstoned, its *lane* is tombstoned (effective liveness is
/// ancestor-walked, 02 § Live-reload resilience), or the card is simply not in this board's snapshot
/// any more, which is what a cross-board move looks like from here.
///
/// ### It can never outlive its board window
///
/// "The board window owns the board" (02 § Components) so a card window whose board has no live
/// store, or whose board session has gone, dismisses immediately rather than becoming an orphan with
/// a store it acquired by itself. That covers the ordinary case (the board window closed and its
/// flush dismissed this one) and the odd one (the system restoring a card window from a previous
/// launch, which scene restoration is disabled precisely to prevent).
///
/// The content is a placeholder the two-column composition, the sidebar, Edit/Preview and the rest
/// are the card-window milestone's.
struct CardWindowHost: View {
let ref: CardWindowRef
@Environment(AppModel.self) private var appModel
@Environment(\.dismissWindow) private var dismissWindow
@State private var windowController = HostedWindowController()
@State private var session = CardWindowSession()
@State private var phase: Phase = .opening
private enum Phase {
case opening
case open(BoardStore)
case closing
}
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "card-window")
// MARK: - The lifecycle rule
/// Whether a card window keyed on `cardID` still has a card, given this board's snapshot.
///
/// The three dismissal cases collapse into two lines: a card that is not in the snapshot is gone
/// (deleted outright, or moved to another board the board half of the key no longer names it),
/// and a card whose **effective** liveness is trashed renders nowhere, whether the tombstone is
/// its own or its lane's. Only a live card in a live lane keeps its window.
///
/// Takes the id as the ref stores it a raw folder name and compares it as an `ItemID`, so two
/// case-spellings of one UUID are one card here exactly as they are everywhere else.
static func cardWindowFate(cardID: String, in snapshot: BoardModel) -> CardWindowFate {
let identity = ItemID(rawValue: cardID)
for lane in snapshot.lanes {
guard let card = lane.cards.first(where: { $0.id == identity }) else { continue }
return lane.isDeleted || card.isDeleted ? .dismisses : .shows(card)
}
return .dismisses
}
// MARK: - View
var body: some View {
content
.frame(minWidth: 360, minHeight: 240)
.background(WindowAccessor(controller: windowController))
.navigationTitle(windowTitle)
.task { start() }
.onChange(of: shouldDismiss, initial: true) { _, dismisses in
guard dismisses else { return }
dismissWindow(id: WindowID.card, value: ref)
}
.onDisappear { finish() }
}
@ViewBuilder
private var content: some View {
if let card {
VStack(alignment: .leading, spacing: 12) {
Text(card.title.value ?? "Untitled")
.font(.title)
// The untitled placeholder is styling, not a title: a card with no `title` key
// shows the word in secondary, never as if somebody had typed it
// (01-storage-format.md § Frontmatter).
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(24)
} else {
Color.clear
}
}
private var card: Card? {
guard case let .open(store) = phase,
case let .shows(card) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot)
else { return nil }
return card
}
private var windowTitle: String {
card?.title.value ?? ""
}
/// The dismissal decision, re-evaluated on every snapshot the store applies.
///
/// Two clauses, and the second is the safety net: the board's session vanishing means the board
/// window has finished tearing down, and a card window still on screen at that point has nothing
/// behind it. It is deliberately redundant with the close flush, which dismisses these windows
/// itself a net is only useful when the thing it backs up has already failed.
private var shouldDismiss: Bool {
guard case let .open(store) = phase else { return false }
guard appModel.session(for: ref.board) != nil else { return true }
return Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) == .dismisses
}
// MARK: - Opening
/// Joins the board's session, or dismisses.
///
/// **`liveStore(for:)` first, and no fallback to `acquire` on a closed board.** A card window
/// never opens a board: doing so would put a store and a watcher behind a window that,
/// by 02's ownership rule, is not allowed to exist. The `acquire` below can only hit the
/// already-open path, which is why its failure is logged rather than surfaced.
private func start() {
guard case .opening = phase else { return }
guard appModel.storeRegistry.liveStore(for: ref.boardURL) != nil else {
Self.logger.debug("card window has no live board — dismissing")
phase = .closing
dismissWindow(id: WindowID.card, value: ref)
return
}
let store: BoardStore
do throws(BoardLoadError) {
store = try appModel.storeRegistry.acquire(ref.boardURL)
} catch {
Self.logger.error("card window could not acquire its board: \(error.description, privacy: .public)")
phase = .closing
dismissWindow(id: WindowID.card, value: ref)
return
}
appModel.registerCardWindow(ref, session: session)
phase = .open(store)
configureWindow()
}
/// Size and placement: the last-used card-window size, cascaded (05-card-window.md, "New windows
/// open at the last-used card-window size, cascaded").
///
/// The size is app-wide rather than per-board or per-card 02 § Per-board app state files "the
/// last-used card-window size" under App-wide state explicitly. Per-*card* frame restoration is a
/// separate promise in 05 ("frames restore per card across relaunch where state restoration
/// allows") and belongs to the card-window milestone, which owns the per-card record it needs.
private func configureWindow() {
windowController.onAttach = { window in
if let size = AppPreferences.lastCardWindowSize {
window.setContentSize(size)
}
// `cascadeTopLeft(from:)` both places this window and returns the origin for the next
// one, so the running point is the whole cascade.
appModel.cardCascadePoint = window.cascadeTopLeft(from: appModel.cardCascadePoint)
}
if let window = windowController.window {
windowController.onAttach?(window)
}
windowController.onFrameChanged = { frame in
guard let window = windowController.window else { return }
let size = window.contentRect(forFrameRect: frame).size
guard size != AppPreferences.lastCardWindowSize else { return }
AppPreferences.setLastCardWindowSize(size)
}
}
// MARK: - Closing
/// Leaves the session and lets the store go.
///
/// The release rides **behind** the session's end rather than beside it: a session that has
/// something to commit (m6) needs the store it is committing through, and a refcount that hit
/// zero first would have stopped the watcher underneath it. In m4 the hook is a no-op and the
/// ordering costs one run-loop turn the point is that the shape is already right.
private func finish() {
guard case let .open(store) = phase else { return }
phase = .closing
appModel.unregisterCardWindow(ref)
Task { @MainActor in
await session.endSession()
appModel.storeRegistry.release(store)
}
}
}
+206
View File
@@ -0,0 +1,206 @@
import Foundation
import os
// MARK: - Vocabulary
/// Why a board is closing the one bit the flush sequence branches on.
///
/// The distinction *is* the restoration mechanism (02-architecture.md § Launch and window
/// lifecycle): a user close clears the record's open-now flag, a quit deliberately leaves it
/// standing so the next launch reopens what was on screen. Everything else about the two paths is
/// identical, which is why this is an enum consulted at one step rather than two sequences.
public enum BoardCloseCause: Sendable, Equatable {
/// W, the red button, File Close the user said this board is done.
case userClose
/// App quit. The boards were open at quit by definition, so their flags stay set.
case quit
}
/// The end-of-session hook a card window runs before it goes away.
///
/// **A seam, not a feature, in m4.** The real work is 05-card-window.md's: "each open Edit session
/// ends with its normal session commit" (06-history-undo.md's granularity), which needs an editor
/// and a dirty buffer that do not exist yet. The default implementation is therefore a no-op, and
/// what this milestone actually pins is the *ordering* that the hook runs, for every open card
/// window, before any of the board's pending work is flushed and long before the store is released.
/// The card-window milestone supplies a body; nothing above it has to change.
///
/// `AnyObject` because a card window's session is a live object with a buffer in it, and because the
/// coordinator holds it across an `await`.
@MainActor
public protocol CardSessionFlushing: AnyObject {
func endSession() async
}
public extension CardSessionFlushing {
func endSession() async {}
}
// MARK: - CloseFlushCoordinator
/// The close-flush sequence, in order, for one board the whole of 02-architecture.md § Windows'
/// "Close flushes" bullet.
///
/// > closing a board window (and app quit) first closes the board's card windows each open Edit
/// > session ends with its normal session commit then flushes pending debounced work, editor saves
/// > before the pending auto-commit, before the store tears down.
///
/// **Nothing about it is conditional.** A card window cannot exist without its board window (the
/// ownership rule in § Components), so there is no shape of the world in which some other order is
/// correct "the close flush is always the whole story". App quit runs this same object once per
/// open board rather than a second sequence that could drift.
///
/// ### Why closures rather than an object graph
///
/// Every step here is a claim about *order*, and an order is only testable if the steps can be
/// observed. Written against `NSWindow`, `BoardStore`, and SwiftUI's dismiss action this would be
/// verifiable only by running the app; written against these seams it is a pure ordering machine
/// that a test drives with an event log. `AppModel.closeBoard(ref:cause:)` is the one production
/// call site and supplies the real ones.
///
/// The two flush seams that are `nil` today `editorFlush` and `committerFlush` are named rather
/// than left to be discovered: 02 fixes their relative order ("editor saves before the pending
/// auto-commit"), and the milestone that adds a debounced editor save should have nowhere to put it
/// except the slot that already sits in the right place.
@MainActor
public struct CloseFlushCoordinator {
// MARK: Step 1 the card windows
/// This board's open card windows, read **live**: the coordinator calls it again while waiting,
/// because the set is what shrinks as each host tears down.
public var openCardRefs: () -> [CardWindowRef]
/// Runs one card window's end-session hook. Driven from here rather than left to the window's own
/// teardown so that "the sessions ended before the board's work was flushed" is an ordering this
/// object guarantees rather than one that happens to hold because SwiftUI ran the disappear
/// callbacks promptly.
public var endCardSession: (CardWindowRef) async -> Void
/// Asks the card window to go away. Its host unregisters on the way out, which is what drains
/// `openCardRefs`.
public var dismissCardWindow: (CardWindowRef) -> Void
/// How long to wait for the dismissed card windows to actually unregister.
///
/// A bound rather than an open-ended wait, and the reason is the quit path: this runs inside
/// `applicationShouldTerminate`'s deferred reply, so a window that never tears down would leave
/// the app unquittable. The sessions have already ended by then the wait exists to keep the
/// refcount honest, not to protect data so expiring it costs ordering tidiness and nothing
/// else.
public var cardDrainDeadline: Duration = .seconds(2)
// MARK: Step 2 pending work
/// The store's own pipeline settling `BoardStore.awaitQuiescence()` in production.
public var storeFlush: () async -> Void
/// The card windows' debounced body saves (05-card-window.md, m6). Runs **before**
/// `committerFlush`: 02 is explicit that editor saves land before the pending auto-commit, so a
/// session's last keystrokes are inside the commit that closes it rather than orphaned in the
/// next one.
public var editorFlush: (() async -> Void)?
/// The pending debounced auto-commit (06-history-undo.md, m7).
public var committerFlush: (() async -> Void)?
// MARK: Step 3 the record
/// Stamps the recents counts (live items only `AppModel.liveCounts(of:)`).
public var recordClose: () -> Void
/// Clears the record's open-now flag. Called **only** for `.userClose`; see `BoardCloseCause`.
public var clearOpenNow: () -> Void
// MARK: Step 4 teardown
/// Releases the store, stops the board's security-scoped access, and forgets the session.
public var tearDown: () -> Void
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "close-flush")
public init(
openCardRefs: @escaping () -> [CardWindowRef],
endCardSession: @escaping (CardWindowRef) async -> Void,
dismissCardWindow: @escaping (CardWindowRef) -> Void,
cardDrainDeadline: Duration = .seconds(2),
storeFlush: @escaping () async -> Void,
editorFlush: (() async -> Void)? = nil,
committerFlush: (() async -> Void)? = nil,
recordClose: @escaping () -> Void,
clearOpenNow: @escaping () -> Void,
tearDown: @escaping () -> Void
) {
self.openCardRefs = openCardRefs
self.endCardSession = endCardSession
self.dismissCardWindow = dismissCardWindow
self.cardDrainDeadline = cardDrainDeadline
self.storeFlush = storeFlush
self.editorFlush = editorFlush
self.committerFlush = committerFlush
self.recordClose = recordClose
self.clearOpenNow = clearOpenNow
self.tearDown = tearDown
}
// MARK: - The sequence
/// Runs the four steps in the one order 02 fixes. Never throws and never returns early: a board
/// that is closing is closing, and a step that fails must not strand the store, the record, or
/// the window.
public func run(cause: BoardCloseCause) async {
await closeCardWindows()
await flushPendingWork()
recordClose()
if cause == .userClose {
clearOpenNow()
}
tearDown()
}
/// Step 1. Every card window's session ends, then every card window is dismissed, then the
/// coordinator waits for them to unregister.
///
/// **All the sessions end before any window is dismissed**, deliberately. The alternative
/// end-then-dismiss, one card at a time would interleave commits with window teardowns, and a
/// teardown that took a moment would leave a later card's unsaved buffer sitting in memory that
/// much longer for no reason. The hooks are awaited in the order the refs came back, so a board
/// with several dirty editors commits them in a stable order rather than a racy one.
private func closeCardWindows() async {
let refs = openCardRefs()
guard !refs.isEmpty else { return }
for ref in refs {
await endCardSession(ref)
}
for ref in refs {
dismissCardWindow(ref)
}
await drainCardWindows()
}
/// Waits for the dismissed hosts to unregister, or for the deadline.
///
/// Polled rather than signalled by a continuation, and the deadline is why: the point of this
/// wait is that it *ends*, and a continuation resumed by the last unregister has no way to end
/// if that unregister never comes. The loop costs a handful of 10 ms turns during a window close
/// and nothing at all when the hosts tear down promptly, which they do.
private func drainCardWindows() async {
let start = ContinuousClock.now
while !openCardRefs().isEmpty {
guard ContinuousClock.now - start < cardDrainDeadline else {
Self.logger.error("card windows did not unregister within the drain deadline; closing anyway")
return
}
try? await Task.sleep(for: .milliseconds(10))
}
}
/// Step 2. The store's pipeline, then the editor saves, then the pending commit 02's order,
/// stated once.
private func flushPendingWork() async {
await storeFlush()
await editorFlush?()
await committerFlush?()
}
}
+79
View File
@@ -0,0 +1,79 @@
import SwiftUI
import os
/// The launch-time restoration pass, wearing a window because that is the only place SwiftUI lets
/// work like this run.
///
/// ### Why a window at all
///
/// Restoration has to open windows, and opening a window needs `openWindow`, which is only readable
/// from a view. An `App.init()` cannot do it and `AppDelegate` has no environment. So the app
/// presents one throwaway window at launch 1×1, plain, ordered straight back out, absent from the
/// Window menu whose only job is to run the pass and then dismiss itself. It exists for a few
/// hundred milliseconds and never draws.
///
/// It is presented **only** when there is something to restore (`KanbanApp` decides), so the ordinary
/// launch-to-welcome path never creates it.
///
/// ### What the pass does
///
/// Reads the registry's flagged records in `lastOpened` order (`BoardRegistry.restorables()`), opens
/// the available ones, and records the unavailable ones as failures 02 § Launch and window
/// lifecycle: "Other restorations proceed unaffected never a launch-time modal chain, never a
/// silent drop." Welcome comes up only if nothing was even attempted; a board that *was* attempted
/// and then failed to load opens welcome from its own host, which is the same rule applied one layer
/// down and keeps this pass from having to wait on loads it did not perform.
struct RestoreBootstrapView: View {
@Environment(AppModel.self) private var appModel
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
@State private var windowController = HostedWindowController()
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "launch")
var body: some View {
Color.clear
.frame(width: 1, height: 1)
.background(WindowAccessor(controller: windowController))
.onAppear {
// Out of sight before it can be seen. `orderOut` rather than a hidden style because
// the scene must still exist a window SwiftUI never presents never runs its task.
windowController.onAttach = { window in
window.alphaValue = 0
window.orderOut(nil)
}
if let window = windowController.window {
windowController.onAttach?(window)
}
}
.task { await restore() }
}
private func restore() async {
// Captured directly rather than waiting for `CaptureOpenWindow`'s `onAppear`: this task is
// the app's first act, and `openBoard` needs the action now.
appModel.captureWindowActions(open: openWindow, dismiss: dismissWindow)
var attempted = 0
for board in appModel.boardRegistry.restorables() {
switch board {
case let .available(_, url):
appModel.openBoard(at: url)
attempted += 1
case let .unavailable(record):
Self.logger.error("a flagged board could not be restored — its bookmark no longer resolves")
appModel.recordLaunchFailure(
path: record.lastKnownPath,
message: "This board is unavailable. Its volume may be offline, or it may have been moved or deleted."
)
}
}
if attempted == 0 {
appModel.showWelcome()
}
dismissWindow(id: WindowID.restoreBootstrap)
}
}
+142
View File
@@ -0,0 +1,142 @@
import AppKit
import SwiftUI
/// The welcome window (02-architecture.md § Windows).
///
/// ### What this is, and what it is not yet
///
/// The settled shape is Xcode's: "branding + actions left, recents right (board icon, name,
/// location, lane/card counts, sorted by last opened)". This is the left half, plus the one thing
/// that cannot wait the list of boards that failed to open, because 02 § Launch and window
/// lifecycle forbids a launch-time failure from being silently dropped and welcome is where it must
/// surface.
///
/// The layout is therefore already an `HStack` with one column in it. The recents column drops in
/// beside it; nothing here has to move.
// m4-welcome: the recents column, New Board / Open Recent, per-row Forget and Reveal in Finder, and
// the row-level failure rendering 02 specifies (a failed board's own row carrying fail-fast's
// specifics, or the unavailable state per Graceful orphaning) all land with the welcome milestone.
struct WelcomeView: View {
@Environment(AppModel.self) private var appModel
var body: some View {
HStack(spacing: 0) {
branding
.frame(width: 300)
.frame(maxHeight: .infinity)
.padding(32)
Divider()
failures
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(32)
}
// Fixed, with `.windowResizability(.contentSize)` on the scene: welcome is a launcher, not a
// workspace, and Xcode's the window this one is modelled on does not resize either. The
// one thing that can grow without bound is the failure list, which scrolls.
.frame(width: 760, height: 460)
}
// MARK: Branding and actions
private var branding: some View {
VStack(alignment: .leading, spacing: 0) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 96, height: 96)
.accessibilityHidden(true)
Text("Lanework")
.font(.system(size: 34, weight: .light))
.padding(.top, 12)
Text(versionSummary)
.font(.callout)
.foregroundStyle(.secondary)
Spacer(minLength: 24)
Button("Open Board…") {
appModel.presentOpenPanel()
}
.controlSize(.large)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var versionSummary: String {
let info = Bundle.main.infoDictionary
let short = info?["CFBundleShortVersionString"] as? String ?? ""
let build = info?["CFBundleVersion"] as? String ?? ""
return "Version \(short) (\(build))"
}
// MARK: Failed opens
@ViewBuilder
private var failures: some View {
if appModel.launchFailures.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("No boards open")
.font(.title3)
Text("Open a board folder to get started.")
.foregroundStyle(.secondary)
}
} else {
VStack(alignment: .leading, spacing: 12) {
Text("Couldn't open")
.font(.title3)
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ForEach(appModel.launchFailures) { failure in
VStack(alignment: .leading, spacing: 2) {
Text(failure.displayName)
.font(.headline)
Text(failure.message)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(failure.path)
.font(.caption)
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
Button("Clear") {
appModel.clearLaunchFailures()
}
}
}
}
}
// MARK: - Settings
/// The app's preferences (, 11-command-nexus.md).
///
/// One control, which is the whole of v1: "Restore open boards at launch". The preference gates only
/// whether the registry's open-now flags are *consulted* at launch the flags themselves are
/// maintained either way, which is what keeps crash recovery working for a user who has restoration
/// turned off and then turns it back on.
struct SettingsView: View {
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey)
private var restoreOpenBoardsAtLaunch = true
var body: some View {
Form {
Toggle("Restore open boards at launch", isOn: $restoreOpenBoardsAtLaunch)
}
.formStyle(.grouped)
.frame(width: 420)
.fixedSize()
}
}
+214
View File
@@ -0,0 +1,214 @@
import AppKit
import SwiftUI
import os
// MARK: - HostedWindowController
/// The `NSWindow` behind a SwiftUI scene, and the three things this app needs from it that SwiftUI
/// does not expose: the window's frame as the user changes it, a chance to run work *before* the
/// window closes, and the window object itself for placement.
///
/// ### The delegate is proxied, never replaced
///
/// SwiftUI owns its windows' delegates and uses them scene teardown, tabbing, restoration all ride
/// through it so assigning `window.delegate = self` and walking away breaks the window in ways
/// that show up much later and look like SwiftUI bugs. This object therefore **inserts itself in
/// front** of whatever delegate is already there: it implements the three methods it cares about and
/// forwards them on by hand, and for every other selector it claims to respond exactly when the
/// previous delegate does and forwards the message wholesale through `forwardingTarget(for:)`. The
/// `responds(to:)` override is what makes that safe `NSWindow` caches which delegate methods exist
/// at the moment the delegate is set, and a proxy that under-reported would silently swallow half of
/// SwiftUI's own callbacks.
///
/// The alternative that was considered and rejected: observing `NSWindow.willCloseNotification`
/// instead of intercepting `windowShouldClose`. It cannot work for the close flush by the time
/// that notification arrives the close has already been decided, and the flush's whole job is to
/// happen *first* (02-architecture.md § Windows). Move and resize, which have nothing to veto, could
/// have gone either way; they are delegate methods here so there is one mechanism rather than two.
@MainActor
final class HostedWindowController: NSObject, NSWindowDelegate {
/// The window, once the view hierarchy has one. Weak: the window owns the view that owns nothing
/// here, and a strong reference would keep a closed window alive.
private(set) weak var window: NSWindow?
/// Whoever was the delegate before us SwiftUI's own, in practice. Weak for the same reason
/// `NSWindow.delegate` is: it is not ours to keep alive.
///
/// `nonisolated(unsafe)` because the two proxying overrides below (`responds(to:)` and
/// `forwardingTarget(for:)`) override `NSObject` methods that are not actor-isolated and cannot
/// be made so. The property is written only on the main actor, and every read is a message the
/// Objective-C runtime is delivering to a window delegate which AppKit does on the main thread.
/// The alternative, `MainActor.assumeIsolated`, would turn any hypothetical off-main
/// `respondsToSelector:` into a crash; a stale read of a weak reference is the milder failure.
private nonisolated(unsafe) weak var previousDelegate: NSWindowDelegate?
/// Called once, when the window first appears. Placement (the saved frame, the card cascade)
/// happens here.
var onAttach: ((NSWindow) -> Void)?
/// Called on `windowDidMove` and at the end of a live resize not during one, because saving a
/// frame per mouse-moved event would write the registry file hundreds of times for one drag.
var onFrameChanged: ((NSRect) -> Void)?
/// Called instead of closing, when non-`nil`. The handler runs the close flush and then closes
/// the window itself through `closeAfterFlush()`. `nil` means "close normally", which is every
/// window that has nothing to flush.
var onCloseRequested: (() -> Void)?
/// Set by `closeAfterFlush()` so the re-entrant `windowShouldClose` lets the close through
/// instead of starting a second flush.
private var isFlushed = false
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
// MARK: Attachment
func attach(to window: NSWindow) {
guard self.window !== window else { return }
self.window = window
if window.delegate !== self {
// Guarding against self-proxying: re-attaching to a window we already front would
// otherwise make `previousDelegate` point at this object and every forwarded selector an
// infinite loop.
previousDelegate = window.delegate
window.delegate = self
}
onAttach?(window)
}
/// Puts the previous delegate back. Called when the hosting view goes away; a no-op if something
/// else has since taken the delegate, because stomping a third party's would be the bug this
/// whole file exists to avoid.
func detach() {
guard let window, window.delegate === self else { return }
window.delegate = previousDelegate
self.window = nil
}
/// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
/// standard path runs SwiftUI's own delegate gets its callbacks, tabbing behaves with the
/// flag telling our own `windowShouldClose` to stand aside.
func closeAfterFlush() {
isFlushed = true
window?.performClose(nil)
}
// MARK: NSWindowDelegate
func windowShouldClose(_ sender: NSWindow) -> Bool {
guard !isFlushed, let onCloseRequested else {
return previousDelegate?.windowShouldClose?(sender) ?? true
}
onCloseRequested()
// The window stays open with everything still on screen while the flush runs which is also
// what makes 02's "close waits for in-flight operations" implementable here later: the
// banner's spinner has somewhere to spin.
return false
}
func windowDidMove(_ notification: Notification) {
reportFrame()
previousDelegate?.windowDidMove?(notification)
}
func windowDidEndLiveResize(_ notification: Notification) {
reportFrame()
previousDelegate?.windowDidEndLiveResize?(notification)
}
private func reportFrame() {
guard let window else { return }
onFrameChanged?(window.frame)
}
// MARK: Proxying
override func responds(to aSelector: Selector!) -> Bool {
if super.responds(to: aSelector) { return true }
return previousDelegate?.responds(to: aSelector) ?? false
}
override func forwardingTarget(for aSelector: Selector!) -> Any? {
guard let previousDelegate, previousDelegate.responds(to: aSelector) else { return nil }
return previousDelegate
}
// MARK: Placement
/// Where a saved frame should actually open the settled rule in 02-architecture.md § Windows,
/// "per-board frame memory (repositioned onto a live screen if the saved one is gone)".
///
/// Pure, and taking the screens as an argument, because the interesting case is a display that is
/// *not attached right now*: a board last closed on an external monitor must not reopen at
/// coordinates nobody can see. Asking `NSScreen` inside would make that untestable and would
/// hide the rule inside a window callback.
///
/// Intersection, not containment, is the test: a window straddling two displays or hanging
/// slightly off the bottom of one is where the user left it, and AppKit's own
/// `constrainFrameRect(_:to:)` nudges the remainder into view when the frame is set. Only a frame
/// that lands on *no* live screen is relocated, and then it keeps its size and centers on the
/// fallback size is a preference, position is a place, and the place is what stopped existing.
static func placement(for saved: WindowFrame, onScreens visibleFrames: [NSRect], fallback: NSRect) -> NSRect {
let frame = NSRect(x: saved.x, y: saved.y, width: saved.width, height: saved.height)
if visibleFrames.contains(where: { $0.intersects(frame) }) {
return frame
}
return NSRect(
x: fallback.midX - frame.width / 2,
y: fallback.midY - frame.height / 2,
width: frame.width,
height: frame.height
)
}
/// `placement(for:onScreens:fallback:)` against the screens attached right now.
static func placementOnCurrentScreens(for saved: WindowFrame) -> NSRect {
let visibleFrames = NSScreen.screens.map(\.visibleFrame)
let fallback = NSScreen.main?.visibleFrame ?? visibleFrames.first ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
return placement(for: saved, onScreens: visibleFrames, fallback: fallback)
}
}
// MARK: - WindowAccessor
/// Hands a SwiftUI view's `NSWindow` to a `HostedWindowController`.
///
/// A zero-size, hidden `NSView` whose only job is `viewDidMoveToWindow()` the moment AppKit itself
/// declares the window known. The alternative idiom (read `view.window` from a dispatched block after
/// `makeNSView`) is a guess about timing that is usually right; this one is never wrong.
struct WindowAccessor: NSViewRepresentable {
let controller: HostedWindowController
func makeCoordinator() -> HostedWindowController { controller }
func makeNSView(context: Context) -> NSView {
let view = WindowSensingView()
view.onWindow = { [controller] window in
controller.attach(to: window)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
static func dismantleNSView(_ nsView: NSView, coordinator: HostedWindowController) {
coordinator.detach()
}
}
/// Draws nothing and wants no space it is a hook wearing a view's clothes. Hosted as a
/// `.background`, so even its zero-size frame is out of the layout's way.
private final class WindowSensingView: NSView {
var onWindow: ((NSWindow) -> Void)?
override var intrinsicContentSize: NSSize { .zero }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard let window else { return }
onWindow?(window)
}
}
+102
View File
@@ -0,0 +1,102 @@
import Foundation
// MARK: - BoardWindowRef
/// What a board window *is*, as a value: the board's root path.
///
/// `WindowGroup(id:for:)` keys its windows on the presented value, so this type is simultaneously
/// the window's identity and the argument its host opens with which is what makes
/// "**one board window per root**" (02-architecture.md § Windows) a property of the scene rather
/// than bookkeeping somebody has to remember: `openWindow(value:)` with a ref that already has a
/// window focuses that window instead of opening a second one.
///
/// **A path, not a bookmark or a file identity**, even though the app keys boards by identity
/// everywhere else (`BoardStoreRegistry`, `BoardRegistry`). Two reasons, both about what a window
/// value has to be: it must be `Codable` into a scene-restoration archive, and it must be cheap to
/// compare a `FileIdentity` is neither. The identity keying is not lost, only moved: `AppModel`
/// asks `BoardStoreRegistry.liveStore(for:)` which *is* identity-keyed before opening anything,
/// so a board reached through two spellings of its path still lands on the window it already has.
public struct BoardWindowRef: Codable, Hashable, Sendable {
/// The board root's filesystem path.
public let path: String
public init(path: String) {
self.path = path
}
public init(url: URL) {
self.init(path: url.path)
}
/// The root as a URL again. Carries **no** security-scoped access the scope belongs to the
/// URL object the bookmark resolved to, which the session holds for its whole life (`AppModel`),
/// never to a URL rebuilt from a string.
public var url: URL {
URL(fileURLWithPath: path, isDirectory: true)
}
}
// MARK: - CardWindowRef
/// What a card window is: **board root path plus card GUID** (05-card-window.md Deletion &
/// lifecycle).
///
/// That compound key is the whole lifecycle rule in one value. Within its board the window *follows*
/// its card the key names the card, not the lane, so a move between lanes is invisible to the
/// window. Across boards it does not: "a cross-board move dismisses it exactly like a delete, since
/// the board half of the key no longer names it once the card has left" the card's UUID travels
/// with the move, but `(oldBoard, uuid)` names nothing afterwards, so the window that was keyed on
/// it has no card and dismisses.
///
/// ### The card id is a string, compared like an `ItemID`
///
/// `rawValue` is stored, because the folder's exact spelling is what builds URLs and what must
/// round-trip byte-perfect (`ItemID`'s doc comment in `BoardModel.swift`). But equality and hashing
/// **case-fold** it, because `ItemID` does: two case-spellings of one UUID are one identity
/// everywhere in this app, and a window key that disagreed would open a *second* window for a card
/// whose folder was spelled `ABC` where the first was spelled `abc` the exact duplicate the
/// identity rule exists to prevent. The board half is compared verbatim: it is a path, and paths are
/// the filesystem's business, not this type's.
public struct CardWindowRef: Codable, Hashable, Sendable {
/// The owning board root's path the same string a `BoardWindowRef` carries, which is what lets
/// a card window find its board's session.
public let boardPath: String
/// The card's `ItemID.rawValue`: the folder name exactly as it is spelled on disk.
public let cardID: String
public init(boardPath: String, cardID: String) {
self.boardPath = boardPath
self.cardID = cardID
}
public init(board: BoardWindowRef, cardID: ItemID) {
self.init(boardPath: board.path, cardID: cardID.rawValue)
}
/// The board half, as the board window's own key how a card window reaches its session.
public var board: BoardWindowRef {
BoardWindowRef(path: boardPath)
}
public var boardURL: URL {
board.url
}
/// The card id under `ItemID`'s comparison rule. Computed, so `cardID` stays the single source of
/// truth for what is on disk.
public var cardIdentity: ItemID {
ItemID(rawValue: cardID)
}
public static func == (lhs: CardWindowRef, rhs: CardWindowRef) -> Bool {
lhs.boardPath == rhs.boardPath && lhs.cardIdentity == rhs.cardIdentity
}
public func hash(into hasher: inout Hasher) {
hasher.combine(boardPath)
hasher.combine(cardIdentity)
}
}