The chooser completes its three tiers: bundled by template order, then keyed user templates, then keyless boards by display name — and a malformed user template still lists, by folder name with the loader's own sentence on the row, never failing its neighbours. The store is re-scanned on every presentation and on app activation, the Reveal round trip made honest without watching a folder 09 deliberately leaves unwatched; Reveal lives in the chooser's header and mints the store on first press. Save as Template repeats Duplicate's sequence — progress row with Cancel, flush, detached cancellable copy — through the engine: mint the store, read the next user order before the copy can count itself, Finder-ladder the name, copy excluding .git and .trash/, then stamp the whole template: mapping on the landed copy through updateIndex, with no bracket because the copy lives outside every watched board. Folder attributes deliberately don't carry — the one lock the command stays live under is the read-only-DMG one, and carrying its mode bits would mint a read-only template in the user's own store; the command gates instead on the real hazard, unsaved card content. A signpost names the template only when the ladder renamed it. One name ladder now serves Duplicate and the store. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
290 lines
16 KiB
Swift
290 lines
16 KiB
Swift
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)".
|
|
/// - **`.trash/` is carried too** (03, settled, re-grounded 2026-07-28 for the materialized trash):
|
|
/// "Duplicate is a full fork, `.trash/` included — dropping it would leave the copy's working tree
|
|
/// disagreeing with its own copied HEAD". This file does nothing to achieve that: the trash is an
|
|
/// ordinary folder under the root, so the tree walk 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.**
|
|
///
|
|
/// Not `BoardWriter.copyItem`, whose copy path exists to remint identities and restamp frontmatter
|
|
/// at an import boundary — this operation is defined by doing neither of those things.
|
|
///
|
|
/// ### A per-item walk, because the copy is cancellable
|
|
///
|
|
/// 03 settles the shape as well as the promise: "the copy runs as a per-item file walk that checks
|
|
/// cancellation between items — **never one monolithic `copyItem`** — and Cancel removes the partial
|
|
/// sibling before dismissing the banner (the attachment partial-cleanup precedent): a cancelled
|
|
/// duplicate never happened."
|
|
///
|
|
/// The walk itself is `BoardTreeCopy`, shared with template instantiation (09-templates.md), which
|
|
/// needs the same cancellable per-item copy of a whole board and differs only in what it excludes
|
|
/// and whether folder attributes carry. Duplicate excludes **nothing** and carries **everything** —
|
|
/// which is not a default taken but this flow's entire definition, stated in the call below.
|
|
///
|
|
/// **The partial goes on both exits.** Cancel promises removal; a failure gets it too, because a
|
|
/// half-copied board is pure residue — nothing was there before, so there is no true state for it to
|
|
/// be (`BoardWriter.copyItem`'s all-or-nothing posture, and 02-architecture.md's "the action visibly
|
|
/// doesn't happen"). Only the silence differs: `.cancelled` says nothing, `.failed` gets the banner.
|
|
/// The one thing never removed is a destination this walk did not create — an existing name is the
|
|
/// user's, and refusing to clobber it is the same stance `BoardWriter.createBoard` takes.
|
|
///
|
|
/// ### 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 {
|
|
|
|
// MARK: - Outcomes
|
|
|
|
/// The three ways a duplicate ends without a copy — which are also, one for one, the three
|
|
/// surfaces 03 gives them: silence, a save panel, a banner.
|
|
///
|
|
/// An enum rather than a bare `BoardWriteError` because the command's branch has to be
|
|
/// exhaustive: "cancelling the panel cancels the duplicate quietly (no banner — the user
|
|
/// declined, nothing failed)" and "non-permission failures (disk full, …) keep the ordinary
|
|
/// one-shot banner" are different outcomes of the same call, and a caller that forgot one of
|
|
/// them should not compile.
|
|
enum Failure: Error, Sendable, Equatable {
|
|
|
|
/// The user pressed Cancel on the in-progress row. The partial sibling is already gone and
|
|
/// there is nothing to report — "a cancelled duplicate never happened".
|
|
case cancelled
|
|
|
|
/// The sandbox refused the destination: "the board's security-scoped bookmark grants its
|
|
/// subtree, not its parent, so the sibling destination may be unwritable" (03, settled).
|
|
/// The caller's cue to open the save panel — the grant *is* the sandbox's own answer — and
|
|
/// never a banner on its own. It still carries the write error, so a caller with no panel to
|
|
/// offer (and the log) has the specifics.
|
|
case refused(BoardWriteError)
|
|
|
|
/// Anything else: a full disk, an unreadable source, a name already taken. The ordinary
|
|
/// one-shot banner, in the vocabulary every other failed write in the app speaks.
|
|
case failed(BoardWriteError)
|
|
}
|
|
|
|
// MARK: - Where the copy lands
|
|
|
|
/// 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.
|
|
///
|
|
/// It doubles as the **save panel's suggested name** when the sibling is refused (03): a parent
|
|
/// the sandbox will not let us write is usually one we cannot list either, so the ladder simply
|
|
/// finds no collision and suggests `"Board copy"` — the right pre-fill, arrived at honestly.
|
|
static func copyDestination(for rootURL: URL) -> URL {
|
|
uncollidedURL(
|
|
named: "\(rootURL.deletingPathExtension().lastPathComponent) copy",
|
|
extension: rootURL.pathExtension,
|
|
in: rootURL.deletingLastPathComponent()
|
|
)
|
|
}
|
|
|
|
/// **Finder's counting ladder, in one place**: `name`, then `name 2`, `name 3`, … counting up
|
|
/// from 2 against what is on disk at decision time, one collision at a time.
|
|
///
|
|
/// Shared rather than spelled twice, because two flows want the same ladder from different
|
|
/// starting names: Duplicate seeds it with `"Board copy"` (above), and Save as Template seeds it
|
|
/// with the board's own name — 09-templates.md ▸ Save as Template's "**Store collisions
|
|
/// auto-rename, Finder-style** (`Board.kanban` → `Board 2.kanban`) — the 01 import precedent:
|
|
/// saving never overwrites an existing template and never refuses". One ladder, two seeds, so
|
|
/// the two can never disagree about what "Finder-style" means.
|
|
///
|
|
/// The extension rides on the end (`Board copy.kanban`) and an extension-less board folder
|
|
/// simply has none to carry — both are shapes a board is allowed to be (01-storage-format.md
|
|
/// § Document packaging).
|
|
///
|
|
/// `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 either flow from overwriting something. It is
|
|
/// a decision-time answer, not a reservation — the caller still creates the folder itself and
|
|
/// still fails rather than clobbers if the name was taken in between.
|
|
static func uncollidedURL(named base: String, extension ext: String, in parent: URL) -> URL {
|
|
func candidate(_ name: String) -> URL {
|
|
parent.appendingPathComponent(ext.isEmpty ? name : "\(name).\(ext)", isDirectory: true)
|
|
}
|
|
|
|
var name = base
|
|
var counter = 2
|
|
while FileManager.default.fileExists(atPath: candidate(name).path) {
|
|
name = "\(base) \(counter)"
|
|
counter += 1
|
|
}
|
|
return candidate(name)
|
|
}
|
|
|
|
// MARK: - Duplicating
|
|
|
|
/// Copies the board at `rootURL` to its Finder-style sibling and answers where it landed — the
|
|
/// silent first attempt 03 asks for ("the silent Finder-style sibling is attempted first").
|
|
///
|
|
/// `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.
|
|
///
|
|
/// **This is the one entry point that can answer `.refused`**, because it is the one that chose
|
|
/// the destination: a permission failure here is a question about *where*, and 03 hands that
|
|
/// question to a save panel rather than to a banner.
|
|
///
|
|
/// `isCancelled` is read between items and nowhere else. Its default is the ambient task's own
|
|
/// cancellation, so the caller cancels a duplicate the way it cancels anything else — by
|
|
/// cancelling the task doing it — and a test can trip it deterministically at item *N*.
|
|
static func duplicate(
|
|
boardAt rootURL: URL,
|
|
titled title: String?,
|
|
isCancelled: () -> Bool = { Task.isCancelled }
|
|
) throws(Failure) -> URL {
|
|
try copyBoard(at: rootURL, titled: title, to: copyDestination(for: rootURL), isCancelled: isCancelled)
|
|
}
|
|
|
|
/// Copies the board at `rootURL` to `destination` — the panel's answer, honored verbatim.
|
|
///
|
|
/// The user picked a name and a folder, so neither is second-guessed: no `copy` ladder, no
|
|
/// extension repair, and nothing overwritten (an existing name fails rather than being replaced,
|
|
/// `BoardWriter.createBoard`'s own refusal — the panel's replace prompt grants access, it does
|
|
/// not delete anything).
|
|
///
|
|
/// **A permission failure here is an ordinary failure, not a refusal.** The panel *was* the
|
|
/// sandbox's answer; asking the same question twice would be a loop, so this one gets the banner.
|
|
static func duplicate(
|
|
boardAt rootURL: URL,
|
|
titled title: String?,
|
|
into destination: URL,
|
|
isCancelled: () -> Bool = { Task.isCancelled }
|
|
) throws(Failure) -> URL {
|
|
do {
|
|
return try copyBoard(at: rootURL, titled: title, to: destination, isCancelled: isCancelled)
|
|
} catch {
|
|
if case let .refused(write) = error { throw .failed(write) }
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/// The copy, once the destination is decided: root, walk, restore — with the partial removed on
|
|
/// the way out of either exit that is not a copy.
|
|
private static func copyBoard(
|
|
at rootURL: URL,
|
|
titled title: String?,
|
|
to destination: URL,
|
|
isCancelled: () -> Bool
|
|
) throws(Failure) -> URL {
|
|
let operation = WriteOperation.duplicateBoard(title: title)
|
|
|
|
/// Every failure in one shape, and the refusal/ordinary split decided in one place.
|
|
func failure(at url: URL, _ error: any Error) -> Failure {
|
|
let write = BoardWriteError(
|
|
operation: operation,
|
|
path: url.path,
|
|
reason: .io(message: error.localizedDescription)
|
|
)
|
|
return isPermissionRefusal(error) ? .refused(write) : .failed(write)
|
|
}
|
|
|
|
// The source's own attributes, read before anything is created — and the first thing that
|
|
// fails when the board root isn't there at all, which is why that failure names the source.
|
|
let rootAttributes: [FileAttributeKey: Any]
|
|
do {
|
|
rootAttributes = try FileManager.default.attributesOfItem(atPath: rootURL.path)
|
|
} catch {
|
|
throw failure(at: rootURL, error)
|
|
}
|
|
|
|
// A duplicate cancelled before it began is still a cancelled duplicate; answering here keeps
|
|
// the empty destination from ever existing.
|
|
if isCancelled() { throw .cancelled }
|
|
|
|
// The root first, and **nothing is cleaned up if this fails**: a name already on disk is not
|
|
// ours to remove, and this is also where the sandbox says "not here" — the refusal the save
|
|
// panel answers, raised before a single byte has been copied.
|
|
do {
|
|
try BoardTreeCopy.createDirectory(at: destination)
|
|
} catch {
|
|
throw failure(at: destination, error)
|
|
}
|
|
|
|
do {
|
|
// No exclusions and folder attributes carried: a duplicate is a full fork — `.git`,
|
|
// `.trash/`, strays, modes and dates included (03; 01-storage-format.md § Fractal layout
|
|
// ▸ Rules, "Whole-board copies are the carve-out").
|
|
try BoardTreeCopy.copy(contentsOf: rootURL, into: destination, isCancelled: isCancelled)
|
|
} catch {
|
|
// Cancelled or failed, the partial sibling goes: the tree exists only because this call
|
|
// made it, and half a board is not a state anything should have to render.
|
|
try? FileManager.default.removeItem(at: destination)
|
|
switch error {
|
|
case .cancelled:
|
|
throw .cancelled
|
|
case let .failed(url, underlying):
|
|
throw failure(at: url, underlying)
|
|
}
|
|
}
|
|
|
|
BoardTreeCopy.restoreAttributes(from: rootAttributes, onto: destination)
|
|
// 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
|
|
}
|
|
|
|
// MARK: - Classifying a refusal
|
|
|
|
/// Whether `error` is the sandbox saying **not here** — the one failure 03 answers with a save
|
|
/// panel instead of a banner ("the board's security-scoped bookmark grants its subtree, not its
|
|
/// parent").
|
|
///
|
|
/// Pure, and deliberately narrow on both axes:
|
|
///
|
|
/// - **Write permission only.** `NSFileWriteNoPermissionError` is what `FileManager` reports for
|
|
/// a create it is not allowed to make, and `EACCES`/`EPERM` is what that wraps when the
|
|
/// underlying error survives. A *read* refusal is not in it: the source is inside the grant the
|
|
/// window already holds, so a board we cannot read is a broken board, not a question about
|
|
/// where the copy should go — re-pointing the destination would not help it.
|
|
/// - **Permission only.** A full disk or a read-only volume is a genuine failure with a banner to
|
|
/// its name; offering a save panel for it would be the app pretending the user chose wrong.
|
|
///
|
|
/// **A Cocoa error answers for itself and its chain is not consulted**, which is the subtle half:
|
|
/// Foundation hangs the POSIX `EACCES` under a *read* denial as readily as under a write one, so
|
|
/// a walk that went looking for `EACCES` anywhere would call an unreadable source folder a
|
|
/// question about the destination. Cocoa has already drawn the read/write line; this trusts it.
|
|
/// The underlying chain is only followed out of domains that have not classified anything —
|
|
/// where a bare POSIX `EACCES`/`EPERM` is all the refusal there is to find.
|
|
static func isPermissionRefusal(_ error: any Error) -> Bool {
|
|
let nsError = error as NSError
|
|
if nsError.domain == NSCocoaErrorDomain {
|
|
return nsError.code == NSFileWriteNoPermissionError
|
|
}
|
|
if nsError.domain == NSPOSIXErrorDomain {
|
|
return nsError.code == Int(EACCES) || nsError.code == Int(EPERM)
|
|
}
|
|
guard let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError else { return false }
|
|
return isPermissionRefusal(underlying)
|
|
}
|
|
}
|