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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user