Build the welcome screen
The welcome window becomes the real thing: Xcode-style, hidden title bar with background drag, branding and actions left, recents right — rows carrying the board symbol, name, location, and the registry's cached lane/card counts (stamped at close, never a scan at welcome time), sorted by last opened. Launch failures surface row-level per 02: a failure joins its recents row as a warning caption, an unresolvable bookmark renders unavailable with Forget its one affordance, and only a failure with no row to carry it falls back to a compact list; a board opening again heals its row. New Board (Opt-Cmd-N) opens the Pages-style template chooser — shipped with the single Basic template and the m9 seams marked — flowing through the save panel into createBoard/createLane and straight into a board window. Open Recent gains its submenu with Clear Menu (byte-identical to forgetting every row, pinned by test), and File > Duplicate forks the frontmost board to a Finder-style copy sibling: pending work flushes first through the close flush's step two alone (sessions stay open — 09's stated exception), every GUID and tombstone carries (the whole-board carve-out from copies-remint), and the copy opens in its own window while the original stays put. 36 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
-7
@@ -11,6 +11,11 @@ import os
|
|||||||
public enum WindowID {
|
public enum WindowID {
|
||||||
public static let welcome = "welcome"
|
public static let welcome = "welcome"
|
||||||
public static let restoreBootstrap = "restore-bootstrap"
|
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 board = "board"
|
||||||
public static let card = "card"
|
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
|
/// **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).
|
/// 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
|
/// The join onto a recents row is `WelcomeRow.derive(recents:failures:)` — 02 § Launch and window
|
||||||
/// § Launch and window lifecycle wants the failure *on the board's recents row*, carrying fail-fast's
|
/// lifecycle wants the failure *on the board's row*, carrying fail-fast's specifics or the
|
||||||
/// specifics or the unavailable state — and that belongs with the recents list itself.
|
/// unavailable state, and a failure naming no row (a first open of a folder that was never a board)
|
||||||
// m4-welcome: row-level failure rendering lands with the full welcome window (recents, Forget,
|
/// falls back to a list of its own. `path` is what the join matches on, which is why it is stored
|
||||||
// Open Recent). Until then a plain list under the branding is the honest placeholder.
|
/// rather than derived from the message.
|
||||||
public struct LaunchFailure: Identifiable, Sendable, Equatable {
|
public struct LaunchFailure: Identifiable, Sendable, Equatable {
|
||||||
public let id = UUID()
|
public let id = UUID()
|
||||||
public let path: String
|
public let path: String
|
||||||
@@ -229,10 +234,82 @@ public final class AppModel {
|
|||||||
windowDismisser = dismiss
|
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<String> {
|
||||||
|
var paths: Set<String> = []
|
||||||
|
if let record = boardRegistry.record(id: id) {
|
||||||
|
paths.insert(record.lastKnownPath)
|
||||||
|
}
|
||||||
|
if let url = recents.first(where: { $0.record.id == id })?.url {
|
||||||
|
paths.insert(url.path)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Launch failures
|
// MARK: Launch failures
|
||||||
|
|
||||||
/// Boards that failed to restore or open, newest last — the minimal welcome's one dynamic
|
/// Boards that failed to restore or open, newest last. Rendered on their own recents rows where
|
||||||
/// section. See `LaunchFailure` for what replaces it.
|
/// one exists, and in a fallback list where none does — `WelcomeRow.derive(recents:failures:)`.
|
||||||
public private(set) var launchFailures: [LaunchFailure] = []
|
public private(set) var launchFailures: [LaunchFailure] = []
|
||||||
|
|
||||||
// MARK: Card-window placement
|
// MARK: Card-window placement
|
||||||
@@ -266,6 +343,11 @@ public final class AppModel {
|
|||||||
/// real Application Support directory".
|
/// real Application Support directory".
|
||||||
public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) {
|
public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) {
|
||||||
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
|
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
|
// MARK: - Opening
|
||||||
@@ -308,6 +390,14 @@ public final class AppModel {
|
|||||||
windowOpener?(id: WindowID.welcome)
|
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).
|
/// 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
|
/// **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.
|
/// 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?) {
|
func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
|
||||||
sessions[ref] = BoardSession(store: store, recordID: recordID, cardRefs: [], access: access)
|
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.
|
/// 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()
|
launchFailures.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forgets exactly the named failures — what the unmatched-failures list's Clear dismisses, so
|
||||||
|
/// that pressing it never also erases a message still standing on a recents row the user has
|
||||||
|
/// not looked at.
|
||||||
|
public func clearLaunchFailures(ids: Set<UUID>) {
|
||||||
|
launchFailures.removeAll { ids.contains($0.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops every failure naming one of `paths` — the resolution path, used when a board opens
|
||||||
|
/// successfully and when its record is forgotten. Paths are compared the way the welcome row's
|
||||||
|
/// join compares them, so "this row's failure" means the same thing in both places.
|
||||||
|
private func clearLaunchFailures(naming paths: Set<String>) {
|
||||||
|
guard !paths.isEmpty else { return }
|
||||||
|
let keys = Set(paths.map(WelcomeRow.pathKey))
|
||||||
|
launchFailures.removeAll { keys.contains(WelcomeRow.pathKey($0.path)) }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Counts
|
// MARK: - Counts
|
||||||
|
|
||||||
/// The lane and card counts stamped into the registry at close — **live items only** (02
|
/// 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) }
|
defer { closingBoards.remove(ref) }
|
||||||
|
|
||||||
await coordinator(for: ref).run(cause: cause)
|
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**.
|
/// Quit: the same sequence, once per open board, **sequentially**.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 `<bundle>/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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,9 @@ struct BoardWindowHost: View {
|
|||||||
// beside it the window's own popover flag, which is what File ▸ Board Info toggles, and
|
// 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.
|
// its purge-alert host, which the trash's two confirmed commands raise.
|
||||||
.focusedSceneValue(\.boardStore, store)
|
.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(\.boardInfo, boardInfo)
|
||||||
.focusedSceneValue(\.trashConfirmations, trashConfirmations)
|
.focusedSceneValue(\.trashConfirmations, trashConfirmations)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,13 @@ public struct CloseFlushCoordinator {
|
|||||||
|
|
||||||
/// Step 2. The store's pipeline, then the editor saves, then the pending commit — 02's order,
|
/// Step 2. The store's pipeline, then the editor saves, then the pending commit — 02's order,
|
||||||
/// stated once.
|
/// 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 storeFlush()
|
||||||
await editorFlush?()
|
await editorFlush?()
|
||||||
await committerFlush?()
|
await committerFlush?()
|
||||||
|
|||||||
@@ -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..<max(1, 3 - index), id: \.self) { _ in
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(.quaternary)
|
||||||
|
.frame(height: 14)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - WelcomeRow
|
||||||
|
|
||||||
|
/// One row of the welcome window's recents list: a `RecentBoard` joined with whatever launch failure
|
||||||
|
/// names the same board.
|
||||||
|
///
|
||||||
|
/// ### Why the join is a value, derived by a pure function
|
||||||
|
///
|
||||||
|
/// 02-architecture.md § Launch and window lifecycle makes the *row* the failure surface:
|
||||||
|
///
|
||||||
|
/// > 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<UUID> = []
|
||||||
|
|
||||||
|
let rows = recents.map { recent -> WelcomeRow in
|
||||||
|
let record = recent.record
|
||||||
|
var keys: Set<String> = [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)
|
||||||
|
}()
|
||||||
|
}
|
||||||
+250
-42
@@ -1,25 +1,51 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
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,
|
/// > Welcome: resizable, no title bar (background drag); recents list with board icon, name,
|
||||||
/// location, lane/card counts, sorted by last opened)". This is the left half, plus the one thing
|
/// > location, counts; single click selects, double click opens; context menu Open / Reveal in
|
||||||
/// that cannot wait — the list of boards that failed to open, because 02 § Launch and window
|
/// > Finder / Forget.
|
||||||
/// lifecycle forbids a launch-time failure from being silently dropped and welcome is where it must
|
|
||||||
/// surface.
|
|
||||||
///
|
///
|
||||||
/// The layout is therefore already an `HStack` with one column in it. The recents column drops in
|
/// Branding and the two create/open actions on the left, recents on the right. Resizable — the
|
||||||
/// beside it; nothing here has to move.
|
/// recents list gets whatever space the user grants it — and title-bar-less, with the background as
|
||||||
// m4-welcome: the recents column, New Board… / Open Recent, per-row Forget and Reveal in Finder, and
|
/// the drag surface (the gesture is attached at low priority, so a row or a button always wins).
|
||||||
// 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.
|
/// ### 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 {
|
struct WelcomeView: View {
|
||||||
|
|
||||||
@Environment(AppModel.self) private var appModel
|
@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 {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
branding
|
branding
|
||||||
@@ -29,14 +55,19 @@ struct WelcomeView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
failures
|
recents
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding(32)
|
|
||||||
}
|
}
|
||||||
// Fixed, with `.windowResizability(.contentSize)` on the scene: welcome is a launcher, not a
|
.frame(minWidth: 760, minHeight: 460)
|
||||||
// workspace, and Xcode's — the window this one is modelled on — does not resize either. The
|
// The window has no title bar, so the background is the drag handle. `.gesture` rather than
|
||||||
// one thing that can grow without bound is the failure list, which scrolls.
|
// `.highPriorityGesture`: a click on a row or a button belongs to the row or the button.
|
||||||
.frame(width: 760, height: 460)
|
.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
|
// MARK: Branding and actions
|
||||||
@@ -56,12 +87,24 @@ struct WelcomeView: View {
|
|||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("Folders and Markdown, on your terms.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
Spacer(minLength: 24)
|
Spacer(minLength: 24)
|
||||||
|
|
||||||
Button("Open Board…") {
|
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()
|
appModel.presentOpenPanel()
|
||||||
}
|
}
|
||||||
.controlSize(.large)
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
@@ -73,30 +116,87 @@ struct WelcomeView: View {
|
|||||||
return "Version \(short) (\(build))"
|
return "Version \(short) (\(build))"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Failed opens
|
// MARK: Recents
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var failures: some View {
|
private var recents: some View {
|
||||||
if appModel.launchFailures.isEmpty {
|
let derivation = self.derivation
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("No boards open")
|
|
||||||
.font(.title3)
|
|
||||||
Text("Open a board folder to get started.")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Couldn't open")
|
|
||||||
.font(.title3)
|
|
||||||
|
|
||||||
ScrollView {
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
if derivation.rows.isEmpty {
|
||||||
ForEach(appModel.launchFailures) { failure in
|
emptyHint
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
} else {
|
||||||
Text(failure.displayName)
|
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)
|
.font(.headline)
|
||||||
Text(failure.message)
|
.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)
|
.font(.callout)
|
||||||
|
Text(failure.message)
|
||||||
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Text(failure.path)
|
Text(failure.path)
|
||||||
@@ -107,13 +207,121 @@ struct WelcomeView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Clear") {
|
// MARK: - Pieces
|
||||||
appModel.clearLaunchFailures()
|
|
||||||
|
/// 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)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-6
@@ -55,11 +55,30 @@ struct KanbanApp: App {
|
|||||||
}
|
}
|
||||||
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .suppressed : .automatic)
|
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .suppressed : .automatic)
|
||||||
.restorationBehavior(.disabled)
|
.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
|
// 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.
|
// the one 11-command-nexus.md names rather than whatever the scene happens to be called.
|
||||||
.commandsRemoved()
|
.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) {
|
Window("", id: WindowID.restoreBootstrap) {
|
||||||
RestoreBootstrapView()
|
RestoreBootstrapView()
|
||||||
.environment(appModel)
|
.environment(appModel)
|
||||||
@@ -107,13 +126,15 @@ struct KanbanApp: App {
|
|||||||
/// changing one silently breaks every remap of it.
|
/// changing one silently breaks every remap of it.
|
||||||
@CommandsBuilder
|
@CommandsBuilder
|
||||||
private var menuCommands: some Commands {
|
private var menuCommands: some Commands {
|
||||||
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, (New Board…,
|
// 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 /
|
// Open…, Open Recent ▸, Board Info, Duplicate, (Save as Template, still owed — 09),
|
||||||
// Reveal in Finder, still owed), then the trash trio and Empty Trash…. Every one of them
|
// Reveal in Finder, then the trash trio and Empty Trash…. The board-scoped ones validate
|
||||||
// validates against the frontmost board through the focus system, so each is simply
|
// against the frontmost board through the focus system, so each is simply absent-of-effect
|
||||||
// absent-of-effect when no board is in front.
|
// when no board is in front; New Board… and Open Recent are available everywhere, including
|
||||||
|
// with no window at all.
|
||||||
CommandGroup(after: .newItem) {
|
CommandGroup(after: .newItem) {
|
||||||
BoardCreationCommands()
|
BoardCreationCommands()
|
||||||
|
NewBoardCommand(appModel: appModel)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -122,12 +143,19 @@ struct KanbanApp: App {
|
|||||||
}
|
}
|
||||||
.keyboardShortcut("o", modifiers: .command)
|
.keyboardShortcut("o", modifiers: .command)
|
||||||
|
|
||||||
|
OpenRecentMenu(appModel: appModel)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
BoardInfoCommand()
|
BoardInfoCommand()
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
DuplicateBoardCommand(appModel: appModel)
|
||||||
|
RevealInFinderCommand()
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
TrashCommands()
|
TrashCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -458,6 +458,8 @@ public final class BannerCenter {
|
|||||||
// looking at — which is what makes "Couldn't rename 'Fix login'" (02's own example
|
// 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.
|
// 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" }
|
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):
|
case let .importAttachment(filename):
|
||||||
"Couldn't import '\(filename)'"
|
"Couldn't import '\(filename)'"
|
||||||
case .listAttachments:
|
case .listAttachments:
|
||||||
|
|||||||
@@ -426,6 +426,21 @@ public final class BoardRegistry {
|
|||||||
save()
|
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
|
// MARK: - Matching
|
||||||
|
|
||||||
/// The index of the record whose bookmark resolves to the same file as `url`, if any.
|
/// The index of the record whose bookmark resolves to the same file as `url`, if any.
|
||||||
|
|||||||
@@ -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
|
/// 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.
|
/// user is still looking at when the banner appears.
|
||||||
case rename(title: String?)
|
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 importAttachment(filename: String)
|
||||||
case listAttachments
|
case listAttachments
|
||||||
case renumberChildren // order-maintenance sweep (compaction)
|
case renumberChildren // order-maintenance sweep (compaction)
|
||||||
@@ -1218,6 +1225,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
case .style: .style(title: title)
|
case .style: .style(title: title)
|
||||||
case .resize: .resize(title: title)
|
case .resize: .resize(title: title)
|
||||||
case .rename: .rename(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 .style(title): Self.phrase("style", title)
|
||||||
case let .resize(title): Self.phrase("resize", title)
|
case let .resize(title): Self.phrase("resize", title)
|
||||||
case let .rename(title): Self.phrase("rename", 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 let .importAttachment(filename): "import attachment '\(filename)'"
|
||||||
case .listAttachments: "list attachments"
|
case .listAttachments: "list attachments"
|
||||||
case .renumberChildren: "renumber children"
|
case .renumberChildren: "renumber children"
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
var paths: Set<String> = []
|
||||||
|
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'"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,6 +226,76 @@ struct BoardRegistryTests {
|
|||||||
#expect(registry.recents().isEmpty)
|
#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
|
// MARK: Persistence
|
||||||
|
|
||||||
@Test("Every mutation survives a reload of the file, dates included")
|
@Test("Every mutation survives a reload of the file, dates included")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, 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.
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
- **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 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.
|
- **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
|
## Development
|
||||||
|
|||||||
Reference in New Issue
Block a user