diff --git a/Kanban/App/AppCommands.swift b/Kanban/App/AppCommands.swift new file mode 100644 index 0000000..e58691d --- /dev/null +++ b/Kanban/App/AppCommands.swift @@ -0,0 +1,212 @@ +import AppKit +import SwiftUI +import os + +// MARK: - Focused values + +/// The frontmost board window's **identity**, published beside its store by `BoardWindowHost`. +/// +/// `FocusedBoardStoreKey` answers "which board is in front"; this answers "which *window*", which is +/// a different question and the one File ▸ Duplicate has to ask: the flush that precedes a copy is +/// keyed on the window's session, not on the store (`AppModel.flushPendingWork(for:)`). +struct FocusedBoardWindowRefKey: FocusedValueKey { + typealias Value = BoardWindowRef +} + +/// The welcome window's selected recents row — File ▸ Reveal in Finder's welcome scope. +struct FocusedWelcomeSelectionKey: FocusedValueKey { + typealias Value = WelcomeRow +} + +extension FocusedValues { + var boardWindowRef: BoardWindowRef? { + get { self[FocusedBoardWindowRefKey.self] } + set { self[FocusedBoardWindowRefKey.self] = newValue } + } + + var welcomeSelection: WelcomeRow? { + get { self[FocusedWelcomeSelectionKey.self] } + set { self[FocusedWelcomeSelectionKey.self] = newValue } + } +} + +// MARK: - New Board + +/// File ▸ New Board… (⌥⌘N) — the template chooser's entry point (11-command-nexus.md; +/// 09-templates.md). +/// +/// **⌥⌘N, not ⌘N**: ⌘N is New *Card*, which is the command a board window user reaches for a hundred +/// times a day, so the rarer creation wears the modifier. Available everywhere — a new board needs +/// no board in front, and the welcome window's own button is this item's twin. +struct NewBoardCommand: View { + + let appModel: AppModel + + var body: some View { + Button("New Board…") { + appModel.showTemplateChooser() + } + .keyboardShortcut("n", modifiers: [.option, .command]) + } +} + +// MARK: - Open Recent + +/// File ▸ Open Recent ▸ (11-command-nexus.md: "Everywhere; reads the board registry"). +/// +/// The registry, rendered as a menu — same rows as the welcome list, through the same derivation, so +/// the two can never disagree about a board's name or about whether it can be opened. An +/// unavailable board is **listed and disabled** rather than hidden, which is the recents row's own +/// posture (02 § Graceful orphaning) applied to a menu: a board that has gone missing is information, +/// and a menu that quietly shortened itself would be the app forgetting on the user's behalf. +/// +/// Clear Menu sits at the bottom, where Finder puts it. See `AppModel.clearRecents` for the +/// equivalence it rests on — the registry *is* this menu, so clearing the menu clears the registry. +struct OpenRecentMenu: View { + + let appModel: AppModel + + /// The failures are deliberately not joined in here: a menu item has no room for fail-fast's + /// specifics, and a board that failed to open is still a board the user may want to try again. + /// The failure's surface is the welcome row (02 § Launch and window lifecycle). + private var rows: [WelcomeRow] { + WelcomeRow.derive(recents: appModel.recents, failures: []).rows + } + + var body: some View { + let rows = self.rows + + Menu("Open Recent") { + ForEach(rows) { row in + Button(row.displayName) { + guard let url = row.url else { return } + appModel.openBoard(at: url) + } + .disabled(!row.canOpen) + } + + if !rows.isEmpty { + Divider() + } + + Button("Clear Menu") { + appModel.clearRecents() + } + .disabled(rows.isEmpty) + } + } +} + +// MARK: - Duplicate + +/// File ▸ Duplicate (⇧⌘S) — **the board**, never the selection (11-command-nexus.md, 03-board-ui.md +/// § Welcome screen & templates). +/// +/// ### What it does, in the order 03 fixes +/// +/// 1. **The flush first** — "The copy is preceded by the close flush ... so neither the tree nor the +/// copied history misses pending work". Not a *close*: 09-templates.md states the rule with its +/// exception attached ("sessions staying open"), and 03 is explicit that "the original stays open +/// too". `AppModel.flushPendingWork(for:)` is that step of the sequence, run on its own. +/// 2. **The copy** — `BoardDuplicator`, off the main actor so the spinner can spin. +/// 3. **The copy opens in its own board window** — "macOS Duplicate convention" — through the +/// ordinary open path, so it registers, bookmarks, and titles itself like any other board. +/// +/// ### Validation +/// +/// Board window only, so a welcome-selected recent can never be duplicated by accident — 03 says it +/// "never acts on a welcome-selected recent", and scoping the item to the focused board window is +/// how that is enforced rather than remembered. +/// +/// **Disabled under the read-only lock in every state** (03: "the flush can't run and the sibling +/// destination shares the board's fate"). It uses `acceptsBoardMutations`, which adds the +/// focused-inline-editor half of 04's rule to the lock 03 names — a deliberate reading rather than a +/// slip: an open title editor holds the one pending change no flush can reach, and a duplicate taken +/// mid-rename would be a fork missing the edit the user is in the middle of making. +struct DuplicateBoardCommand: View { + + let appModel: AppModel + + @FocusedValue(\.boardStore) private var store + @FocusedValue(\.boardWindowRef) private var ref + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "duplicate") + + var body: some View { + Button("Duplicate") { + duplicate() + } + .keyboardShortcut("s", modifiers: [.shift, .command]) + .disabled(!canDuplicate) + } + + private var canDuplicate: Bool { + guard let store, ref != nil else { return false } + return store.acceptsBoardMutations + } + + private func duplicate() { + guard canDuplicate, let store, let ref else { return } + let name = AppModel.displayName(of: store) + let source = store.rootURL + + Task { @MainActor in + // The in-progress row 02 § The banner surface names for "big-board Duplicate": info + // tone, pinned, cleared on completion, swapped for the error row on failure. + // + // No Cancel yet. 02 promises one on copy-shaped work ("remove the partial copy, nothing + // lost"), which needs a cooperatively cancellable copy and a cleanup of the partial + // destination; `beginOperation`'s `cancel` slot is where it plugs in. + let operation = store.banners.beginOperation(label: "Duplicating '\(name)'…") + defer { store.banners.endOperation(operation) } + + await appModel.flushPendingWork(for: ref) + + do { + // Off the main actor: the copy is real I/O on a board that may carry a large `.git`, + // and a spinner drawn by a blocked main thread is a still picture. See + // `BoardDuplicator` for why that is safe here. + let copy = try await Task.detached(priority: .userInitiated) { + try BoardDuplicator.duplicate(boardAt: source, titled: name) + }.value + appModel.openBoard(at: copy) + } catch let error as BoardWriteError { + Self.logger.error("duplicate failed: \(error.description, privacy: .public)") + store.banners.post(error) + } catch { + store.banners.post(BoardWriteError( + operation: .duplicateBoard(title: name), + path: source.path, + reason: .io(message: error.localizedDescription) + )) + } + } + } +} + +// MARK: - Reveal in Finder + +/// File ▸ Reveal in Finder — **the welcome scope** (11-command-nexus.md: "welcome: the selected +/// recent's folder (disabled on unavailable rows) — the context-menu entry's required twin"). +/// +/// It is here because the welcome row's context menu is: 11 files the menu-bar item as that entry's +/// *required* twin, so shipping one without the other would leave the context menu as the only path +/// to a command — the thing 04's contract forbids. +/// +// m5-context-menus, m6-card-window: the item's other two scopes. Board window — the selection's +// folder(s), or the board root with nothing selected — arrives with the board's own context menus; +// card window — the card's folder, or the selected attachment's file when the attachments section is +// focused — with the card window. Each adds a focused value and a branch here; the welcome branch +// does not move. +struct RevealInFinderCommand: View { + + @FocusedValue(\.welcomeSelection) private var selection + + var body: some View { + Button("Reveal in Finder") { + guard let url = selection?.url else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + .disabled(selection?.canReveal != true) + } +} diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index aae5ded..e046ec9 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -11,6 +11,11 @@ import os public enum WindowID { public static let welcome = "welcome" public static let restoreBootstrap = "restore-bootstrap" + /// The template chooser (09-templates.md; File ▸ New Board… ⌥⌘N). Its own window rather than a + /// sheet on welcome because ⌥⌘N is available *everywhere* (11-command-nexus.md) — including from + /// a board window, and including when welcome is not open at all, which a sheet would have to + /// conjure a host for. + public static let templateChooser = "template-chooser" public static let board = "board" public static let card = "card" } @@ -66,11 +71,11 @@ public enum AppPreferences { /// **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. +/// The join onto a recents row is `WelcomeRow.derive(recents:failures:)` — 02 § Launch and window +/// lifecycle wants the failure *on the board's row*, carrying fail-fast's specifics or the +/// unavailable state, and a failure naming no row (a first open of a folder that was never a board) +/// falls back to a list of its own. `path` is what the join matches on, which is why it is stored +/// rather than derived from the message. public struct LaunchFailure: Identifiable, Sendable, Equatable { public let id = UUID() public let path: String @@ -229,10 +234,82 @@ public final class AppModel { windowDismisser = dismiss } + // MARK: Recents + + /// The recents list, cached: what the welcome window renders and what File ▸ Open Recent lists + /// (02-architecture.md § Per-board app state — "The recents list *is* this registry sorted by + /// last-opened"). + /// + /// **Cached rather than read through on demand, and both halves of that are deliberate.** + /// `BoardRegistry` is not `@Observable`, so a view reading it directly would never learn that a + /// row was forgotten; and `recents()` resolves every record's bookmark, which is filesystem work + /// no SwiftUI body should be doing on every evaluation — the File menu's command graph is + /// rebuilt far more often than this list changes. So the list lives here as observable state and + /// every path that can change the registry refreshes it explicitly (`refreshRecents()`). + /// + /// The honest residual: a registry mutated behind this object's back would show stale until the + /// next refresh. There is no such path today — every writer goes through this type or through a + /// session it owns — and welcome refreshes on appearance as the cheap belt-and-braces. + public private(set) var recents: [RecentBoard] = [] + + /// Re-reads the registry into `recents`. Called wherever the registry changes: a board opening, + /// a board closing (the counts are stamped there), Forget, Clear Menu, and welcome appearing. + public func refreshRecents() { + recents = boardRegistry.recents() + } + + /// The welcome row's Forget (11-command-nexus.md ▸ Welcome recent) — the record, plus any launch + /// failure that row was carrying, plus the refresh, in one call so no caller can do one without + /// the others. + /// + /// **Forgetting the board forgets the failure too.** The row *is* the failure's surface (02 + /// § Launch and window lifecycle); dropping the row while keeping the failure would relocate its + /// message into the unmatched-failures list, which reads as the app declining to forget. + public func forget(boardID: UUID) { + clearLaunchFailures(naming: knownPaths(ofBoard: boardID)) + boardRegistry.forget(id: boardID) + refreshRecents() + } + + /// File ▸ Open Recent ▸ Clear Menu (11-command-nexus.md). + /// + /// **Finder clears the *menu*; here the registry is the menu**, so clearing removes every record + /// — there is no second list to clear, and a "menu" that still knew about the boards it had + /// stopped listing would be a distinction with no surface. What that costs is per-board settings + /// (window frames, push-on-commit) for boards the user reopens later, which is exactly what + /// Forget costs one row at a time and what 02's "its settings are conveniences" already accepts. + /// + /// It is Forget applied wholesale, so it clears failures the same way — the ones naming records, + /// leaving a failure that named no row (and therefore no menu entry) standing in its own list. + /// + /// A board that is open right now keeps working: its session holds a record id that no longer + /// resolves, and `BoardRegistry.update` treats an unknown id as a no-op for precisely this case. + public func clearRecents() { + clearLaunchFailures(naming: Set(recents.flatMap { recent in + [recent.record.lastKnownPath, recent.url?.path].compactMap { $0 } + })) + boardRegistry.forgetAll() + refreshRecents() + } + + /// Every path a given record is known by — the one it was last seen at and, when its bookmark + /// still resolves, where it lives now. The two can differ (a bookmark follows a move), and a + /// failure recorded before the move names the older one. + private func knownPaths(ofBoard id: UUID) -> Set { + var paths: Set = [] + if let record = boardRegistry.record(id: id) { + paths.insert(record.lastKnownPath) + } + if let url = recents.first(where: { $0.record.id == id })?.url { + paths.insert(url.path) + } + return paths + } + // MARK: Launch failures - /// Boards that failed to restore or open, newest last — the minimal welcome's one dynamic - /// section. See `LaunchFailure` for what replaces it. + /// Boards that failed to restore or open, newest last. Rendered on their own recents rows where + /// one exists, and in a fallback list where none does — `WelcomeRow.derive(recents:failures:)`. public private(set) var launchFailures: [LaunchFailure] = [] // MARK: Card-window placement @@ -266,6 +343,11 @@ public final class AppModel { /// real Application Support directory". public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) { boardRegistry = BoardRegistry(storageURL: registryStorageURL) + // Read once here rather than lazily, so File ▸ Open Recent is populated from the app's first + // menu pass — a launch that restores boards never shows welcome, and a submenu that filled + // in only after the first close would look broken. It costs one bookmark-resolution sweep at + // launch, next to the one `restorables()` already runs. + refreshRecents() } // MARK: - Opening @@ -308,6 +390,14 @@ public final class AppModel { windowOpener?(id: WindowID.welcome) } + /// File ▸ New Board… (⌥⌘N) — shows, or focuses, the template chooser (09-templates.md). + /// + /// The command opens a *chooser*, never a board: the location is the save panel's question and + /// the panel is the chooser's, so this method's whole job is the window. + public func showTemplateChooser() { + windowOpener?(id: WindowID.templateChooser) + } + /// The standard open panel behind File ▸ Open… ⌘O (11-command-nexus.md). /// /// **Validation is the open attempt itself** — there is no pre-flight check that a folder is a @@ -352,8 +442,17 @@ public final class AppModel { } /// Starts a board's session — the board window's host calls this once its load has succeeded. + /// + /// Two bookkeeping consequences of "this board is now open" ride along. The recents list is + /// re-read, because `recordOpen` just moved this board to the top of it. And any launch failure + /// naming this board is dropped: the board demonstrably opens, so a row still captioned with the + /// old error would be reporting a condition that has stopped being true. That is not the silent + /// drop 02 forbids — it forbids a failure that was never surfaced disappearing, not one the user + /// has since fixed. func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) { sessions[ref] = BoardSession(store: store, recordID: recordID, cardRefs: [], access: access) + clearLaunchFailures(naming: [ref.path, store.rootURL.path]) + refreshRecents() } /// Registers a card window with its board's session, so the close flush can find it. @@ -389,6 +488,22 @@ public final class AppModel { launchFailures.removeAll() } + /// Forgets exactly the named failures — what the unmatched-failures list's Clear dismisses, so + /// that pressing it never also erases a message still standing on a recents row the user has + /// not looked at. + public func clearLaunchFailures(ids: Set) { + launchFailures.removeAll { ids.contains($0.id) } + } + + /// Drops every failure naming one of `paths` — the resolution path, used when a board opens + /// successfully and when its record is forgotten. Paths are compared the way the welcome row's + /// join compares them, so "this row's failure" means the same thing in both places. + private func clearLaunchFailures(naming paths: Set) { + guard !paths.isEmpty else { return } + let keys = Set(paths.map(WelcomeRow.pathKey)) + launchFailures.removeAll { keys.contains(WelcomeRow.pathKey($0.path)) } + } + // MARK: - Counts /// The lane and card counts stamped into the registry at close — **live items only** (02 @@ -442,6 +557,29 @@ public final class AppModel { defer { closingBoards.remove(ref) } await coordinator(for: ref).run(cause: cause) + // The flush stamped this board's counts and (on a user close) cleared its open-now flag, so + // the cached list is now one close out of date — and welcome is often the very next thing on + // screen. + refreshRecents() + } + + /// The close flush's **pending-work step, without the teardown** — what File ▸ Duplicate runs + /// before it copies (03-board-ui.md § Welcome screen & templates: "The copy is preceded by the + /// close flush ... so neither the tree nor the copied history misses pending work"). + /// + /// **Not `closeBoard`**, and the design says so itself: 09-templates.md ▸ Save as Template states + /// the rule together with its exception — "with the pull-style mechanical exception committing an + /// open Edit session's on-disk saves as-is, **sessions staying open**". A duplicate leaves the + /// original on screen (03: "the original stays open too"), so what it needs is pending work + /// *landed on disk*, not a session ended: no card window is dismissed, no record is stamped + /// closed, nothing is torn down, and the board the user is looking at never blinks. + /// + /// It goes through `CloseFlushCoordinator` rather than calling the store directly so that the + /// order of the three flushes — store pipeline, then editor saves, then the pending auto-commit + /// (02's own order) — keeps having exactly one definition. + public func flushPendingWork(for ref: BoardWindowRef) async { + guard sessions[ref] != nil else { return } + await coordinator(for: ref).flushPendingWork() } /// Quit: the same sequence, once per open board, **sequentially**. diff --git a/Kanban/App/BoardDuplicator.swift b/Kanban/App/BoardDuplicator.swift new file mode 100644 index 0000000..621c7e6 --- /dev/null +++ b/Kanban/App/BoardDuplicator.swift @@ -0,0 +1,96 @@ +import Foundation + +/// File ▸ Duplicate (⇧⌘S) — the copy itself (03-board-ui.md § Welcome screen & templates). +/// +/// ### A literal tree copy, and every one of its exclusions is deliberate +/// +/// - **Every GUID is kept.** A whole-board copy is 01-storage-format.md's explicit carve-out from +/// the copies-remint rule: "the remint rule governs *item-level* copies landing inside an existing +/// board, where identities could collide; a whole-board copy is a new namespace, and Duplicate's +/// fork-keeps-history guarantee requires it (copied `.git` history must keep naming the paths it +/// describes)". +/// - **Tombstoned items are carried too** (03, settled): "Duplicate is a full fork, trash included — +/// dropping them would leave the copy's working tree disagreeing with its own copied HEAD". This +/// file does nothing to achieve that: a tombstone is a `deleted:` key inside a file, so a copy +/// carries it by declining to be clever. +/// - **`.git` comes along** — a duplicate of a git board is a fork of its history — with only its +/// remote configuration stripped, which is m7's. +/// - Timestamps, unknown keys, strays, `CLAUDE.user.md`, attachments: verbatim, for the same reason. +/// **Nothing here reads a board file at all.** +/// +/// `FileManager.copyItem` rather than a walk through `BoardWriter.copyItem`: the Writer's copy path +/// exists to remint identities and restamp frontmatter at an import boundary, and this operation is +/// defined by doing neither of those things. +/// +/// ### Not `@MainActor` +/// +/// Duplicating a board with a year of `.git` behind it is real I/O, and it runs while the original's +/// window stays open with an in-progress banner row spinning (02-architecture.md § The banner +/// surface names "big-board Duplicate" as an example). A spinner on a blocked main thread is a +/// frozen picture, so the caller runs this off the main actor. That is safe by construction: it +/// touches only its two URLs, and the board's security-scoped access is a process-wide grant the +/// session holds open for the window's whole life, not a per-thread one. +enum BoardDuplicator { + + /// The Finder-style destination for duplicating `rootURL`: `"Board copy"`, then `"Board copy 2"`, + /// `"Board copy 3"`, … — Finder's own ladder, counting up from 2 against what is on disk at + /// decision time, one collision at a time. + /// + /// **A sibling**, per 03 ("a Finder-style 'copy' sibling"), so a board found in a folder full of + /// boards produces its duplicate where the user is already looking. + /// + /// The extension rides on the end (`Board.kanban` → `Board copy.kanban`) and an extension-less + /// board folder simply has none to carry (`Board` → `Board copy`) — both are shapes a board is + /// allowed to be (01-storage-format.md § Document packaging, "Extension-less board folders still + /// open"), and both are what `URL`'s own splitting produces, which is also how the Writer's + /// attachment-collision helper spells the same idea. + /// + /// `fileExists` is the one test, and it is true for a file as much as a folder: anything already + /// wearing the name blocks it, which is what keeps a duplicate from ever overwriting something. + static func copyDestination(for rootURL: URL) -> URL { + let parent = rootURL.deletingLastPathComponent() + let base = rootURL.deletingPathExtension().lastPathComponent + let ext = rootURL.pathExtension + + func candidate(_ name: String) -> URL { + parent.appendingPathComponent(ext.isEmpty ? name : "\(name).\(ext)", isDirectory: true) + } + + var name = "\(base) copy" + var counter = 2 + while FileManager.default.fileExists(atPath: candidate(name).path) { + name = "\(base) copy \(counter)" + counter += 1 + } + return candidate(name) + } + + /// Copies the board at `rootURL` to its Finder-style sibling and answers where it landed. + /// + /// `title` is the board's display name, carried only so a failure can name the board the user + /// pressed Duplicate on — this function never reads it off disk, which is the whole point. + /// + /// The failure is a `BoardWriteError` like every other write in the app, so the board window's + /// banner renders it in the vocabulary it already speaks. A copy that fails part-way leaves a + /// partial folder behind; `FileManager` cleans up its own destination on most failures, and the + /// residue that survives is a folder the user can see and delete — the honest outcome, and the + /// one 02's "remove the partial copy" Cancel affordance would formalise when it lands. + static func duplicate(boardAt rootURL: URL, titled title: String?) throws(BoardWriteError) -> URL { + let destination = copyDestination(for: rootURL) + do { + try FileManager.default.copyItem(at: rootURL, to: destination) + } catch { + throw BoardWriteError( + operation: .duplicateBoard(title: title), + path: destination.path, + reason: .io(message: error.localizedDescription) + ) + } + // m7-git: strip the copy's remote configuration — "the duplicate keeps `.git` but has its + // remote configuration stripped ... it must not silently push into the original's remote" + // (03-board-ui.md). Remotes only: the repo-local `user.name`/`user.email` survives, so the + // fork keeps its commit identity (06-history-undo.md's identity home). Push-on-commit needs + // nothing here — it lives on the registry record, and the copy's record is born fresh. + return destination + } +} diff --git a/Kanban/App/BoardTemplate.swift b/Kanban/App/BoardTemplate.swift new file mode 100644 index 0000000..d1fdd2c --- /dev/null +++ b/Kanban/App/BoardTemplate.swift @@ -0,0 +1,89 @@ +import Foundation + +/// A board template — what File ▸ New Board… (⌥⌘N) instantiates (09-templates.md). +/// +/// ### One template today, and that is the shape of this card, not a shortcut +/// +/// 09 settles both the inventory (all ten pathfinder templates carry over) and the definition +/// format, and the format is the interesting part: **a template is itself a board** — a schema-valid +/// board folder in the app's resources, read by the same `BoardLoader`, its `index.md` supplying the +/// display name (`title`), the picker blurb (the body), the icon, and the chooser position +/// (`template.order`). None of that exists yet. What this card ships is the *entry point*: the +/// chooser window, the save panel, and a real path from ⌥⌘N to an open board, with exactly one +/// template behind it so that path is exercised rather than described. +/// +// m9-templates: the inventory becomes a walk of `/Templates/*.kanban` plus the user store in +// Application Support, each folder loaded through `BoardLoader` — `name`/`blurb`/`icon` off the +// template board's own `index.md`, order off its `template.order`, an unloadable user template still +// listed (by folder name, marked unloadable, carrying the loader's specifics) but not instantiable. +// `laneTitles` stops existing at that point: instantiation becomes a tree copy that skips tombstones, +// mints fresh GUIDs, and stamps `created`/`modified` fresh (`BoardWriter.CopyStamps.born`), never +// copying `.git`. The chooser's mini preview renders from the loaded `BoardModel` rather than from +// these strings. +struct BoardTemplate: Identifiable, Sendable, Equatable { + + /// The bundle folder name a real template would have (`basic.kanban` → `basic`) — 09 calls it + /// "the template's stable slug (tests, a11y ids)", so it is the identity here too. + let slug: String + + /// The chooser's display name — a real template's `title`. + let name: String + + /// The chooser's blurb — a real template's `index.md` body, which also becomes the new board's + /// description. Nothing is written from it yet: this card creates lanes, not board bodies. + let blurb: String + + /// The board icon shown in the picker and inherited by the new board. + let icon: String + + /// The lanes to create, in order. + let laneTitles: [String] + + var id: String { slug } + + /// The plain scaffold, and the one template that exists. + /// + // m9-templates: the bundled `basic.kanban` is 09's "plain To Do / Done scaffold" — two lanes, + // not these three. The third is here because a chooser preview with two lanes reads as a mistake + // and because this stub's whole job is to prove the create path; when the bundled template + // arrives it replaces this value wholesale and 09's inventory is the only source. + static let basic = BoardTemplate( + slug: "basic", + name: "Basic", + blurb: "Three lanes to move work through.", + icon: ItemSymbol.board, + laneTitles: ["To Do", "Doing", "Done"] + ) + + /// Every template the chooser offers, in chooser order. + static let all: [BoardTemplate] = [.basic] + + // MARK: - Instantiation + + /// Writes this template to `rootURL`: the board's `index.md`, then one lane per title, in order. + /// + /// **The board's title is the document name the user chose**, not the template's — 09 + /// ▸ Instantiation says so, and 01-storage-format.md § Board naming is the reason: display name + /// and folder name start out matching, so a board called "Roadmap" on disk is called "Roadmap" in + /// its window title. An extension-less name is as legal a board as a `.kanban` one, so the + /// extension is stripped rather than required. + /// + /// Lanes land at `1024`, `2048`, `3072` without this function saying so: each `createLane` call + /// appends after the visible siblings the previous one left behind (`Ranks.append(toVisible:)`), + /// which is what makes the array's order the board's order. + /// + /// Separated from the panel and from the window flow deliberately — this is the whole of what + /// "instantiate a template" means on disk, and a test drives it against a temp folder without + /// going anywhere near `NSSavePanel`. + func instantiate(at rootURL: URL) throws(BoardWriteError) { + try BoardWriter.createBoard(at: rootURL, title: Self.documentName(of: rootURL)) + for title in laneTitles { + _ = try BoardWriter.createLane(inBoard: rootURL, title: title) + } + } + + /// The document name behind a chosen URL — `~/Boards/Roadmap.kanban` → `Roadmap`. + static func documentName(of rootURL: URL) -> String { + rootURL.deletingPathExtension().lastPathComponent + } +} diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index ca61650..7bfad2f 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -95,6 +95,9 @@ struct BoardWindowHost: View { // beside it the window's own popover flag, which is what File ▸ Board Info toggles, and // its purge-alert host, which the trash's two confirmed commands raise. .focusedSceneValue(\.boardStore, store) + // The window's identity beside its store — File ▸ Duplicate flushes a *session*, which + // is keyed on the window rather than on the board it is showing. + .focusedSceneValue(\.boardWindowRef, ref) .focusedSceneValue(\.boardInfo, boardInfo) .focusedSceneValue(\.trashConfirmations, trashConfirmations) } diff --git a/Kanban/App/CloseFlushCoordinator.swift b/Kanban/App/CloseFlushCoordinator.swift index 75429c1..2cef136 100644 --- a/Kanban/App/CloseFlushCoordinator.swift +++ b/Kanban/App/CloseFlushCoordinator.swift @@ -198,7 +198,13 @@ public struct CloseFlushCoordinator { /// Step 2. The store's pipeline, then the editor saves, then the pending commit — 02's order, /// stated once. - private func flushPendingWork() async { + /// + /// **Callable on its own**, which is the one place the sequence is entered part-way: File ▸ + /// Duplicate needs pending work on disk before it copies but must leave the session standing + /// (09-templates.md ▸ Save as Template's stated exception — "sessions staying open"). It reaches + /// this step through `AppModel.flushPendingWork(for:)` rather than re-listing the three flushes, + /// so their order still has one definition. + public func flushPendingWork() async { await storeFlush() await editorFlush?() await committerFlush?() diff --git a/Kanban/App/TemplateChooserView.swift b/Kanban/App/TemplateChooserView.swift new file mode 100644 index 0000000..ccf126e --- /dev/null +++ b/Kanban/App/TemplateChooserView.swift @@ -0,0 +1,232 @@ +import AppKit +import SwiftUI +import os + +/// The template chooser — File ▸ New Board… (⌥⌘N), 09-templates.md's picker. +/// +/// ### Pages' shape, one card in it +/// +/// A grid of template cards, each showing a **mini per-lane preview** above its name, one selected +/// at a time, with Cancel and Choose at the bottom (03-board-ui.md § Welcome screen & templates: "a +/// Pages-style chooser with a mini per-lane preview per template"). The grid holds exactly one card +/// today because exactly one template exists (`BoardTemplate`); everything about the layout is +/// already the plural case, so the m9 inventory drops in without the surface changing shape. +/// +/// ### Choosing is three steps, and the middle one is a save panel +/// +/// Choose runs `NSSavePanel`, instantiates into the chosen location, and opens the result. The panel +/// is not ceremony: a sandboxed app cannot write anywhere the user has not pointed at, so the panel +/// *is* how a new board gets a location it is allowed to occupy (09 files this under "seed the save +/// panel's suggested name"). The chooser stays open if the panel is cancelled — a cancelled location +/// is not a cancelled choice. +/// +/// ### Failure is an alert here, deliberately +/// +/// Everywhere else in the app a failed write is a banner in the window that produced it +/// (02-architecture.md § Write-failure surfacing). A failed *create* has no such window: the board +/// that would host the banner is the one that did not get created, welcome may not be open (⌥⌘N +/// works from a board window), and filing it under `AppModel.launchFailures` would put it in a +/// recents-adjacent list belonging to boards the registry knows — which this one, having never +/// existed, is not. So it is an `NSAlert`, continuing the modal conversation the user is already in +/// with the save panel, in the same sentence the banner would have used +/// (`BannerCenter.headline(for:)`) so the app has one vocabulary for a failed write rather than two. +struct TemplateChooserView: View { + + @Environment(AppModel.self) private var appModel + @Environment(\.dismiss) private var dismiss + + @State private var selection: BoardTemplate.ID = BoardTemplate.basic.id + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates") + + private var selected: BoardTemplate? { + BoardTemplate.all.first { $0.id == selection } + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + grid + Divider() + footer + } + .frame(width: 620, height: 460) + } + + // MARK: Header + + private var header: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Choose a Template") + .font(.title3.weight(.semibold)) + Text("Every template is an ordinary board — lanes and cards you can change afterwards.") + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + } + + // MARK: Grid + + private var grid: some View { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 170), spacing: 20)], spacing: 20) { + ForEach(BoardTemplate.all) { template in + TemplateCard(template: template, isSelected: template.id == selection) + .onTapGesture { selection = template.id } + // The list convention welcome's recents use, for the same reason: a + // double click is how a chooser is answered without reaching for a button. + .onTapGesture(count: 2) { choose() } + .accessibilityAddTraits(template.id == selection ? [.isSelected] : []) + } + } + .padding(20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color(nsColor: .controlBackgroundColor)) + } + + // MARK: Footer + + private var footer: some View { + HStack(alignment: .firstTextBaseline) { + Text(selected?.blurb ?? "") + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + + Spacer(minLength: 16) + + Button("Cancel", role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + + Button("Choose") { choose() } + .keyboardShortcut(.defaultAction) + .disabled(selected == nil) + } + .padding(20) + } + + // MARK: - Choosing + + /// Panel, instantiate, open — and only then dismiss, so a cancelled panel leaves the chooser + /// exactly as the user left it. + private func choose() { + guard let template = selected, let url = Self.chooseLocation(for: template) else { return } + + do { + try template.instantiate(at: url) + } catch { + Self.logger.error("template instantiation failed: \(error.description, privacy: .public)") + Self.present(error) + return + } + + dismiss() + // The ordinary open path, so the new board joins recents, gets its bookmark, and closes + // welcome on the way in exactly like a board opened from a row. + appModel.openBoard(at: url) + } + + /// The save panel — where the board goes and what it is called. + /// + /// `"Untitled.kanban"` is the suggestion; the package extension is visible and editable, because + /// an extension-less board folder is equally legal (01-storage-format.md § Document packaging) + /// and deleting the suffix should therefore work rather than be silently undone. + /// + // m9-templates: 09 ▸ Instantiation seeds this name from the template's own title once templates + // have titles of their own ("Basic.kanban", "Bug Tracker.kanban"). With one stub template a + // suggestion of "Basic" would name the *template*, not the user's board, which is worse than + // Untitled. + /// + /// A name that already exists gets the panel's own replace prompt; agreeing to it does not delete + /// anything (the panel never does), so `BoardWriter.createBoard`'s refusal to clobber an existing + /// board is what the user sees — as an alert, naming the path. That is the honest outcome: this + /// flow is a *create*, and quietly replacing a board with an empty one is not a thing it should + /// be able to do. + private static func chooseLocation(for template: BoardTemplate) -> URL? { + let panel = NSSavePanel() + panel.nameFieldStringValue = "Untitled.kanban" + panel.canCreateDirectories = true + panel.isExtensionHidden = false + panel.allowsOtherFileTypes = true + panel.prompt = "Create" + panel.message = "Choose where to keep the new board." + + guard panel.runModal() == .OK, let url = panel.url else { return nil } + return url + } + + private static func present(_ error: BoardWriteError) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = BannerCenter.headline(for: error) + alert.informativeText = error.path + alert.addButton(withTitle: "OK") + alert.runModal() + } +} + +// MARK: - Template card + +/// One template in the grid: its mini per-lane preview, its name, and the selection ring. +private struct TemplateCard: View { + + let template: BoardTemplate + let isSelected: Bool + + var body: some View { + VStack(spacing: 8) { + TemplatePreview(template: template) + .frame(height: 96) + .background(RoundedRectangle(cornerRadius: 8).fill(Color(nsColor: .textBackgroundColor))) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(isSelected ? Color.accentColor : Color(nsColor: .separatorColor), + lineWidth: isSelected ? 3 : 1) + ) + + Label(template.name, systemImage: template.icon) + .font(.callout) + .labelStyle(.titleAndIcon) + } + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel(template.name) + .accessibilityHint(template.blurb) + } +} + +/// The mini per-lane preview: one column per lane, each a title bar over a couple of card shapes. +/// +/// Deliberately abstract — no text, because the point is the *shape* of the board and legible lane +/// names at this size are not available. It renders from `laneTitles` only for the count and the +/// stable identity of each column. +private struct TemplatePreview: View { + + let template: BoardTemplate + + var body: some View { + HStack(alignment: .top, spacing: 6) { + ForEach(Array(template.laneTitles.enumerated()), id: \.offset) { index, _ in + VStack(spacing: 4) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.accentColor.opacity(0.65)) + .frame(height: 5) + // A descending number of cards, so the preview reads as work in flight rather + // than as three identical columns. + ForEach(0.. A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome +/// > appears alongside whatever did restore, the failed board's recents row carrying fail-fast's +/// > specifics (load error) or the unavailable state per Graceful orphaning. Other restorations +/// > proceed unaffected — never a launch-time modal chain, never a silent drop. +/// +/// That is a rule about *data*: which of three captions a row wears, which of its actions are live, +/// and — the clause a list of rows cannot express by itself — what happens to a failure that matches +/// no row at all. Derived here, every one of those cases is stateable in a test without a window, +/// which is the only way the "never a silent drop" half is checkable at all. +/// +/// The same derivation feeds File ▸ Open Recent, so the submenu and the list can never disagree +/// about a board's name or about whether it can be opened. +struct WelcomeRow: Identifiable, Equatable { + + /// The registry record's id — the row's identity, the selection's value, and what Forget names. + let id: UUID + + /// The board's name: the record's cached `displayName`, falling back to the folder name for a + /// record that somehow carries none (01-storage-format.md § Board naming's fallback, applied to + /// the cached string rather than to a board this window must never open). + let displayName: String + + /// Where the board is **now**, or `nil` when its bookmark no longer resolves. The single source + /// of the row's availability: Open and Reveal need a URL, and an orphan has none. + let url: URL? + + /// The containing folder, for the row's location line — Xcode's welcome shows where a project + /// lives, not its own path repeated under its name. Home-abbreviated where it can be. + let location: String + + /// The counts stamped at last close, `nil` until a close has stamped them + /// (02 § Per-board app state: registry-cached, never a directory scan at welcome time). + let laneCount: Int? + let cardCount: Int? + + /// The message of the launch failure naming this board, if one does. + let failure: String? + + var isAvailable: Bool { url != nil } + + /// Open and Reveal in Finder both need somewhere to go; Forget is deliberately not here, because + /// it is enabled on every row — an orphan the user can never open is exactly the row that most + /// needs erasing (02 § Graceful orphaning: "recents surface it as unavailable with Forget"). + var canOpen: Bool { isAvailable } + var canReveal: Bool { isAvailable } + + /// The row's third line — one line, so the three states are alternatives rather than a stack. + /// + /// The precedence is 02's sentence read in order: a failure is what the row is *for* at that + /// moment and outranks both the orphan state (which the failure message already describes in + /// better words) and the counts (facts about a board the user cannot currently get into). + enum Caption: Equatable { + /// The ordinary row: "3 lanes · 12 cards", or an em dash where nothing has been stamped. + case counts(lanes: Int?, cards: Int?) + /// The bookmark no longer resolves (02 § Graceful orphaning). + case unavailable + /// Fail-fast's specifics, from the open or restore that failed. + case failed(String) + } + + var caption: Caption { + if let failure { return .failed(failure) } + if url == nil { return .unavailable } + return .counts(lanes: laneCount, cards: cardCount) + } + + /// "3 lanes · 12 cards" — or "—" when the record has never been closed and so carries nothing. + /// + /// A single em dash rather than "0 lanes · 0 cards": an unstamped record knows nothing about the + /// board's size, and zero is a claim. + var countsSummary: String { + guard let laneCount, let cardCount else { return "—" } + let lanes = "\(laneCount) lane\(laneCount == 1 ? "" : "s")" + let cards = "\(cardCount) card\(cardCount == 1 ? "" : "s")" + return "\(lanes) · \(cards)" + } + + // MARK: - Derivation + + /// The rows, and the failures no row could carry. + struct Derivation: Equatable { + var rows: [WelcomeRow] + + /// Failures naming no record — a first open of a folder that turned out not to be a board, + /// which fails before anything is registered and so has no row to render on. They keep a + /// list of their own on welcome, because the alternative is the silent drop 02 rules out. + var unmatched: [LaunchFailure] + } + + /// Joins the recents list with the launch failures, in `recents`' order (which is the registry's + /// `lastOpened` descending — this function never re-sorts, so the sort rule keeps living in + /// exactly one place). + /// + /// **Matching is by path, and by both of a record's paths.** A record knows where it was last + /// seen (`lastKnownPath`) and, when its bookmark resolves, where it lives now; a bookmark follows + /// a move, so a failure recorded before one names the older spelling. Paths are standardized + /// before comparison — `/tmp/b/../b` and `/tmp/b` are one board — but never resolved through + /// symlinks: that would be a filesystem round trip per row, which is the cost the registry's + /// whole design is built to avoid at welcome time. + /// + /// **The newest failure wins a row's caption** when several name it (a board that failed, was + /// retried, and failed again), because it is the one describing the state the file is in now. + /// All of them are consumed either way — a row carries one message, and the older attempts must + /// not resurface in the unmatched list as if nothing had shown them. + static func derive(recents: [RecentBoard], failures: [LaunchFailure]) -> Derivation { + var claimed: Set = [] + + let rows = recents.map { recent -> WelcomeRow in + let record = recent.record + var keys: Set = [pathKey(record.lastKnownPath)] + if let url = recent.url { + keys.insert(pathKey(url.path)) + } + + let matches = failures.filter { keys.contains(pathKey($0.path)) } + claimed.formUnion(matches.map(\.id)) + + return WelcomeRow( + id: record.id, + displayName: record.displayName.isEmpty + ? URL(fileURLWithPath: record.lastKnownPath).deletingPathExtension().lastPathComponent + : record.displayName, + url: recent.url, + location: location(of: recent.url?.path ?? record.lastKnownPath), + laneCount: record.laneCount, + cardCount: record.cardCount, + failure: matches.last?.message + ) + } + + return Derivation(rows: rows, unmatched: failures.filter { !claimed.contains($0.id) }) + } + + /// How two paths are compared for "the same board" on this screen. Standardized only — see + /// `derive` for why nothing here touches the filesystem. + static func pathKey(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.path + } + + /// The board's containing folder, with the user's home written as `~`. + static func location(of path: String) -> String { + let parent = URL(fileURLWithPath: path).deletingLastPathComponent().path + guard let home = realHomeDirectory, parent == home || parent.hasPrefix(home + "/") else { + return parent + } + return "~" + parent.dropFirst(home.count) + } + + /// The user's **real** home directory. + /// + /// `NSHomeDirectory()` and `FileManager.homeDirectoryForCurrentUser` both answer with the sandbox + /// container, which no board is ever inside — abbreviating against either would never once fire. + /// The password database is where the real path still lives, and this is display text only: + /// nothing is opened, resolved, or written relative to it, so being wrong costs a longer row. + private static let realHomeDirectory: String? = { + guard let entry = getpwuid(getuid()), let directory = entry.pointee.pw_dir else { return nil } + return String(cString: directory) + }() +} diff --git a/Kanban/App/WelcomeView.swift b/Kanban/App/WelcomeView.swift index ad08010..0f62c78 100644 --- a/Kanban/App/WelcomeView.swift +++ b/Kanban/App/WelcomeView.swift @@ -1,25 +1,51 @@ import AppKit import SwiftUI -/// The welcome window (02-architecture.md § Windows). +/// The welcome window (02-architecture.md § Windows; 03-board-ui.md § Welcome screen & templates). /// -/// ### What this is, and what it is not yet +/// ### Xcode's shape, which 03 says carries over unchanged /// -/// 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. +/// > Welcome: resizable, no title bar (background drag); recents list with board icon, name, +/// > location, counts; single click selects, double click opens; context menu Open / Reveal in +/// > Finder / Forget. /// -/// 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. +/// Branding and the two create/open actions on the left, recents on the right. Resizable — the +/// recents list gets whatever space the user grants it — and title-bar-less, with the background as +/// the drag surface (the gesture is attached at low priority, so a row or a button always wins). +/// +/// ### The rows are derived, not assembled here +/// +/// Everything a row *says* — which caption it wears, whether Open and Reveal are live, and which +/// failures had no row to land on — is `WelcomeRow.derive(recents:failures:)`, a pure function over +/// the registry's recents and `AppModel.launchFailures`. That is where 02's row-level failure rule +/// lives, and it is why the rule is testable. This file renders the answer. +/// +/// ### What it never does +/// +/// It never opens a board's `index.md` — not for a title, not for a count, not for an icon. Counts +/// and names come from the registry record, stamped at last close: "no directory scan at welcome +/// time (which would be slow or hang on big/unavailable boards)" (02 § Per-board app state). The +/// pathfinder loaded every board to build this list; that is the one thing about it that does not +/// carry over. struct WelcomeView: View { @Environment(AppModel.self) private var appModel + /// The selected row's record id. A row's identity is its record, so a Forget leaves this + /// pointing at nothing, which reads as "no selection" without any cleanup of its own. + @State private var selection: UUID? + + /// Whether the recents list holds the keyboard, so Return can mean "open the selected row". + @FocusState private var listFocused: Bool + + private var derivation: WelcomeRow.Derivation { + WelcomeRow.derive(recents: appModel.recents, failures: appModel.launchFailures) + } + + private var selectedRow: WelcomeRow? { + derivation.rows.first { $0.id == selection } + } + var body: some View { HStack(spacing: 0) { branding @@ -29,14 +55,19 @@ struct WelcomeView: View { Divider() - failures - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding(32) + recents + .frame(maxWidth: .infinity, maxHeight: .infinity) } - // 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) + .frame(minWidth: 760, minHeight: 460) + // The window has no title bar, so the background is the drag handle. `.gesture` rather than + // `.highPriorityGesture`: a click on a row or a button belongs to the row or the button. + .gesture(WindowDragGesture()) + // Belt and braces over the explicit refreshes `AppModel` runs on every registry mutation: + // welcome is the one surface that can appear long after the last thing that changed the list. + .onAppear { appModel.refreshRecents() } + // What File ▸ Reveal in Finder acts on in this window's scope (11-command-nexus.md: "welcome: + // the selected recent's folder (disabled on unavailable rows)"). + .focusedSceneValue(\.welcomeSelection, selectedRow) } // MARK: Branding and actions @@ -56,12 +87,24 @@ struct WelcomeView: View { .font(.callout) .foregroundStyle(.secondary) + Text("Folders and Markdown, on your terms.") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.top, 4) + Spacer(minLength: 24) - Button("Open Board…") { - appModel.presentOpenPanel() + VStack(spacing: 8) { + // The menu-bar twin of this button is File ▸ New Board… (⌥⌘N) — same action, and + // deliberately the same words, because a button and a menu item that differ read as + // two features. + WelcomeActionButton(title: "New Board…", systemImage: "plus.square") { + appModel.showTemplateChooser() + } + WelcomeActionButton(title: "Open Board…", systemImage: "folder") { + appModel.presentOpenPanel() + } } - .controlSize(.large) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -73,47 +116,212 @@ struct WelcomeView: View { return "Version \(short) (\(build))" } - // MARK: Failed opens + // MARK: Recents @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.") + private var recents: some View { + let derivation = self.derivation + + VStack(spacing: 0) { + if derivation.rows.isEmpty { + emptyHint + } else { + list(derivation.rows) + } + + if !derivation.unmatched.isEmpty { + Divider() + unmatchedFailures(derivation.unmatched) + } + } + .background(Color(nsColor: .controlBackgroundColor)) + } + + private var emptyHint: some View { + VStack(spacing: 6) { + Image(systemName: "clock") + .font(.title) + .foregroundStyle(.tertiary) + Text("No Recent Boards") + .font(.headline) + .foregroundStyle(.secondary) + Text("Boards you create or open appear here.") + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func list(_ rows: [WelcomeRow]) -> some View { + List(rows, selection: $selection) { row in + RecentBoardRow(row: row) + // Single click selects (the `List` does that); the second click of a double click + // opens. `simultaneousGesture` rather than `onTapGesture` so the list's own + // selection handling still sees the first click. + .simultaneousGesture(TapGesture(count: 2).onEnded { open(row) }) + .contextMenu { + Button("Open") { open(row) } + .disabled(!row.canOpen) + Button("Reveal in Finder") { reveal(row) } + .disabled(!row.canReveal) + Divider() + // Always enabled, on every row: an orphan the user can never open again is + // exactly the row that most needs erasing (02 § Graceful orphaning). + Button("Forget") { appModel.forget(boardID: row.id) } + } + } + .listStyle(.inset) + .scrollContentBackground(.hidden) + .focused($listFocused) + // Return on a selected row opens it — the list convention, and the reason the list takes + // focus on a click rather than only on Tab. + .onKeyPress(.return) { + guard let selectedRow, selectedRow.canOpen else { return .ignored } + open(selectedRow) + return .handled + } + .onTapGesture { listFocused = true } + } + + /// The failures no recents row could carry — a first open of a folder that turned out not to be + /// a board fails before anything is registered, so there is no row for it to land on. A list of + /// their own, because the alternative is the silent drop 02 rules out. + private func unmatchedFailures(_ failures: [LaunchFailure]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Couldn't Open") + .font(.subheadline.weight(.semibold)) + + ForEach(failures) { failure in + VStack(alignment: .leading, spacing: 1) { + Text(failure.displayName) + .font(.callout) + Text(failure.message) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Text(failure.path) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Clears exactly the failures listed here, never the ones standing on rows above: those + // are still describing a board the user can see, and one button quietly erasing both + // lists would be the drop 02 forbids wearing a different hat. + Button("Clear") { + appModel.clearLaunchFailures(ids: Set(failures.map(\.id))) + } + .controlSize(.small) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: Actions + + /// Opens a row's board. Welcome closes itself on the way in — that is the board window host's + /// job ("Opening a board from welcome closes welcome"), not this view's, because the close has to + /// wait for the load to actually succeed. + private func open(_ row: WelcomeRow) { + guard let url = row.url else { return } + appModel.openBoard(at: url) + } + + private func reveal(_ row: WelcomeRow) { + guard let url = row.url else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } +} + +// MARK: - Pieces + +/// A full-width, leading-aligned action button — Xcode's welcome column. +private struct WelcomeActionButton: View { + + let title: String + let systemImage: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(title, systemImage: systemImage) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 2) + } + .buttonStyle(.bordered) + .controlSize(.large) + } +} + +/// One recents row: icon, name, location, and the one caption line carrying whichever of the three +/// things the row has to say (`WelcomeRow.Caption`). +private struct RecentBoardRow: View { + + let row: WelcomeRow + + var body: some View { + HStack(spacing: 12) { + // The board default symbol, on every row. + // + // The record carries no icon. 02 § Per-board app state settles that it should — "the + // row's title and icon are registry-cached too — with live write-through" — and today it + // holds only the display name and the counts. An `icon`/`iconColor` stamp joining + // `recordClose` (and the store's reload path, which is where the write-through half + // lives) is what turns this into the board's own glyph; until then a row that guessed + // would be worse than one that is honestly generic. + Image(systemName: ItemSymbol.board) + .font(.system(size: 22)) + .foregroundStyle(.secondary) + .frame(width: 34, height: 34) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 1) { + Text(row.displayName) + .font(.headline) + .lineLimit(1) + + Text(row.location) + .font(.caption) .foregroundStyle(.secondary) - } - } else { - VStack(alignment: .leading, spacing: 12) { - Text("Couldn't open") - .font(.title3) + .lineLimit(1) + .truncationMode(.middle) - 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() - } + caption } + + Spacer(minLength: 0) + } + .padding(.vertical, 4) + // Dimmed when the board cannot be reached — the row stays, with Forget, rather than + // disappearing (02 § Graceful orphaning). + .opacity(row.isAvailable ? 1 : 0.55) + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var caption: some View { + switch row.caption { + case .counts: + Text(row.countsSummary) + .font(.caption) + .foregroundStyle(.tertiary) + case .unavailable: + Label("Unavailable — moved, deleted, or on a volume that isn't mounted", + systemImage: "questionmark.folder") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + case let .failed(message): + // The warning tint, and the whole of fail-fast's specifics — this row *is* the failure + // surface (02 § Launch and window lifecycle). + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 3b24ac2..6044c4e 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -55,11 +55,30 @@ struct KanbanApp: App { } .defaultLaunchBehavior(shouldRestoreAtLaunch ? .suppressed : .automatic) .restorationBehavior(.disabled) - .windowResizability(.contentSize) + // "Welcome: resizable, no title bar (background drag)" (03-board-ui.md § Welcome screen & + // templates). `.contentMinSize` rather than `.contentSize`, because the view states a + // *minimum* and the recents list is meant to take whatever space the user gives it; the + // hidden title bar is why `WelcomeView` carries a `WindowDragGesture`. + .windowResizability(.contentMinSize) + .defaultSize(width: 820, height: 500) + .windowStyle(.hiddenTitleBar) // 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() + // The template chooser (09-templates.md), reached from File ▸ New Board… ⌥⌘N and from + // welcome's own button. A `Window` for welcome's reason — there is one of it, ever — and + // suppressed at launch, since ⌥⌘N is the only thing that ever asks for it. + Window("New Board", id: WindowID.templateChooser) { + TemplateChooserView() + .environment(appModel) + .captureWindowActions(into: appModel) + } + .defaultLaunchBehavior(.suppressed) + .restorationBehavior(.disabled) + .windowResizability(.contentSize) + .commandsRemoved() + Window("", id: WindowID.restoreBootstrap) { RestoreBootstrapView() .environment(appModel) @@ -107,13 +126,15 @@ struct KanbanApp: App { /// changing one silently breaks every remap of it. @CommandsBuilder private var menuCommands: some Commands { - // The File group, in 11-command-nexus.md's own row order: New Card, New Lane, (New Board…, - // still owed), Open…, (Open Recent, still owed), Board Info, (Duplicate / Save as Template / - // Reveal in Finder, still owed), then the trash trio and Empty Trash…. Every one of them - // validates against the frontmost board through the focus system, so each is simply - // absent-of-effect when no board is in front. + // The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board…, + // Open…, Open Recent ▸, Board Info, Duplicate, (Save as Template, still owed — 09), + // Reveal in Finder, then the trash trio and Empty Trash…. The board-scoped ones validate + // against the frontmost board through the focus system, so each is simply absent-of-effect + // when no board is in front; New Board… and Open Recent are available everywhere, including + // with no window at all. CommandGroup(after: .newItem) { BoardCreationCommands() + NewBoardCommand(appModel: appModel) Divider() @@ -122,12 +143,19 @@ struct KanbanApp: App { } .keyboardShortcut("o", modifiers: .command) + OpenRecentMenu(appModel: appModel) + Divider() BoardInfoCommand() Divider() + DuplicateBoardCommand(appModel: appModel) + RevealInFinderCommand() + + Divider() + TrashCommands() } diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index babcf15..8e23188 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -458,6 +458,8 @@ public final class BannerCenter { // looking at — which is what makes "Couldn't rename 'Fix login'" (02's own example // sentence) identify the right row rather than a name that never landed. if let title { "Couldn't rename '\(title)'" } else { "Couldn't rename the item" } + case let .duplicateBoard(title): + if let title { "Couldn't duplicate '\(title)'" } else { "Couldn't duplicate the board" } case let .importAttachment(filename): "Couldn't import '\(filename)'" case .listAttachments: diff --git a/Kanban/LiveStore/BoardRegistry.swift b/Kanban/LiveStore/BoardRegistry.swift index 135815b..db9ac26 100644 --- a/Kanban/LiveStore/BoardRegistry.swift +++ b/Kanban/LiveStore/BoardRegistry.swift @@ -426,6 +426,21 @@ public final class BoardRegistry { save() } + /// Drops every record — File ▸ Open Recent ▸ Clear Menu (11-command-nexus.md). + /// + /// **Finder's Clear Menu clears the menu; this registry *is* the menu**, so the equivalence is + /// exact: there is no separate recents list that could be emptied while the records stayed, and + /// a record whose board never appears anywhere is a setting nothing can reach. It is therefore + /// `forget(id:)` applied to every row, and the one test worth writing says exactly that. + /// + /// One save rather than one per record: the file is rewritten wholesale anyway, and forgetting + /// twenty boards should not be twenty writes. + public func forgetAll() { + guard !records.isEmpty else { return } + records.removeAll() + save() + } + // MARK: - Matching /// The index of the record whose bookmark resolves to the same file as `url`, if any. diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 658d34e..9b9a638 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -1192,6 +1192,13 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { /// the edit — `updateIndex` enriches it off the document it just read — which is the name the /// user is still looking at when the banner appears. case rename(title: String?) + + /// File ▸ Duplicate — the whole-board copy (03-board-ui.md § Welcome screen & templates). Its + /// own case rather than a fold into `.copy`, on `.rename`'s reasoning: the user pressed + /// *Duplicate*, and a banner telling them the app could not "copy the item" would name neither + /// the command nor the thing. `title` is the board's display name. + case duplicateBoard(title: String?) + case importAttachment(filename: String) case listAttachments case renumberChildren // order-maintenance sweep (compaction) @@ -1218,6 +1225,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case .style: .style(title: title) case .resize: .resize(title: title) case .rename: .rename(title: title) + case .duplicateBoard: .duplicateBoard(title: title) } } @@ -1241,6 +1249,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case let .style(title): Self.phrase("style", title) case let .resize(title): Self.phrase("resize", title) case let .rename(title): Self.phrase("rename", title) + case let .duplicateBoard(title): Self.phrase("duplicate board", title) case let .importAttachment(filename): "import attachment '\(filename)'" case .listAttachments: "list attachments" case .renumberChildren: "renumber children" diff --git a/KanbanTests/BoardDuplicatorTests.swift b/KanbanTests/BoardDuplicatorTests.swift new file mode 100644 index 0000000..0f0fecc --- /dev/null +++ b/KanbanTests/BoardDuplicatorTests.swift @@ -0,0 +1,189 @@ +import Foundation +import Testing +@testable import Kanban + +/// File ▸ Duplicate's copy (03-board-ui.md § Welcome screen & templates). +/// +/// Three promises, and all three are about what the copy *doesn't* do: +/// +/// - it doesn't remint — "The copy keeps every GUID", 01-storage-format.md's whole-board carve-out, +/// and the thing that keeps a copied `.git` history naming paths that still exist; +/// - it doesn't drop the trash — "Tombstoned items are carried too (settled) ... the duplicate is +/// born exactly matching its history"; +/// - it doesn't overwrite — the Finder-style `copy` ladder renames instead. +/// +/// Tested against real folders, because every one of those is a claim about bytes on disk. The +/// window flow around the copy (the flush, the banner row, the open that follows) is the command's, +/// not this type's. + +// MARK: - Fixtures + +/// A board with a live lane, a tombstoned lane, a live card, a tombstoned card, and a stray file — +/// everything a literal copy has to carry through untouched. +@MainActor +private func makeBoard(named name: String) throws -> (fixture: WriterFixture, root: URL) { + let fixture = try WriterFixture() + let root = fixture.url(name) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + + try fixture.item(name, Item.board) + try fixture.item("\(name)/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(name)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item( + "\(name)/\(Ident.lane1)/\(Ident.card2)", + "---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + try fixture.item( + "\(name)/\(Ident.lane2)", + "---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + try fixture.item("\(name)/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried")) + try fixture.file("\(name)/CLAUDE.user.md", Data("board instructions\n".utf8)) + + return (fixture, root) +} + +/// Every path under `root`, board-root-relative, hidden entries included — what "a literal copy" +/// means as an assertion. +private func tree(of root: URL) throws -> Set { + var paths: Set = [] + let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, options: []) + while let url = walker?.nextObject() as? URL { + paths.insert(url.path.replacingOccurrences(of: root.path + "/", with: "")) + } + return paths +} + +// MARK: - Tests + +@MainActor +@Suite("BoardDuplicator") +struct BoardDuplicatorTests { + + // MARK: The name ladder + + @Test("The first duplicate is a 'copy' sibling, extension carried") + func firstCopyIsNamedFinderStyle() throws { + let board = try makeBoard(named: "Roadmap.kanban") + defer { board.fixture.tearDown() } + + let destination = BoardDuplicator.copyDestination(for: board.root) + + #expect(destination.lastPathComponent == "Roadmap copy.kanban") + #expect(destination.deletingLastPathComponent().path == board.root.deletingLastPathComponent().path, + "a sibling — where the user is already looking") + } + + @Test("An extension-less board has none to carry") + func extensionlessBoardCopiesWithoutOne() throws { + let board = try makeBoard(named: "Plain") + defer { board.fixture.tearDown() } + + #expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Plain copy") + } + + @Test("The collision ladder counts up from 2, one collision at a time") + func collisionLadderCountsUp() throws { + let board = try makeBoard(named: "Roadmap.kanban") + defer { board.fixture.tearDown() } + + let first = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") + #expect(first.lastPathComponent == "Roadmap copy.kanban") + + let second = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") + #expect(second.lastPathComponent == "Roadmap copy 2.kanban") + + let third = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") + #expect(third.lastPathComponent == "Roadmap copy 3.kanban") + + // And duplicating a duplicate ladders off *its* name, exactly as Finder does. + let ofACopy = try BoardDuplicator.duplicate(boardAt: first, titled: "Roadmap copy") + #expect(ofACopy.lastPathComponent == "Roadmap copy copy.kanban") + } + + @Test("Anything already wearing the name blocks it, folder or file alike") + func anyExistingEntryBlocksTheName() throws { + let board = try makeBoard(named: "Roadmap.kanban") + defer { board.fixture.tearDown() } + // Not a board — just a file in the way. A duplicate must rename around it, never through it. + try board.fixture.file("Roadmap copy.kanban", Data("in the way".utf8)) + + #expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Roadmap copy 2.kanban") + } + + // MARK: What lands in the copy + + @Test("Every GUID is kept — the whole-board carve-out from the remint rule") + func guidsArePreserved() throws { + let board = try makeBoard(named: "Roadmap.kanban") + defer { board.fixture.tearDown() } + + let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") + + #expect(try tree(of: copy) == tree(of: board.root), + "same identities, same nesting — a copied history keeps naming paths that exist") + #expect(FileManager.default.fileExists(atPath: copy.appendingPathComponent(Ident.lane1).path)) + #expect(FileManager.default.fileExists( + atPath: copy.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)").path + )) + } + + @Test("Tombstoned lanes and cards are carried, trash included") + func tombstonesAreCarried() throws { + let board = try makeBoard(named: "Roadmap.kanban") + defer { board.fixture.tearDown() } + + let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") + let loaded = try BoardLoader.load(boardRoot: copy).model + + let archive = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane2 }) + #expect(archive.isDeleted, "the tombstoned lane came along — Duplicate is a full fork") + #expect(archive.cards.contains { $0.id.rawValue == Ident.card3 }, "and everything beneath it") + + let todo = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane1 }) + #expect(todo.cards.contains { $0.id.rawValue == Ident.card2 && $0.isDeleted }) + } + + @Test("The copy is byte-for-byte, strays and stale attribution included") + func contentIsCopiedVerbatim() throws { + let board = try makeBoard(named: "Roadmap.kanban") + defer { board.fixture.tearDown() } + + let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") + + func bytes(_ root: URL, _ relative: String) throws -> Data { + try Data(contentsOf: root.appendingPathComponent(relative)) + } + #expect(try bytes(copy, "index.md") == bytes(board.root, "index.md")) + #expect(try bytes(copy, "\(Ident.lane1)/\(Ident.card1)/index.md") + == bytes(board.root, "\(Ident.lane1)/\(Ident.card1)/index.md"), + "created, unknown keys and modified-by all survive — nothing here reads a board file") + #expect(try bytes(copy, "CLAUDE.user.md") == bytes(board.root, "CLAUDE.user.md")) + } + + @Test("The original is untouched by its own duplication") + func originalSurvivesUnchanged() throws { + let board = try makeBoard(named: "Roadmap.kanban") + defer { board.fixture.tearDown() } + let before = try tree(of: board.root) + + _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") + + #expect(try tree(of: board.root) == before) + } + + // MARK: Failure + + @Test("A source that isn't there fails as a duplicate, naming the board") + func missingSourceFailsInTheDuplicateVocabulary() throws { + let board = try makeBoard(named: "Roadmap.kanban") + defer { board.fixture.tearDown() } + let missing = board.fixture.url("Never.kanban") + + let error = writeFailure { _ = try BoardDuplicator.duplicate(boardAt: missing, titled: "Never") } + + #expect(error?.operation == .duplicateBoard(title: "Never"), + "the user pressed Duplicate; the banner must say so") + #expect(BannerCenter.headline(for: try #require(error)).hasPrefix("Couldn't duplicate 'Never'")) + } +} diff --git a/KanbanTests/BoardRegistryTests.swift b/KanbanTests/BoardRegistryTests.swift index 3adae09..1d31681 100644 --- a/KanbanTests/BoardRegistryTests.swift +++ b/KanbanTests/BoardRegistryTests.swift @@ -226,6 +226,76 @@ struct BoardRegistryTests { #expect(registry.recents().isEmpty) } + // MARK: Clear Menu + + @Test("Clear Menu empties the registry, and persists") + func forgetAllEmptiesTheRegistry() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + let first = try makeBoard() + defer { first.tearDown() } + let second = try makeBoard() + defer { second.tearDown() } + + let registry = BoardRegistry(storageURL: storage.url) + registry.recordOpen(of: first.root, displayName: "First") + registry.recordOpen(of: second.root, displayName: "Second") + #expect(registry.recents().count == 2) + + registry.forgetAll() + + #expect(registry.recents().isEmpty) + #expect(BoardRegistry(storageURL: storage.url).recents().isEmpty, "Clear Menu persists") + } + + /// The equivalence 11-command-nexus.md's Clear Menu rests on: Finder clears a *menu*, and here + /// the registry **is** the menu — so clearing it can only mean forgetting every record, and must + /// leave the file in precisely the state that forgetting them one at a time would. + @Test("Clear Menu is Forget applied to every row — same result, same file") + func forgetAllMatchesForgettingEachRow() async throws { + let wholesale = try RegistryStorage() + defer { wholesale.tearDown() } + let piecemeal = try RegistryStorage() + defer { piecemeal.tearDown() } + let first = try makeBoard() + defer { first.tearDown() } + let second = try makeBoard() + defer { second.tearDown() } + + func populate(_ storage: RegistryStorage) -> BoardRegistry { + let registry = BoardRegistry(storageURL: storage.url) + registry.recordOpen(of: first.root, displayName: "First") + registry.recordOpen(of: second.root, displayName: "Second") + return registry + } + + let bulk = populate(wholesale) + bulk.forgetAll() + + let oneByOne = populate(piecemeal) + for row in oneByOne.recents() { + oneByOne.forget(id: row.record.id) + } + + #expect(bulk.recents().isEmpty) + #expect(oneByOne.recents().isEmpty) + #expect( + try Data(contentsOf: wholesale.url) == Data(contentsOf: piecemeal.url), + "the two paths leave byte-identical files — there is no state Clear Menu skips" + ) + } + + @Test("Clear Menu on an empty registry writes nothing") + func forgetAllOnEmptyRegistryIsANoOp() async throws { + let storage = try RegistryStorage() + defer { storage.tearDown() } + + let registry = BoardRegistry(storageURL: storage.url) + registry.forgetAll() + + #expect(try storage.entryNames().isEmpty, "an empty registry has nothing to clear and no file to write") + } + // MARK: Persistence @Test("Every mutation survives a reload of the file, dates included") diff --git a/KanbanTests/BoardTemplateTests.swift b/KanbanTests/BoardTemplateTests.swift new file mode 100644 index 0000000..221bd7b --- /dev/null +++ b/KanbanTests/BoardTemplateTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing +@testable import Kanban + +/// Template instantiation — the on-disk half of File ▸ New Board… (09-templates.md ▸ Instantiation). +/// +/// The window flow around it (the chooser, `NSSavePanel`, the open that follows) is untestable +/// without a screen and deliberately holds no rules of its own; everything that *is* a rule — what +/// gets written, what the board is called, and the order the lanes land in — lives in +/// `BoardTemplate.instantiate(at:)` and is checked here against a real temp folder, through the +/// app's own loader. + +// MARK: - Fixtures + +/// An empty temp folder to create *into* — the save panel's answer, minus the panel. +private func temporaryLocation(named name: String) throws -> (url: URL, tearDown: () -> Void) { + let container = FileManager.default.temporaryDirectory + .appendingPathComponent("BoardTemplateTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: container, withIntermediateDirectories: true) + return (container.appendingPathComponent(name, isDirectory: true), { + try? FileManager.default.removeItem(at: container) + }) +} + +// MARK: - Tests + +@Suite("BoardTemplate") +struct BoardTemplateTests { + + @Test("Basic writes a board and its three lanes, in order") + func basicInstantiatesInOrder() throws { + let location = try temporaryLocation(named: "Roadmap.kanban") + defer { location.tearDown() } + + try BoardTemplate.basic.instantiate(at: location.url) + + let result = try BoardLoader.load(boardRoot: location.url) + #expect(result.warnings.isEmpty, "a board this app just wrote must load clean") + #expect(result.model.lanes.map { $0.title.value } == ["To Do", "Doing", "Done"]) + #expect(result.model.lanes.allSatisfy { !$0.isDeleted }) + #expect(result.model.lanes.allSatisfy { $0.cards.isEmpty }, "the Basic scaffold seeds no cards") + } + + @Test("The lanes are ranked by the board convention — 1024 apart, from 1024") + func lanesAreRankedByTheAppendConvention() throws { + let location = try temporaryLocation(named: "Ranked.kanban") + defer { location.tearDown() } + + try BoardTemplate.basic.instantiate(at: location.url) + + let orders = try BoardLoader.load(boardRoot: location.url).model.lanes.map(\.order) + #expect(orders == [1024, 2048, 3072], "each lane appends after the one before it") + } + + @Test("The board's title is the document name the user chose, not the template's") + func titleComesFromTheChosenName() throws { + let location = try temporaryLocation(named: "Q3 Planning.kanban") + defer { location.tearDown() } + + try BoardTemplate.basic.instantiate(at: location.url) + + // 01-storage-format.md § Board naming: display name and folder name start out matching. + #expect(try BoardLoader.load(boardRoot: location.url).model.title.value == "Q3 Planning") + } + + @Test("An extension-less location is as legal a board, and keeps its whole name as the title") + func extensionlessLocationWorks() throws { + let location = try temporaryLocation(named: "Plain") + defer { location.tearDown() } + + try BoardTemplate.basic.instantiate(at: location.url) + + #expect(try BoardLoader.load(boardRoot: location.url).model.title.value == "Plain") + } + + @Test("Every lane folder is a fresh lowercase UUID") + func laneFoldersAreMintedIdentities() throws { + let location = try temporaryLocation(named: "Minted.kanban") + defer { location.tearDown() } + + try BoardTemplate.basic.instantiate(at: location.url) + + let ids = try BoardLoader.load(boardRoot: location.url).model.lanes.map(\.id.rawValue) + #expect(ids.count == 3) + #expect(Set(ids).count == 3, "three lanes, three identities") + #expect(ids.allSatisfy { $0 == $0.lowercased() }, "the app emits lowercase UUIDs") + #expect(ids.allSatisfy { UUID(uuidString: $0) != nil }) + } + + @Test("Instantiating over an existing board refuses rather than clobbering it") + func refusesToOverwriteAnExistingBoard() throws { + let location = try temporaryLocation(named: "Taken.kanban") + defer { location.tearDown() } + try BoardTemplate.basic.instantiate(at: location.url) + let before = try Data(contentsOf: location.url.appendingPathComponent("index.md")) + + let error = writeFailure { try BoardTemplate.basic.instantiate(at: location.url) } + + #expect(error?.operation == .createBoard) + #expect( + try Data(contentsOf: location.url.appendingPathComponent("index.md")) == before, + "the existing board is untouched — a create never replaces one" + ) + } + + @Test("The document name behind a chosen URL is the folder name without its extension") + func documentNameStripsTheExtension() { + #expect(BoardTemplate.documentName(of: URL(fileURLWithPath: "/Boards/Roadmap.kanban")) == "Roadmap") + #expect(BoardTemplate.documentName(of: URL(fileURLWithPath: "/Boards/Roadmap")) == "Roadmap") + } + + @Test("The chooser offers Basic, and Basic is first") + func inventoryHoldsBasicFirst() { + // m9-templates: this becomes the bundled inventory's ten, with Basic still first + // (09-templates.md ▸ Inventory). + #expect(BoardTemplate.all.first == BoardTemplate.basic) + #expect(BoardTemplate.basic.slug == "basic") + } +} diff --git a/KanbanTests/WelcomeRowTests.swift b/KanbanTests/WelcomeRowTests.swift new file mode 100644 index 0000000..19be3cb --- /dev/null +++ b/KanbanTests/WelcomeRowTests.swift @@ -0,0 +1,241 @@ +import Foundation +import Testing +@testable import Kanban + +/// The welcome window's row derivation — 02-architecture.md § Launch and window lifecycle's +/// row-level failure rule, which is the one part of that screen a test can hold to account: +/// +/// > A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome +/// > appears alongside whatever did restore, the failed board's recents row carrying fail-fast's +/// > specifics (load error) or the unavailable state per Graceful orphaning. Other restorations +/// > proceed unaffected — never a launch-time modal chain, **never a silent drop**. +/// +/// "Never a silent drop" is the clause with teeth: a failure that matches no row has to come out +/// somewhere, and the only way to know it does is to ask the function that decides. + +// MARK: - Fixtures + +/// A record with nothing in it that matters except what a given test is about. The bookmark is empty +/// because this function never resolves one — `RecentBoard` is constructed directly here, so the +/// availability classification is an input rather than a filesystem outcome. +private func record( + name: String, + at path: String, + lanes: Int? = nil, + cards: Int? = nil, + opened: Date = Date() +) -> BoardRecord { + BoardRecord( + bookmark: Data(), + displayName: name, + lastKnownPath: path, + lastOpened: opened, + laneCount: lanes, + cardCount: cards + ) +} + +private func available(_ record: BoardRecord, at path: String? = nil) -> RecentBoard { + .available(record, at: URL(fileURLWithPath: path ?? record.lastKnownPath, isDirectory: true)) +} + +// MARK: - Tests + +@Suite("WelcomeRow") +struct WelcomeRowTests { + + // MARK: The three states + + @Test("An available record with stamped counts is an ordinary row") + func availableRow() throws { + let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban", lanes: 3, cards: 12)) + + let derived = WelcomeRow.derive(recents: [recent], failures: []) + + #expect(derived.rows.count == 1) + let row = try #require(derived.rows.first) + #expect(row.displayName == "Roadmap") + #expect(row.isAvailable) + #expect(row.canOpen) + #expect(row.canReveal) + #expect(row.caption == .counts(lanes: 3, cards: 12)) + #expect(row.countsSummary == "3 lanes · 12 cards") + #expect(derived.unmatched.isEmpty) + } + + @Test("A record that has never been closed shows an em dash, not zeroes") + func unstampedCountsShowAPlaceholder() throws { + let recent = available(record(name: "Fresh", at: "/Boards/Fresh")) + + let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first) + + #expect(row.caption == .counts(lanes: nil, cards: nil)) + #expect(row.countsSummary == "—", "zero is a claim; an unstamped record makes none") + } + + @Test("Counts are singular at one") + func countsPluralize() { + let recent = available(record(name: "Tiny", at: "/Boards/Tiny", lanes: 1, cards: 1)) + + #expect(WelcomeRow.derive(recents: [recent], failures: []).rows.first?.countsSummary == "1 lane · 1 card") + } + + @Test("An unresolvable bookmark is a dimmed row with Open and Reveal off — never a missing row") + func unavailableRow() throws { + let recent = RecentBoard.unavailable(record(name: "Archive", at: "/Volumes/Gone/Archive", lanes: 4, cards: 9)) + + let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first) + + #expect(!row.isAvailable) + #expect(!row.canOpen) + #expect(!row.canReveal) + #expect(row.caption == .unavailable) + #expect(row.displayName == "Archive", "an orphan still says which board it was") + } + + // MARK: The failure join + + @Test("A failure naming a row renders on that row instead of in a list of its own") + func failureLandsOnItsRow() throws { + let recent = available(record(name: "Broken", at: "/Boards/Broken.kanban", lanes: 2, cards: 5)) + let failure = LaunchFailure(path: "/Boards/Broken.kanban", message: "index.md: schema 7 is from a newer version") + + let derived = WelcomeRow.derive(recents: [recent], failures: [failure]) + + let row = try #require(derived.rows.first) + #expect(row.caption == .failed("index.md: schema 7 is from a newer version")) + #expect(derived.unmatched.isEmpty, "the row carried it — it must not also appear in the fallback list") + } + + @Test("A failure outranks the counts and the unavailable state alike") + func failureOutranksTheOtherCaptions() throws { + let orphan = RecentBoard.unavailable(record(name: "Offline", at: "/Volumes/NAS/Offline", lanes: 2, cards: 2)) + let failure = LaunchFailure(path: "/Volumes/NAS/Offline", message: "This board is unavailable.") + + let row = try #require(WelcomeRow.derive(recents: [orphan], failures: [failure]).rows.first) + + #expect(row.caption == .failed("This board is unavailable.")) + #expect(!row.canOpen, "the caption changed; the row is still an orphan") + } + + @Test("A bookmark that followed a move matches a failure recorded at the older path") + func failureMatchesEitherOfARecordsPaths() { + // The record was last *seen* at the old path; its bookmark now resolves to the new one. + let moved = RecentBoard.available( + record(name: "Moved", at: "/Boards/Old.kanban"), + at: URL(fileURLWithPath: "/Boards/New.kanban", isDirectory: true) + ) + + let atOldPath = WelcomeRow.derive( + recents: [moved], + failures: [LaunchFailure(path: "/Boards/Old.kanban", message: "old")] + ) + let atNewPath = WelcomeRow.derive( + recents: [moved], + failures: [LaunchFailure(path: "/Boards/New.kanban", message: "new")] + ) + + #expect(atOldPath.rows.first?.caption == .failed("old")) + #expect(atNewPath.rows.first?.caption == .failed("new")) + #expect(atOldPath.unmatched.isEmpty) + #expect(atNewPath.unmatched.isEmpty) + } + + @Test("Paths are compared standardized, so two spellings of one board are one board") + func pathsAreStandardizedBeforeMatching() { + let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban")) + let failure = LaunchFailure(path: "/Boards/./Sub/../Roadmap.kanban", message: "couldn't read it") + + let derived = WelcomeRow.derive(recents: [recent], failures: [failure]) + + #expect(derived.rows.first?.caption == .failed("couldn't read it")) + #expect(derived.unmatched.isEmpty) + } + + @Test("The newest of several failures for one board is the caption, and all of them are consumed") + func newestFailureWinsAndOlderOnesAreNotOrphaned() { + let recent = available(record(name: "Retried", at: "/Boards/Retried")) + let failures = [ + LaunchFailure(path: "/Boards/Retried", message: "first attempt"), + LaunchFailure(path: "/Boards/Retried", message: "second attempt"), + ] + + let derived = WelcomeRow.derive(recents: [recent], failures: failures) + + #expect(derived.rows.first?.caption == .failed("second attempt"), "the newest describes the file as it is now") + #expect(derived.unmatched.isEmpty, "the older attempt must not resurface as if nothing had shown it") + } + + @Test("A failure naming no record keeps a fallback of its own — never a silent drop") + func failureWithNoRowFallsBack() { + let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban")) + let stray = LaunchFailure(path: "/Downloads/not-a-board", message: "index.md: no such file or directory") + + let derived = WelcomeRow.derive(recents: [recent], failures: [stray]) + + #expect(derived.rows.count == 1) + #expect(derived.rows.first?.caption == .counts(lanes: nil, cards: nil), "the unrelated row is untouched") + #expect(derived.unmatched.map(\.message) == ["index.md: no such file or directory"]) + } + + @Test("Other rows are unaffected by a failure on one of them") + func failuresDoNotBleedBetweenRows() { + let recents = [ + available(record(name: "Good", at: "/Boards/Good", lanes: 1, cards: 1)), + available(record(name: "Bad", at: "/Boards/Bad")), + ] + let failure = LaunchFailure(path: "/Boards/Bad", message: "boom") + + let derived = WelcomeRow.derive(recents: recents, failures: [failure]) + + #expect(derived.rows[0].caption == .counts(lanes: 1, cards: 1)) + #expect(derived.rows[1].caption == .failed("boom")) + } + + // MARK: Order and naming + + @Test("The derivation preserves the registry's order and never re-sorts") + func orderIsTheRegistrys() { + let recents = [ + available(record(name: "Third", at: "/Boards/C", opened: Date(timeIntervalSince1970: 3))), + available(record(name: "First", at: "/Boards/A", opened: Date(timeIntervalSince1970: 1))), + available(record(name: "Second", at: "/Boards/B", opened: Date(timeIntervalSince1970: 2))), + ] + + let derived = WelcomeRow.derive(recents: recents, failures: []) + + #expect(derived.rows.map(\.displayName) == ["Third", "First", "Second"], + "the sort rule lives in BoardRegistry.recents() and must not be duplicated here") + } + + @Test("A record with no display name falls back to its folder name, extension stripped") + func displayNameFallsBackToTheFolderName() { + let recent = available(record(name: "", at: "/Boards/Untitled.kanban")) + + #expect(WelcomeRow.derive(recents: [recent], failures: []).rows.first?.displayName == "Untitled") + } + + @Test("The location line is the containing folder, not the board's own path") + func locationIsTheContainingFolder() { + let recent = available(record(name: "Roadmap", at: "/Boards/Work/Roadmap.kanban")) + + #expect(WelcomeRow.derive(recents: [recent], failures: []).rows.first?.location == "/Boards/Work") + } + + @Test("An unavailable row's location comes from where it was last seen") + func unavailableLocationUsesLastKnownPath() { + let recent = RecentBoard.unavailable(record(name: "Archive", at: "/Volumes/Gone/Boards/Archive")) + + #expect(WelcomeRow.derive(recents: [recent], failures: []).rows.first?.location == "/Volumes/Gone/Boards") + } + + @Test("An empty registry derives no rows and drops no failures") + func emptyRecentsStillSurfaceFailures() { + let stray = LaunchFailure(path: "/Downloads/whatever", message: "not a board") + + let derived = WelcomeRow.derive(recents: [], failures: [stray]) + + #expect(derived.rows.isEmpty) + #expect(derived.unmatched.count == 1) + } +} diff --git a/README.md b/README.md index bd35b2c..340e517 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Lanework is in early development. This list tracks what has actually shipped and - **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. +- **Window architecture** — the three window types and their lifecycle: a welcome window (below), 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. - **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the reorder drag surface, a plain click selecting the lane and movement carrying it above its siblings while they show the would-be order. - **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). - **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock. @@ -20,6 +20,10 @@ Lanework is in early development. This list tracks what has actually shipped and - **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live. +- **The welcome screen** — branding and two actions on the left, recents on the right: board icon, name, containing folder, and the lane/card counts stamped at last close, newest first. The list never opens a board to build itself, so a huge board or an offline volume costs nothing. Single click selects, double click or Return opens, and a context menu carries Open, Reveal in Finder, and Forget. A board that failed to open or restore says so **on its own row**, in the warning tint, carrying the loader's specifics rather than a modal at launch; a board whose bookmark no longer resolves dims to Unavailable with Open and Reveal off and Forget still live; and a failure naming no known board keeps a list of its own rather than vanishing. File ▸ Open Recent lists the same boards — unavailable ones disabled — with Clear Menu at the bottom, which forgets every record because here the registry *is* the menu. + +- **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel for the location; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — tombstoned items carried, strays and timestamps untouched. + - **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board, and below that a Git section that says plainly that this board has no repository yet. The read-only lock disables the surface without closing it. ## Development