diff --git a/Kanban/App/AppDelegate.swift b/Kanban/App/AppDelegate.swift new file mode 100644 index 0000000..e83b14e --- /dev/null +++ b/Kanban/App/AppDelegate.swift @@ -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 + } +} diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift new file mode 100644 index 0000000..66377d7 --- /dev/null +++ b/Kanban/App/AppModel.swift @@ -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 = [] + + /// The scope the board is being read and written inside, released at teardown. `nil` when + /// the board was opened from a URL that needed none. + var access: ScopedAccess? + } + + /// Keyed by board window, because that is the thing whose lifetime a session shares. + /// + /// Observed: a card window watches for its board's session disappearing and dismisses itself when + /// it does — the safety net behind "card windows never outlive the board window". + public private(set) var sessions: [BoardWindowRef: BoardSession] = [:] + + /// The end-session hooks, keyed the same way the card windows are. + /// + /// Beside `BoardSession.cardRefs` rather than inside it: the set is *membership* (what the close + /// flush drains and what the safety net checks), this is the *seam table* (what it calls). They + /// are only ever written together, by the two register/unregister methods below, which is what + /// keeps them from becoming two answers to one question. + @ObservationIgnored + private var cardSessions: [CardWindowRef: any CardSessionFlushing] = [:] + + /// Boards whose close flush is already running — the re-entrancy guard. + /// + /// Needed because a board window can be told to close twice in quick succession: the + /// `windowShouldClose` interception starts the flush, and the host's own disappear runs a second + /// attempt as its safety net. The second must not re-enter a sequence that is mid-await. + @ObservationIgnored + private var closingBoards: Set = [] + + // MARK: Window actions + + /// SwiftUI's window-opening action, captured from whatever scene view is alive. + /// + /// It exists because the two things that most need to open a window are not views: + /// `AppDelegate.applicationShouldHandleReopen` (a Dock click with no windows must show welcome) + /// and the close-flush coordinator (which dismisses card windows). Neither can read + /// `@Environment`. The action stays valid after the view that supplied it is gone — it is a value + /// addressed to the app, not to a window — which is precisely the windowless case it is for. + /// + /// `@ObservationIgnored` on both: nothing renders from them, and an assignment on every scene's + /// appear would otherwise invalidate every observer for no reason. + @ObservationIgnored + public var windowOpener: OpenWindowAction? + + @ObservationIgnored + public var windowDismisser: DismissWindowAction? + + /// What `CaptureOpenWindow` calls. A method rather than two assignments so the launch flow, which + /// needs the actions before any `onAppear` has run, has one thing to call. + func captureWindowActions(open: OpenWindowAction, dismiss: DismissWindowAction) { + windowOpener = open + windowDismisser = dismiss + } + + // MARK: Launch failures + + /// Boards that failed to restore or open, newest last — the minimal welcome's one dynamic + /// section. See `LaunchFailure` for what replaces it. + public private(set) var launchFailures: [LaunchFailure] = [] + + // MARK: Card-window placement + + /// Where the next card window cascades from (05-card-window.md, "New windows open at the + /// last-used card-window size, cascaded"). + /// + /// `NSWindow.cascadeTopLeft(from:)` is the whole mechanism: passing `.zero` places the window at + /// its natural position and returns the point for the next one, so this is a running cursor + /// rather than a computed grid. App-wide, not per-board: two boards' card windows cascade past + /// each other rather than landing on top of one another. + @ObservationIgnored + var cardCascadePoint: NSPoint = .zero + + // MARK: Pending opens + + /// The security-scoped URL a board window is about to be built from, stashed between + /// `openBoard(at:)` and the host's first appearance. + /// + /// The handoff exists because a window value has to be `Codable` and a scoped URL is not a + /// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone + /// unless something carried it across. The host claims it on appear; an unclaimed entry (a window + /// that never opened) leaks one scope until quit, which is the cheapest failure available here. + @ObservationIgnored + private var pendingAccess: [BoardWindowRef: ScopedAccess] = [:] + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model") + + /// The app builds one of these with the real registry file; a test passes its own path for the + /// same reason `BoardRegistry` takes one at all — "injecting it is how a test stays out of the + /// real Application Support directory". + public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) { + boardRegistry = BoardRegistry(storageURL: registryStorageURL) + } + + // MARK: - Opening + + public var hasOpenBoards: Bool { !sessions.isEmpty } + + /// Opens a board window for `url`, or focuses the one this board already has. + /// + /// **The already-open check is by file identity, not by path** — `liveStore(for:)` resolves it — + /// so a board reached through a resolved bookmark and the same board reached through the open + /// panel land on one window even when the two URLs are spelled differently. Only when nothing is + /// open for it does a ref get minted, and `openWindow(value:)` with an equal ref focuses rather + /// than duplicates, which is the second half of "one board window per root". + /// + /// Security-scoped access starts here, *before* the window exists, because the host's very first + /// act is a tree walk: a scope started after the load would be too late. + public func openBoard(at url: URL) { + guard let windowOpener else { + Self.logger.error("openBoard with no window opener captured yet — ignored") + return + } + + if storeRegistry.liveStore(for: url) != nil, let existing = boardRef(forBoardAt: url) { + windowOpener(id: WindowID.board, value: existing) + return + } + + let ref = BoardWindowRef(url: url) + // Replacing a stash for the same ref would strand the old scope; there is no such case today + // (an unopened window's ref is not reachable), but stopping the loser is free. + pendingAccess.removeValue(forKey: ref)?.stop() + pendingAccess[ref] = ScopedAccess(url) + windowOpener(id: WindowID.board, value: ref) + } + + /// Shows — or focuses — the welcome window. Its own scene id, so this works with no windows at + /// all, which is the Dock-reactivation case (02: "Reactivation (Dock click) with no windows shows + /// welcome"). + public func showWelcome() { + windowOpener?(id: WindowID.welcome) + } + + /// The standard open panel behind File ▸ Open… ⌘O (11-command-nexus.md). + /// + /// **Validation is the open attempt itself** — there is no pre-flight check that a folder is a + /// board. Fail-fast owns that verdict (01-storage-format.md § Malformed input) and it is the same + /// verdict a restored board gets, so a folder that is not a board produces one error in one + /// vocabulary rather than two near-identical rejections in two. + /// + /// `treatsFilePackagesAsDirectories` is what lets a `.kanban` package be *chosen* while + /// `canChooseFiles` stays off: a package is a file to the panel otherwise, and boards are both + /// packages and plain folders (01 § Board naming). The cost is that double-clicking a package + /// navigates into it, which the welcome window's own open affordances will make moot. + public func presentOpenPanel() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.treatsFilePackagesAsDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = "Open" + panel.message = "Choose a board folder." + + guard panel.runModal() == .OK, let url = panel.url else { return } + openBoard(at: url) + } + + /// The ref of the window already showing the board at `url`, if any — matched through the store, + /// which is identity-keyed, rather than through the path. + private func boardRef(forBoardAt url: URL) -> BoardWindowRef? { + guard let store = storeRegistry.liveStore(for: url) else { return nil } + return sessions.first { $0.value.store === store }?.key + } + + // MARK: - Sessions + + public func session(for ref: BoardWindowRef) -> BoardSession? { + sessions[ref] + } + + /// Claims the scoped URL `openBoard(at:)` stashed for this window, or `nil` if it opened by some + /// other route. Claiming removes it: the session owns the balance from here. + func claimPendingAccess(for ref: BoardWindowRef) -> ScopedAccess? { + pendingAccess.removeValue(forKey: ref) + } + + /// Starts a board's session — the board window's host calls this once its load has succeeded. + func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) { + sessions[ref] = BoardSession(store: store, recordID: recordID, cardRefs: [], access: access) + } + + /// Registers a card window with its board's session, so the close flush can find it. + /// + /// A card window whose board has no session is a card window with no board — the ownership rule + /// says that cannot exist, and the host's own check dismisses it before reaching this. Recording + /// the seam anyway would leave an entry nothing ever drains. + func registerCardWindow(_ ref: CardWindowRef, session: any CardSessionFlushing) { + guard sessions[ref.board] != nil else { + Self.logger.debug("card window registered against a board with no session — ignored") + return + } + sessions[ref.board]?.cardRefs.insert(ref) + cardSessions[ref] = session + } + + func unregisterCardWindow(_ ref: CardWindowRef) { + sessions[ref.board]?.cardRefs.remove(ref) + cardSessions[ref] = nil + } + + // MARK: - Launch failures + + /// Records a board that could not be opened. Deliberately additive and never cleared on success: + /// welcome is showing *because* something failed, and a list that emptied itself as other boards + /// arrived would be the silent drop 02 rules out. + public func recordLaunchFailure(path: String, message: String) { + launchFailures.append(LaunchFailure(path: path, message: message)) + } + + /// Forgets the failures — the welcome window's dismissal of a list the user has read. + public func clearLaunchFailures() { + launchFailures.removeAll() + } + + // MARK: - Counts + + /// The lane and card counts stamped into the registry at close — **live items only** (02 + /// § Per-board app state, settled). + /// + /// > tombstoned lanes and cards — and cards hidden beneath a tombstoned lane — don't count; the + /// > row advertises the board's working size, and the trash is an errand, not inventory. + /// + /// The nesting is the ancestor walk: a tombstoned lane is skipped whole, so its cards are never + /// reached whatever their own flags say. `Lane.isDeleted`/`Card.isDeleted` are presence-of-key, + /// not validity, so a malformed `deleted:` counts as deleted here exactly as it does everywhere + /// else. + /// + /// Static and pure: it is a fact about a snapshot, and the close flush is the wrong place to + /// discover a counting bug. + public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) { + var lanes = 0 + var cards = 0 + for lane in snapshot.lanes where !lane.isDeleted { + lanes += 1 + for card in lane.cards where !card.isDeleted { + cards += 1 + } + } + return (lanes, cards) + } + + /// A board's display name: its `title`, falling back to the folder name sans extension + /// (01-storage-format.md § Board naming). + /// + /// Read from `store.rootURL` rather than `snapshot.rootURL` so the fallback follows a rename the + /// moment it is absorbed, instead of lagging by one reload (see `BoardStore.rootURL`). + public static func displayName(of store: BoardStore) -> String { + if let title = store.snapshot.title.value, !title.isEmpty { + return title + } + return store.rootURL.deletingPathExtension().lastPathComponent + } + + // MARK: - Closing + + /// Runs the close flush for one board and tears its session down. + /// + /// Idempotent by two guards: a board with no session has already closed, and a board already + /// mid-flush is not started again. Both matter — the window's close interception and the host's + /// disappear both call this, by design, because neither one alone fires on every path a window + /// can leave by. + public func closeBoard(ref: BoardWindowRef, cause: BoardCloseCause) async { + guard sessions[ref] != nil, !closingBoards.contains(ref) else { return } + closingBoards.insert(ref) + defer { closingBoards.remove(ref) } + + await coordinator(for: ref).run(cause: cause) + } + + /// Quit: the same sequence, once per open board, **sequentially**. + /// + /// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than + /// three interleavings of it, and in a stable board order so a quit is reproducible. Nothing here + /// clears an open-now flag — that is what `.quit` means, and it is what makes the next launch + /// restore this set (§ Launch and window lifecycle). + public func flushAllBoardsForQuit() async { + for ref in sessions.keys.sorted(by: { $0.path < $1.path }) { + await closeBoard(ref: ref, cause: .quit) + } + } + + /// Wires a session into `CloseFlushCoordinator`'s seams. The ordering lives over there; this is + /// only which real object each step touches. + private func coordinator(for ref: BoardWindowRef) -> CloseFlushCoordinator { + CloseFlushCoordinator( + openCardRefs: { [weak self] in + // Sorted so a board with several card windows commits and closes them in a stable + // order rather than a `Set`'s. + (self?.sessions[ref]?.cardRefs).map { $0.sorted { $0.cardID < $1.cardID } } ?? [] + }, + endCardSession: { [weak self] cardRef in + await self?.cardSessions[cardRef]?.endSession() + }, + dismissCardWindow: { [weak self] cardRef in + self?.windowDismisser?(value: cardRef) + }, + storeFlush: { [weak self] in + await self?.sessions[ref]?.store.awaitQuiescence() + }, + // editorFlush / committerFlush stay nil until m6 and m7 have something to flush; the + // slots exist so their order is already decided when they do. + recordClose: { [weak self] in + guard let self, let session = sessions[ref] else { return } + let counts = Self.liveCounts(of: session.store.snapshot) + boardRegistry.recordClose(id: session.recordID, laneCount: counts.lanes, cardCount: counts.cards) + }, + clearOpenNow: { [weak self] in + guard let self, let session = sessions[ref] else { return } + boardRegistry.clearOpenNow(id: session.recordID) + }, + tearDown: { [weak self] in + guard let self, let session = sessions.removeValue(forKey: ref) else { return } + storeRegistry.release(session.store) + session.access?.stop() + } + ) + } +} diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift new file mode 100644 index 0000000..19a6d19 --- /dev/null +++ b/Kanban/App/BoardWindowHost.swift @@ -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 + } +} diff --git a/Kanban/App/CaptureOpenWindow.swift b/Kanban/App/CaptureOpenWindow.swift new file mode 100644 index 0000000..165bd26 --- /dev/null +++ b/Kanban/App/CaptureOpenWindow.swift @@ -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)) + } +} diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift new file mode 100644 index 0000000..65a0f40 --- /dev/null +++ b/Kanban/App/CardWindowHost.swift @@ -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) + } + } +} diff --git a/Kanban/App/CloseFlushCoordinator.swift b/Kanban/App/CloseFlushCoordinator.swift new file mode 100644 index 0000000..75429c1 --- /dev/null +++ b/Kanban/App/CloseFlushCoordinator.swift @@ -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?() + } +} diff --git a/Kanban/App/RestoreBootstrapView.swift b/Kanban/App/RestoreBootstrapView.swift new file mode 100644 index 0000000..2d977cd --- /dev/null +++ b/Kanban/App/RestoreBootstrapView.swift @@ -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) + } +} diff --git a/Kanban/App/WelcomeView.swift b/Kanban/App/WelcomeView.swift new file mode 100644 index 0000000..ad08010 --- /dev/null +++ b/Kanban/App/WelcomeView.swift @@ -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() + } +} diff --git a/Kanban/App/WindowAccessor.swift b/Kanban/App/WindowAccessor.swift new file mode 100644 index 0000000..f423106 --- /dev/null +++ b/Kanban/App/WindowAccessor.swift @@ -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) + } +} diff --git a/Kanban/App/WindowRefs.swift b/Kanban/App/WindowRefs.swift new file mode 100644 index 0000000..39b8bc3 --- /dev/null +++ b/Kanban/App/WindowRefs.swift @@ -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) + } +} diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 19ddb11..6eb5a38 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -1,12 +1,125 @@ import SwiftUI +/// The scene graph (02-architecture.md § Windows, § Launch and window lifecycle). +/// +/// ### Four scenes, and why each is the kind it is +/// +/// - **Welcome** is a `Window`: there is one of it, ever, and `openWindow(id:)` focuses the existing +/// one rather than making a second. +/// - **The restore bootstrap** is a `Window` too, and a deliberate oddity — see +/// `RestoreBootstrapView` for why launch-time work has to wear a window at all. +/// - **Boards** and **cards** are `WindowGroup(for:)`s, because their identity is a *value*: opening +/// with a ref that already has a window focuses it, which is how "one board window per root" and +/// "at most one card window per card (reopen focuses)" are enforced by the scene rather than by +/// bookkeeping. +/// +/// ### Restoration is the registry's, not the system's +/// +/// Both groups declare `.restorationBehavior(.disabled)`. The app already knows which boards were +/// open — the registry's open-now flags, which survive a crash and reopen in `lastOpened` order — +/// and letting AppKit *also* restore windows would produce duplicates, and worse, card windows +/// restored behind boards that never opened. One mechanism, and it is the one that can explain +/// itself when a board has moved or gone. +/// +/// ### Which window appears at launch +/// +/// Exactly one of welcome and the bootstrap, decided once in `init` and never re-derived: the +/// preference is read before any scene exists, and `restorables()` costs a bookmark resolution per +/// known board — a computed property here would pay that on every scene-graph evaluation. @main struct KanbanApp: App { + + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + @State private var appModel: AppModel + + /// Whether this launch restores boards: the preference is on **and** there is something flagged + /// to restore. Welcome "appears only when nothing restores". + private let shouldRestoreAtLaunch: Bool + + init() { + let model = AppModel() + _appModel = State(initialValue: model) + shouldRestoreAtLaunch = AppPreferences.restoreOpenBoardsAtLaunch + && !model.boardRegistry.restorables().isEmpty + // The delegate is constructed by the adaptor before this runs, so this is the one place the + // app's model and its AppKit half meet. + appDelegate.appModel = model + } + var body: some Scene { - WindowGroup { - Text("Lanework") - .font(.largeTitle) - .padding(80) + Window("Welcome to Lanework", id: WindowID.welcome) { + WelcomeView() + .environment(appModel) + .captureWindowActions(into: appModel) + } + .defaultLaunchBehavior(shouldRestoreAtLaunch ? .suppressed : .automatic) + .restorationBehavior(.disabled) + .windowResizability(.contentSize) + // Its automatic Window-menu item is replaced by the explicit command below, so the title is + // the one 11-command-nexus.md names rather than whatever the scene happens to be called. + .commandsRemoved() + + Window("", id: WindowID.restoreBootstrap) { + RestoreBootstrapView() + .environment(appModel) + .captureWindowActions(into: appModel) + } + .defaultLaunchBehavior(shouldRestoreAtLaunch ? .presented : .suppressed) + .restorationBehavior(.disabled) + .windowStyle(.plain) + .defaultSize(width: 1, height: 1) + .commandsRemoved() + + WindowGroup(id: WindowID.board, for: BoardWindowRef.self) { $ref in + if let ref { + BoardWindowHost(ref: ref) + .environment(appModel) + .captureWindowActions(into: appModel) + } + } + .restorationBehavior(.disabled) + .defaultLaunchBehavior(.suppressed) + .commands { menuCommands } + + WindowGroup(id: WindowID.card, for: CardWindowRef.self) { $ref in + if let ref { + CardWindowHost(ref: ref) + .environment(appModel) + .captureWindowActions(into: appModel) + } + } + .restorationBehavior(.disabled) + .defaultLaunchBehavior(.suppressed) + + Settings { + SettingsView() + .environment(appModel) + .captureWindowActions(into: appModel) + } + } + + /// The two menu items this milestone owns. + /// + /// **The titles are API** (04-interactions.md ▸ Configurable bindings): macOS's App Shortcuts + /// mechanism remaps menu items *by title*, so these strings are the keys a user's custom binding + /// is stored under. They are spelled exactly as 11-command-nexus.md inventories them, and + /// changing one silently breaks every remap of it. + @CommandsBuilder + private var menuCommands: some Commands { + CommandGroup(after: .newItem) { + Button("Open…") { + appModel.presentOpenPanel() + } + .keyboardShortcut("o", modifiers: .command) + } + + CommandGroup(after: .windowList) { + // No default chord — "— (no default)" in the Nexus is deliberate, not a gap; it remaps + // like any other item. + Button("Welcome to Lanework") { + appModel.showWelcome() + } } } } diff --git a/Kanban/LiveStore/BoardRegistry.swift b/Kanban/LiveStore/BoardRegistry.swift index 8abdd59..135815b 100644 --- a/Kanban/LiveStore/BoardRegistry.swift +++ b/Kanban/LiveStore/BoardRegistry.swift @@ -99,6 +99,21 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { public var windowFrame: WindowFrame? + /// Whether this board's window is open **right now** — the restoration set, as a live marker + /// rather than an at-quit write (02-architecture.md § Launch and window lifecycle, settled). + /// + /// Set when the board's window opens, cleared on *user-initiated* close; quit's teardown + /// deliberately leaves it standing, because the boards open at quit are by definition the ones + /// to restore. **Crash recovery falls out for free**: after a crash the flags describe what was + /// open at crash time, so the next launch restores exactly that — no separate recovery logic, no + /// once-at-quit stamp to race teardown or miss when the app dies. + /// + /// **Optional because every key here must be** (see Evolving this struct above): a registry file + /// written before this key existed decodes with `nil`, which reads as "not open" and costs the + /// user nothing. A non-optional `Bool` would have quarantined every existing file on upgrade and + /// emptied everyone's recents. + public var isOpenNow: Bool? + /// Whether committing also pushes (07-sync-collab.md). Off by default: pushing is a decision, /// not a side effect. public var pushOnCommit: Bool @@ -117,6 +132,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { laneCount: Int? = nil, cardCount: Int? = nil, windowFrame: WindowFrame? = nil, + isOpenNow: Bool? = nil, pushOnCommit: Bool = false, remoteLocationWarned: Bool = false ) { @@ -128,6 +144,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { self.laneCount = laneCount self.cardCount = cardCount self.windowFrame = windowFrame + self.isOpenNow = isOpenNow self.pushOnCommit = pushOnCommit self.remoteLocationWarned = remoteLocationWarned } @@ -236,6 +253,12 @@ public final class BoardRegistry { /// A match is updated in place with a **fresh bookmark** (subsuming the stale-refresh case), the /// caller's `displayName`, the path it was opened at, and `lastOpened` = now. No match creates a /// record. Either way the file is saved before returning. + /// + /// **`isOpenNow` is deliberately untouched here.** Recording an open and *being* open are two + /// different facts: this method is called before a window exists (and, later, by flows that + /// record a board without showing one), so the flag is set by `setOpenNow(id:)` once the window + /// has actually opened. Folding it in would flag boards that never made it onto screen and hand + /// the next launch a restoration set describing failures. @discardableResult public func recordOpen(of rootURL: URL, displayName: String) -> UUID { let bookmark = Self.makeBookmark(for: rootURL)?.data ?? Data() @@ -279,6 +302,50 @@ public final class BoardRegistry { } } + // MARK: - The open-now marker + + /// Marks this board as open — called when its window has actually opened, not when the open was + /// merely attempted (02-architecture.md § Launch and window lifecycle). + public func setOpenNow(id: UUID) { + update(id) { $0.isOpenNow = true } + } + + /// Clears the marker — **user-initiated close only**. + /// + /// Quit's teardown must never call this, and that omission is the entire restoration mechanism: + /// "quit's teardown closes deliberately leave it standing (the boards were open at quit by + /// definition; teardown distinguishes user-close from quit-close, and that distinction is the + /// whole mechanism)". A crash calls nothing at all, which is why crash recovery needs no code of + /// its own — the flags already describe what was open when the app died. + public func clearOpenNow(id: UUID) { + update(id) { $0.isOpenNow = false } + } + + /// The boards to reopen at launch: every record still flagged open, **oldest `lastOpened` + /// first**. + /// + /// Ascending, unlike `recents()`, because these are reopened in order and the result should be + /// the stacking the user left behind — the most recently opened board ends up frontmost because + /// it opens last. Classification is `recents()`' own bookmark resolution, reused rather than + /// re-implemented: a flagged board on an unmounted volume is `unavailable` here for exactly the + /// reason it is unavailable there, and the launch flow renders it as a failed restoration + /// (§ "A restored board that fails surfaces on welcome, row-level") instead of hanging on a + /// mount. + /// + /// The preference gates only whether this is *consulted*; the flags are maintained regardless. + public func restorables() -> [RecentBoard] { + recents() + .filter { $0.record.isOpenNow == true } + // Ascending, with the same id tie-break `recents()` uses inverted, so two boards opened + // in the same millisecond still come back in one stable order rather than whichever + // `sorted(by:)` felt like. + .sorted { lhs, rhs in + lhs.record.lastOpened == rhs.record.lastOpened + ? lhs.record.id.uuidString < rhs.record.id.uuidString + : lhs.record.lastOpened < rhs.record.lastOpened + } + } + // MARK: - Per-board settings public func updateWindowFrame(id: UUID, frame: WindowFrame) { diff --git a/KanbanTests/AppModelTests.swift b/KanbanTests/AppModelTests.swift new file mode 100644 index 0000000..867ca5f --- /dev/null +++ b/KanbanTests/AppModelTests.swift @@ -0,0 +1,224 @@ +import Foundation +import Testing +@testable import Kanban + +/// `AppModel` is mostly window bookkeeping that only means anything with a window on screen, but two +/// of its members are pure facts about a snapshot and both are load-bearing: the counts a welcome row +/// advertises, and the name a window title shows. Neither is observable from a unit test any other +/// way once it is wrong — a stale count looks like staleness, which the design accepts, and a wrong +/// count looks exactly the same. + +// MARK: - Fixtures + +/// Live and tombstoned at both levels, plus the case the ancestor walk exists for: live cards +/// underneath a tombstoned lane. +/// +/// - lane 1 (live): two live cards, one tombstoned card +/// - lane 2 (**tombstoned**): two live cards, which render nowhere and must not count +/// - lane 3 (live): empty +@MainActor +private func makeMixedBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + try fixture.item( + "\(Ident.lane1)/\(Ident.card3)", + "---\nschema: 1\norder: 3072\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + + try fixture.item( + Ident.lane2, + "---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Buried")) + try fixture.item( + "\(Ident.lane2)/\(Ident.indexless)", + "---\nschema: 1\norder: 2048\ntitle: Also buried\n---\nbody\n" + ) + + try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Doing")) + return fixture +} + +/// An `AppModel` whose registry file lives in temp rather than in the test host's real Application +/// Support directory. +@MainActor +private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { + let folder = FileManager.default.temporaryDirectory + .appendingPathComponent("AppModelTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let model = AppModel(registryStorageURL: folder.appendingPathComponent("board-registry.json")) + return (model, { try? FileManager.default.removeItem(at: folder) }) +} + +/// Opens a board the way `BoardWindowHost` does — acquire, record, flag, begin — so the close tests +/// are closing something the app would recognise. +@MainActor +@discardableResult +private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef { + let ref = BoardWindowRef(url: url) + let store = try model.storeRegistry.acquire(url) + let recordID = model.boardRegistry.recordOpen(of: url, displayName: AppModel.displayName(of: store)) + model.boardRegistry.setOpenNow(id: recordID) + model.beginSession(ref: ref, store: store, recordID: recordID, access: nil) + return ref +} + +// MARK: - Tests + +@MainActor +@Suite("AppModel") +struct AppModelTests { + + // MARK: Live-only counts + + @Test("The recents counts are live items only, at both levels") + func liveCountsIgnoreTombstonesAndWhatHidesBeneathThem() throws { + let fixture = try makeMixedBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // The snapshot itself keeps everything — tombstones are what the trash renders — so this is a + // genuine filter, not a property of the load. + #expect(snapshot.lanes.count == 3) + #expect(snapshot.lanes.flatMap(\.cards).count == 5) + + let counts = AppModel.liveCounts(of: snapshot) + #expect(counts.lanes == 2, "the tombstoned lane is not part of the board's working size") + #expect(counts.cards == 2, "one tombstoned card, and two more hidden beneath a tombstoned lane") + } + + @Test("A board with nothing live counts zero rather than declining to answer") + func liveCountsOfAnEmptyBoard() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item( + Ident.lane1, + "---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Buried")) + + let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model) + #expect(counts.lanes == 0) + #expect(counts.cards == 0) + } + + @Test("A malformed deleted: still counts as deleted") + func liveCountsFollowPresenceNotValidity() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item( + "\(Ident.lane1)/\(Ident.card1)", + "---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: yesterday\n---\nbody\n" + ) + + // The presence of the key is what encodes deletion intent (`Card.isDeleted`), so an + // unparseable timestamp hides the card here exactly as it hides it on the board. + let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model) + #expect(counts.lanes == 1) + #expect(counts.cards == 0) + } + + // MARK: Display name + + @Test("A board's display name is its title, falling back to the folder name sans extension") + func displayNameFallsBackToTheFolderName() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + + try fixture.item("Weekly.kanban", "---\nschema: 1\ntitle: Weekly Review\n---\nbody\n") + let titled = try BoardStore(rootURL: fixture.url("Weekly.kanban")) + #expect(AppModel.displayName(of: titled) == "Weekly Review") + + try fixture.item("Untitled Board.kanban", "---\nschema: 1\n---\nbody\n") + let untitled = try BoardStore(rootURL: fixture.url("Untitled Board.kanban")) + #expect(AppModel.displayName(of: untitled) == "Untitled Board", "sans extension, per 01 § Board naming") + } + + // MARK: Sessions + + @Test("A user close stamps live counts, unflags the board, and lets the store go") + func closingABoardRunsTheRealFlush() async throws { + let fixture = try makeMixedBoard() + defer { fixture.tearDown() } + let (model, tearDown) = try makeModel() + defer { tearDown() } + + let ref = try openBoard(model, at: fixture.root) + let recordID = try #require(model.session(for: ref)?.recordID) + #expect(model.hasOpenBoards) + #expect(model.storeRegistry.liveStore(for: fixture.root) != nil) + + await model.closeBoard(ref: ref, cause: .userClose) + + #expect(model.session(for: ref) == nil) + #expect(!model.hasOpenBoards) + #expect(model.storeRegistry.liveStore(for: fixture.root) == nil, "the last reference went with the session") + + let record = try #require(model.boardRegistry.record(id: recordID)) + #expect(record.laneCount == 2, "the counts the welcome row will show are the live ones") + #expect(record.cardCount == 2) + #expect(record.isOpenNow == false) + #expect(model.boardRegistry.restorables().isEmpty) + + // Twice is a no-op, which is what lets the window's close interception and its disappear both + // call this without the sequence running twice. + await model.closeBoard(ref: ref, cause: .userClose) + #expect(model.boardRegistry.record(id: recordID)?.isOpenNow == false) + } + + @Test("Quit closes every board and leaves them all flagged for the next launch") + func quitFlushesEveryBoardAndPreservesTheRestorationSet() async throws { + let first = try makeMixedBoard() + defer { first.tearDown() } + let second = try makeMixedBoard() + defer { second.tearDown() } + let (model, tearDown) = try makeModel() + defer { tearDown() } + + try openBoard(model, at: first.root) + try openBoard(model, at: second.root) + #expect(model.storeRegistry.openBoardCount == 2) + + await model.flushAllBoardsForQuit() + + #expect(!model.hasOpenBoards) + #expect(model.storeRegistry.openBoardCount == 0, "every board's store was released, not just the first") + #expect(model.boardRegistry.restorables().count == 2, "the flags describe what was open at quit") + for row in model.boardRegistry.restorables() { + #expect(row.record.laneCount == 2, "and every board was stamped on the way out") + } + } + + @Test("Card windows join and leave their board's session") + func cardWindowMembershipIsTracked() throws { + let fixture = try makeMixedBoard() + defer { fixture.tearDown() } + let (model, tearDown) = try makeModel() + defer { tearDown() } + + let ref = try openBoard(model, at: fixture.root) + let card = CardWindowRef(board: ref, cardID: ItemID(rawValue: Ident.card1)) + let session = CardWindowSession() + + model.registerCardWindow(card, session: session) + #expect(model.session(for: ref)?.cardRefs == [card]) + + // A card window against a board with no session is the one thing the ownership rule forbids; + // registering it would leave an entry the close flush never drains. + let orphan = CardWindowRef(boardPath: "/nowhere", cardID: Ident.card2) + model.registerCardWindow(orphan, session: CardWindowSession()) + #expect(model.session(for: orphan.board) == nil) + + model.unregisterCardWindow(card) + #expect(model.session(for: ref)?.cardRefs.isEmpty == true) + + model.storeRegistry.release(try #require(model.session(for: ref)?.store)) + } +} diff --git a/KanbanTests/BoardRegistryTests.swift b/KanbanTests/BoardRegistryTests.swift index a6c5378..3adae09 100644 --- a/KanbanTests/BoardRegistryTests.swift +++ b/KanbanTests/BoardRegistryTests.swift @@ -346,6 +346,118 @@ struct BoardRegistryTests { #expect(ids(registry.recents()) == [firstID, thirdID, secondID]) } + // MARK: The open-now marker + + @Test("Opening flags a board, a user close unflags it, and quit deliberately leaves it standing") + func openNowTracksWindowsAndSurvivesQuit() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + let fixture = try makeBoard() + defer { fixture.tearDown() } + let registry = BoardRegistry(storageURL: storage.url) + + let id = registry.recordOpen(of: fixture.root, displayName: "Work") + #expect(registry.record(id: id)?.isOpenNow == nil, "recording an open is not opening a window") + #expect(registry.restorables().isEmpty) + + registry.setOpenNow(id: id) + #expect(registry.record(id: id)?.isOpenNow == true) + #expect(ids(registry.restorables()) == [id]) + + // A user close. The flag goes, and with it the board's place in the next launch. + registry.clearOpenNow(id: id) + #expect(registry.record(id: id)?.isOpenNow == false) + #expect(registry.restorables().isEmpty) + + // A quit. The teardown stamps counts and does *not* clear the flag — that omission is the + // whole restoration mechanism, so it is asserted rather than assumed, and asserted across a + // reload of the file because a relaunch is what consumes it. + registry.setOpenNow(id: id) + registry.recordClose(id: id, laneCount: 2, cardCount: 5) + + let afterRelaunch = BoardRegistry(storageURL: storage.url) + #expect(afterRelaunch.record(id: id)?.isOpenNow == true, "the flags describe what was open at quit") + #expect(ids(afterRelaunch.restorables()) == [id]) + #expect(afterRelaunch.record(id: id)?.laneCount == 2) + } + + @Test("Restorables are the flagged records only, oldest first") + func restorablesReopenInLastOpenedOrder() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + let first = try makeBoard() + defer { first.tearDown() } + let second = try makeBoard() + defer { second.tearDown() } + let third = try makeBoard() + defer { third.tearDown() } + let registry = BoardRegistry(storageURL: storage.url) + + let firstID = registry.recordOpen(of: first.root, displayName: "First") + try await Task.sleep(for: .milliseconds(5)) + let secondID = registry.recordOpen(of: second.root, displayName: "Second") + try await Task.sleep(for: .milliseconds(5)) + let thirdID = registry.recordOpen(of: third.root, displayName: "Third") + + registry.setOpenNow(id: firstID) + registry.setOpenNow(id: thirdID) + + // Ascending, the exact inverse of `recents()` — these are reopened in order, so the board + // opened last at quit opens last again and ends up frontmost. + #expect(ids(registry.restorables()) == [firstID, thirdID]) + #expect(ids(registry.recents()) == [thirdID, secondID, firstID], "recents is unchanged, and still newest first") + #expect(!ids(registry.restorables()).contains(secondID), "a board that was not open does not restore") + + // A flagged board whose folder has gone still comes back — classified, not dropped. The + // launch flow renders it as a failed restoration rather than pretending it was never open. + third.tearDown() + let rows = registry.restorables() + #expect(ids(rows) == [firstID, thirdID]) + guard case .unavailable = rows[1] else { + Issue.record("expected the deleted board to classify unavailable, got \(rows[1])") + return + } + } + + @Test("A registry file written before the open-now key existed still decodes") + func oldRegistryFilesDecodeWithoutTheOpenNowKey() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + + // Byte-for-byte the shape this file had one milestone ago: no `isOpenNow` anywhere. The + // struct's evolution rule says a new key must be optional precisely so this file survives — + // a required key would have failed to decode, quarantined the file, and emptied the user's + // recents on upgrade. + let id = UUID() + let garbage = Data("not a bookmark".utf8).base64EncodedString() + let json = """ + [ + { + "bookmark" : "\(garbage)", + "cardCount" : 9, + "displayName" : "Archive", + "id" : "\(id.uuidString)", + "laneCount" : 4, + "lastKnownPath" : "/Volumes/Archive/Boards/Archive", + "lastOpened" : "2026-01-01T09:00:00.000Z", + "pushOnCommit" : true, + "remoteLocationWarned" : true + } + ] + """ + try Data(json.utf8).write(to: storage.url) + + let registry = BoardRegistry(storageURL: storage.url) + #expect(registry.recents().count == 1, "the file decoded; nothing was quarantined") + #expect(registry.record(id: id)?.isOpenNow == nil, "a missing key reads as 'not open'") + #expect(registry.restorables().isEmpty) + #expect(registry.record(id: id)?.laneCount == 4, "and every other field survived") + + // And the key writes through from here on. + registry.setOpenNow(id: id) + #expect(BoardRegistry(storageURL: storage.url).record(id: id)?.isOpenNow == true) + } + // MARK: Bookmarks in a sandboxed host @Test("A bookmark is always produced, and resolves back to the same folder") diff --git a/KanbanTests/CardWindowFateTests.swift b/KanbanTests/CardWindowFateTests.swift new file mode 100644 index 0000000..faf139d --- /dev/null +++ b/KanbanTests/CardWindowFateTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing +@testable import Kanban + +/// A card window's whole lifecycle is one decision re-taken on every snapshot: does this key still +/// name a card? Four answers, three of which are "no" for different reasons, and the one that is +/// easiest to get wrong — a live card under a tombstoned lane — is invisible in the card's own data. +/// So the decision is a pure function and this is its suite; nothing here needs a window. + +// MARK: - Fixtures + +/// - lane 1 (live): one live card, one tombstoned card +/// - lane 2 (**tombstoned**): one live card, whose own flag is clear +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) + try fixture.item( + "\(Ident.lane1)/\(Ident.card2)", + "---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + + try fixture.item( + Ident.lane2, + "---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried alive")) + return fixture +} + +private func title(_ fate: CardWindowFate) -> String? { + guard case let .shows(card) = fate else { return nil } + return card.title.value +} + +// MARK: - Tests + +@MainActor +@Suite("Card window fate") +struct CardWindowFateTests { + + @Test("A live card in a live lane keeps its window") + func aLiveCardShows() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + let fate = CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) + #expect(title(fate) == "Fix login") + } + + @Test("A tombstoned card dismisses its window") + func aTombstonedCardDismisses() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // ⌫ on the board closes the card's open window — "a tombstone counts as deleted" + // (05-card-window.md). The card is still in the snapshot; the trash renders it. + #expect(snapshot.lanes[0].cards.contains { $0.id.rawValue == Ident.card2 }) + #expect(CardWindowHost.cardWindowFate(cardID: Ident.card2, in: snapshot) == .dismisses) + } + + @Test("A live card under a tombstoned lane dismisses too — liveness is ancestor-walked") + func aLaneTombstoneDismissesItsCards() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // The card's own flag says nothing is wrong. Its lane's does, and 03-board-ui.md collapses a + // tombstoned lane to one restorable trash entry — so the card renders nowhere, and a window + // onto something that renders nowhere is the case this walk exists for. + let buried = try #require(snapshot.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.first) + #expect(!buried.isDeleted) + #expect(CardWindowHost.cardWindowFate(cardID: Ident.card3, in: snapshot) == .dismisses) + } + + @Test("A card that is not in this board's snapshot dismisses — the cross-board move") + func anAbsentCardDismisses() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // What a cross-board move looks like from the source board: the UUID travels with the card, + // but the board half of the window's key no longer names it, so the window goes exactly as it + // would for a delete. + #expect(CardWindowHost.cardWindowFate(cardID: Ident.card4, in: snapshot) == .dismisses) + } + + @Test("A case-respelled card id still finds its card") + func theCardIDIsComparedAsAUUIDValue() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // An agent's `uuidgen` prints uppercase (01-storage-format.md § Fractal layout), so a card + // window keyed on one spelling must still find a folder written in the other. Comparing the + // strings would dismiss a perfectly live card. + let fate = CardWindowHost.cardWindowFate(cardID: Ident.card1.uppercased(), in: snapshot) + #expect(title(fate) == "Fix login") + } +} diff --git a/KanbanTests/CloseFlushCoordinatorTests.swift b/KanbanTests/CloseFlushCoordinatorTests.swift new file mode 100644 index 0000000..77a172c --- /dev/null +++ b/KanbanTests/CloseFlushCoordinatorTests.swift @@ -0,0 +1,232 @@ +import Foundation +import Testing +@testable import Kanban + +/// The close flush is an *order*, and an order is only worth stating if something checks it. 02 +/// § Windows fixes it: the board's card windows and their sessions first, then pending debounced work +/// with editor saves before the pending auto-commit, then the registry stamp, then teardown — and +/// "nothing about this is conditional". +/// +/// So every test here is an assertion about a list. The coordinator is driven with fakes that append +/// to one event log, which is the only way to see an ordering that in production is spread across +/// SwiftUI teardowns, a store's pipeline, and a JSON file. + +// MARK: - Fixtures + +@MainActor +private final class FlushLog { + private(set) var events: [String] = [] + + func record(_ event: String) { + events.append(event) + } +} + +/// A card window's session as the coordinator sees it: something that ends, once, in order. +@MainActor +private final class FakeCardSession: CardSessionFlushing { + private let name: String + private let log: FlushLog + + init(name: String, log: FlushLog) { + self.name = name + self.log = log + } + + func endSession() async { + log.record("card-session \(name)") + } +} + +/// One board's worth of seams, wired to a shared log. +/// +/// `dismissDrains` is the interesting knob: normally a dismissed card window unregisters and the +/// ref goes, which is what the coordinator waits for. Turning it off simulates a window that never +/// tears down — the case the drain deadline exists for. +@MainActor +private final class FakeBoard { + + let name: String + private let log: FlushLog + private var cardRefs: [CardWindowRef] + private var sessions: [CardWindowRef: FakeCardSession] = [:] + private let dismissDrains: Bool + private let drainDeadline: Duration + + init( + name: String, + cards: [String], + log: FlushLog, + dismissDrains: Bool = true, + drainDeadline: Duration = .seconds(2) + ) { + self.name = name + self.log = log + self.dismissDrains = dismissDrains + self.drainDeadline = drainDeadline + cardRefs = cards.map { CardWindowRef(boardPath: "/boards/\(name)", cardID: $0) } + for ref in cardRefs { + sessions[ref] = FakeCardSession(name: "\(name)/\(ref.cardID)", log: log) + } + } + + var coordinator: CloseFlushCoordinator { + CloseFlushCoordinator( + openCardRefs: { [self] in cardRefs }, + endCardSession: { [self] ref in await sessions[ref]?.endSession() }, + dismissCardWindow: { [self] ref in + log.record("dismiss \(name)/\(ref.cardID)") + guard dismissDrains else { return } + cardRefs.removeAll { $0 == ref } + }, + cardDrainDeadline: drainDeadline, + storeFlush: { [self] in log.record("store-flush \(name)") }, + editorFlush: { [self] in log.record("editor-flush \(name)") }, + committerFlush: { [self] in log.record("committer-flush \(name)") }, + recordClose: { [self] in log.record("record-close \(name)") }, + clearOpenNow: { [self] in log.record("clear-open-now \(name)") }, + tearDown: { [self] in log.record("teardown \(name)") } + ) + } +} + +// MARK: - Tests + +@MainActor +@Suite("Close flush") +struct CloseFlushCoordinatorTests { + + @Test("A user close runs every step, in the one order the design fixes") + func userCloseOrdering() async { + let log = FlushLog() + let board = FakeBoard(name: "work", cards: ["card-a", "card-b"], log: log) + + await board.coordinator.run(cause: .userClose) + + #expect(log.events == [ + // Every session ends before any window is dismissed: commits are not interleaved with + // teardowns. + "card-session work/card-a", + "card-session work/card-b", + "dismiss work/card-a", + "dismiss work/card-b", + // Pending debounced work, editor saves before the pending auto-commit. + "store-flush work", + "editor-flush work", + "committer-flush work", + // The record, then the store. + "record-close work", + "clear-open-now work", + "teardown work", + ]) + } + + @Test("Quit runs the identical sequence but leaves the open-now flag standing") + func quitDoesNotClearTheOpenNowFlag() async { + let log = FlushLog() + let board = FakeBoard(name: "work", cards: ["card-a"], log: log) + + await board.coordinator.run(cause: .quit) + + // The single difference between the two causes, and the whole restoration mechanism: the + // boards open at quit are by definition the ones the next launch reopens. + #expect(!log.events.contains("clear-open-now work")) + #expect(log.events == [ + "card-session work/card-a", + "dismiss work/card-a", + "store-flush work", + "editor-flush work", + "committer-flush work", + "record-close work", + "teardown work", + ]) + } + + @Test("A board with no card windows still runs the rest of the sequence") + func noCardWindowsIsNotASpecialCase() async { + let log = FlushLog() + let board = FakeBoard(name: "solo", cards: [], log: log) + + await board.coordinator.run(cause: .userClose) + + // "Nothing about this is conditional" — the card step is empty, not skipped-with-a-branch, + // and everything downstream is unchanged. + #expect(log.events == [ + "store-flush solo", + "editor-flush solo", + "committer-flush solo", + "record-close solo", + "clear-open-now solo", + "teardown solo", + ]) + } + + @Test("The seams that are still nil are simply absent from the sequence") + func absentSeamsAreSkipped() async { + let log = FlushLog() + let coordinator = CloseFlushCoordinator( + openCardRefs: { [] }, + endCardSession: { _ in }, + dismissCardWindow: { _ in }, + storeFlush: { log.record("store-flush") }, + recordClose: { log.record("record-close") }, + clearOpenNow: { log.record("clear-open-now") }, + tearDown: { log.record("teardown") } + ) + + // m4's real shape: no editor and no committer exist yet, and their absence must not change + // the order of anything around them. + await coordinator.run(cause: .userClose) + #expect(log.events == ["store-flush", "record-close", "clear-open-now", "teardown"]) + } + + @Test("Quitting with several boards runs each board's sequence whole, one after another") + func quitPreservesPerBoardOrdering() async { + let log = FlushLog() + let first = FakeBoard(name: "alpha", cards: ["card-a"], log: log) + let second = FakeBoard(name: "beta", cards: ["card-b"], log: log) + + // Sequential, as `AppModel.flushAllBoardsForQuit()` runs them: a board's steps are never + // interleaved with another's, so each board's ordering guarantee survives a multi-board quit. + await first.coordinator.run(cause: .quit) + await second.coordinator.run(cause: .quit) + + #expect(log.events == [ + "card-session alpha/card-a", + "dismiss alpha/card-a", + "store-flush alpha", + "editor-flush alpha", + "committer-flush alpha", + "record-close alpha", + "teardown alpha", + "card-session beta/card-b", + "dismiss beta/card-b", + "store-flush beta", + "editor-flush beta", + "committer-flush beta", + "record-close beta", + "teardown beta", + ]) + } + + @Test("A card window that never unregisters cannot wedge the close") + func theDrainIsBounded() async { + let log = FlushLog() + let board = FakeBoard( + name: "stuck", + cards: ["card-a"], + log: log, + dismissDrains: false, + drainDeadline: .milliseconds(50) + ) + + // The quit path runs inside `applicationShouldTerminate`'s deferred reply, so an unbounded + // wait here would be an app that cannot be quit. The sessions have already ended by this + // point, so expiring the deadline costs tidiness and no data. + await board.coordinator.run(cause: .quit) + + #expect(log.events.first == "card-session stuck/card-a") + #expect(log.events.last == "teardown stuck") + #expect(log.events.contains("record-close stuck")) + } +} diff --git a/KanbanTests/WindowPlacementTests.swift b/KanbanTests/WindowPlacementTests.swift new file mode 100644 index 0000000..3032627 --- /dev/null +++ b/KanbanTests/WindowPlacementTests.swift @@ -0,0 +1,56 @@ +import AppKit +import Testing +@testable import Kanban + +/// Per-board frame memory has one rule that is not "put it back where it was": a frame saved on a +/// display that is no longer attached must land somewhere visible (02-architecture.md § Windows). +/// That rule is untestable against real hardware — the interesting case *is* the monitor that is not +/// plugged in — which is why the decision takes its screens as an argument. + +@MainActor +@Suite("Window placement") +struct WindowPlacementTests { + + private let laptop = NSRect(x: 0, y: 0, width: 1512, height: 916) + private let external = NSRect(x: 1512, y: 0, width: 2560, height: 1415) + + @Test("A frame on an attached screen is restored exactly") + func aVisibleFrameIsUntouched() { + let saved = WindowFrame(x: 120, y: 80, width: 1200, height: 700) + let placed = HostedWindowController.placement(for: saved, onScreens: [laptop, external], fallback: laptop) + #expect(placed == NSRect(x: 120, y: 80, width: 1200, height: 700)) + } + + @Test("A frame straddling two screens is still where the user left it") + func aStraddlingFrameIsUntouched() { + // AppKit's own `constrainFrameRect(_:to:)` nudges the remainder into view when the frame is + // set, so intersection — not containment — is the right test: a window hanging slightly off + // an edge is a place, not a problem. + let saved = WindowFrame(x: 1400, y: 100, width: 900, height: 600) + let placed = HostedWindowController.placement(for: saved, onScreens: [laptop, external], fallback: laptop) + #expect(placed.origin.x == 1400) + } + + @Test("A frame on a screen that is gone keeps its size and centers on the fallback") + func aVanishedScreenRelocates() { + let saved = WindowFrame(x: 3000, y: 200, width: 1000, height: 600) + let placed = HostedWindowController.placement(for: saved, onScreens: [laptop], fallback: laptop) + + // Size is a preference and survives; position is a place, and the place stopped existing. + #expect(placed.size == NSSize(width: 1000, height: 600)) + #expect(placed.midX == laptop.midX) + #expect(placed.midY == laptop.midY) + #expect(laptop.intersects(placed)) + } + + @Test("With no screens at all the fallback still produces a frame") + func noScreensStillPlaces() { + // Not a real state, but the accessor's `NSScreen.screens` can be empty during a display + // reconfiguration, and returning something sane beats a window at the saved coordinates of a + // display nobody has. + let saved = WindowFrame(x: 4000, y: 4000, width: 800, height: 500) + let placed = HostedWindowController.placement(for: saved, onScreens: [], fallback: laptop) + #expect(placed.size == NSSize(width: 800, height: 500)) + #expect(laptop.contains(placed.origin)) + } +} diff --git a/KanbanTests/WindowRefsTests.swift b/KanbanTests/WindowRefsTests.swift new file mode 100644 index 0000000..eeb73a4 --- /dev/null +++ b/KanbanTests/WindowRefsTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import Kanban + +/// A window ref is two things at once — the value `WindowGroup(for:)` keys its windows on, and the +/// argument the host is built from — so it has exactly two promises: it survives the round trip +/// through a scene's archive, and two refs naming one thing are *equal*. Both are pinned here, +/// because a failure of either is invisible until the app opens a second window for something that +/// already has one. + +@Suite("Window refs") +struct WindowRefsTests { + + // MARK: Codable + + @Test("Both refs round-trip through JSON unchanged") + func refsRoundTripThroughCoding() throws { + let board = BoardWindowRef(path: "/Users/x/Boards/Work.kanban") + let card = CardWindowRef(boardPath: board.path, cardID: "55555555-5555-4555-8555-555555555555") + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + #expect(try decoder.decode(BoardWindowRef.self, from: encoder.encode(board)) == board) + + let decodedCard = try decoder.decode(CardWindowRef.self, from: encoder.encode(card)) + #expect(decodedCard == card) + #expect(decodedCard.cardID == card.cardID, "the folder's exact spelling survives, not just its value") + #expect(decodedCard.boardPath == board.path) + } + + @Test("A board ref and a URL agree in both directions") + func boardRefAndURLAgree() { + let url = URL(fileURLWithPath: "/Users/x/Boards/Work.kanban", isDirectory: true) + let ref = BoardWindowRef(url: url) + #expect(ref.path == url.path) + #expect(ref.url.path == url.path) + } + + // MARK: Identity + + @Test("A card ref's id compares by UUID value, not by spelling") + func cardRefFoldsTheCaseOfItsCardID() { + let board = "/Users/x/Boards/Work.kanban" + let lower = CardWindowRef(boardPath: board, cardID: "55555555-5555-4555-8555-555555555555") + let upper = CardWindowRef(boardPath: board, cardID: "55555555-5555-4555-8555-555555555555".uppercased()) + + // The consequence that matters: `WindowGroup(for:)` keys on this equality, so a card whose + // folder is spelled in caps focuses the window the lowercase spelling already opened instead + // of opening a second one for the same card. + #expect(lower == upper) + #expect(lower.hashValue == upper.hashValue) + #expect(Set([lower, upper]).count == 1) + + // And `rawValue` is untouched by any of that — it is what builds URLs. + #expect(upper.cardID == "55555555-5555-4555-8555-555555555555".uppercased()) + #expect(upper.cardIdentity == lower.cardIdentity) + } + + @Test("The board half of a card ref is compared verbatim") + func cardRefsOnDifferentBoardsAreDifferent() { + let card = "55555555-5555-4555-8555-555555555555" + let here = CardWindowRef(boardPath: "/Users/x/Boards/Work.kanban", cardID: card) + let there = CardWindowRef(boardPath: "/Users/x/Boards/Home.kanban", cardID: card) + + // The cross-board move rule in one assertion: the card's UUID travels with it, but the key + // that named its window does not — so the window dismisses exactly like a delete. + #expect(here != there) + #expect(here.board == BoardWindowRef(path: "/Users/x/Boards/Work.kanban")) + #expect(here.boardURL.path == "/Users/x/Boards/Work.kanban") + } + + @Test("A card ref built from an ItemID keeps the id's exact spelling") + func cardRefFromItemIDKeepsRawValue() { + let board = BoardWindowRef(path: "/Users/x/Boards/Work.kanban") + let id = ItemID(rawValue: "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA") + let ref = CardWindowRef(board: board, cardID: id) + + #expect(ref.cardID == id.rawValue) + #expect(ref == CardWindowRef(boardPath: board.path, cardID: id.rawValue.lowercased())) + } +} diff --git a/README.md b/README.md index da34e6d..f4191dc 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ The defining consequence: anything that can read and write files is a first-clas Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/). -*No UI yet — the storage foundation is in place:* - - **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and tombstone-aware snapshots — pinned by a golden fixture suite of 18 on-disk boards. - **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes are in-place tombstones with position-perfect restore and physical purge; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. +- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo; a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state. +- **Window architecture** — the three window types and their lifecycle: a welcome window (branding, failed-open reporting), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded; follows its card across lanes; dismisses on delete, tombstone, or cross-board move). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. ## Development