Make Duplicate a cancellable per-item walk with a save-panel fallback

The copy now walks the source tree item by item, checking cancellation
between items, and the in-progress banner row carries its promised
Cancel — a cancelled duplicate removes the partial sibling and never
happened (DESIGN/03 > File menu). A sandbox permission refusal of the
silent Finder-style sibling falls back to an NSSavePanel pre-filled with
the parent folder and the copy name — the panel's grant is the sandbox's
own answer; cancelling the panel cancels quietly, and non-permission
failures keep the ordinary one-shot banner. Refusal classification is
deliberately narrow (NSFileWriteNoPermissionError itself, no underlying-
chain walk) so an unreadable source never masquerades as a destination
refusal. Directories are created writable first with mode and timestamps
restored after the subtree lands, so a read-only source folder can't
strand its own copy. BoardDuplicatorTests grows from 9 to 18 tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 07:20:14 -04:00
parent 756e936291
commit bf18512abc
3 changed files with 660 additions and 42 deletions
+298 -15
View File
@@ -18,9 +18,34 @@ import Foundation
/// - 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 `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."
///
/// So the walk *is* the promise: one `FileManager.copyItem` per file bytes and their metadata
/// copied by the file system, never re-encoded, symlinks copied as symlinks folders recreated by
/// hand, and a cancellation read **between** items, never mid-file (a copy interrupted inside a
/// 200 MB pack file leaves rubble that is harder to reason about than one more file's wait). The
/// entries of every folder are walked in name order, so the same cancellation removes the same
/// partial tree every time and a failure names the same file twice running.
///
/// **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.
///
/// The accepted cost of building folders by hand rather than handing the tree to one `copyItem`:
/// **extended attributes on folders do not carry** (files keep theirs each is still copied by
/// `copyItem` and POSIX permissions and timestamps are carried explicitly below). Nothing the app,
/// the storage format, or git keeps lives in a folder xattr.
///
/// ### Not `@MainActor`
///
@@ -32,6 +57,43 @@ import Foundation
/// 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)
}
/// Why a walk stopped and on which item the recursion's private currency, converted to a
/// `Failure` (and the partial removed) the moment it surfaces.
private enum WalkStop: Error {
case cancelled
case failed(url: URL, error: any Error)
}
// 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.
@@ -47,6 +109,10 @@ enum BoardDuplicator {
///
/// `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 {
let parent = rootURL.deletingLastPathComponent()
let base = rootURL.deletingPathExtension().lastPathComponent
@@ -65,27 +131,109 @@ enum BoardDuplicator {
return candidate(name)
}
/// Copies the board at `rootURL` to its Finder-style sibling and answers where it landed.
// 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.
///
/// 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)
/// **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 {
try FileManager.default.copyItem(at: rootURL, to: destination)
return try copyBoard(at: rootURL, titled: title, to: destination, isCancelled: isCancelled)
} catch {
throw BoardWriteError(
operation: .duplicateBoard(title: title),
path: destination.path,
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 createDirectory(at: destination)
} catch {
throw failure(at: destination, error)
}
do {
try copyContents(of: 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)
}
}
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
@@ -93,4 +241,139 @@ enum BoardDuplicator {
// nothing here it lives on the registry record, and the copy's record is born fresh.
return destination
}
// MARK: - The walk
/// Copies everything inside `source` into the already-created `destination`, one item at a time.
///
/// Name order, hidden entries included (no `.skipsHiddenFiles`): `.git`, `.DS_Store` and every
/// other dotfile are part of the fork, and a deterministic order is what makes a cancellation
/// reproducible.
///
/// Each entry's type comes from `attributesOfItem`, which does **not** traverse symlinks so a
/// link is copied as a link (by `copyItem`, which does not follow it either) rather than being
/// mistaken for the folder it points at and walked into.
private static func copyContents(
of source: URL,
into destination: URL,
isCancelled: () -> Bool
) throws(WalkStop) {
let entries: [URL]
do {
entries = try FileManager.default.contentsOfDirectory(
at: source,
includingPropertiesForKeys: nil,
options: []
)
} catch {
throw .failed(url: source, error: error)
}
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
// Between items, never mid-item: this is the whole of "checks cancellation between
// items", and the reason the copy is a walk at all.
if isCancelled() { throw .cancelled }
let attributes: [FileAttributeKey: Any]
do {
attributes = try FileManager.default.attributesOfItem(atPath: entry.path)
} catch {
throw .failed(url: entry, error: error)
}
let isDirectory = attributes[.type] as? FileAttributeType == .typeDirectory
let target = destination.appendingPathComponent(entry.lastPathComponent, isDirectory: isDirectory)
guard isDirectory else {
// Files, symlinks, and whatever else the file system holds: `copyItem` lands the
// bytes and the metadata that rides with them, byte-for-byte, unread.
do {
try FileManager.default.copyItem(at: entry, to: target)
} catch {
throw .failed(url: entry, error: error)
}
continue
}
do {
try createDirectory(at: target)
} catch {
throw .failed(url: entry, error: error)
}
try copyContents(of: entry, into: target, isCancelled: isCancelled)
restoreAttributes(from: attributes, onto: target)
}
}
/// Creates `url` as a plain directory, wearing the process's own default permissions until its
/// contents have landed (`restoreAttributes(from:onto:)` puts the source's back afterwards).
///
/// **Default permissions first, the source's last**, because a folder is not only a thing being
/// copied but the thing being copied *into*: a source folder that is read-only, or unreadable,
/// would otherwise be reproduced as a destination this walk cannot write its own children into
/// and, when something later fails, as a partial tree the cleanup cannot remove either. `cp -R`
/// defers the mode for the same reason.
///
/// `withIntermediateDirectories: false` throughout: every parent either exists already (the walk
/// just made it) or is the one the user pointed at, and inventing a missing folder would be this
/// function deciding where a board lives.
private static func createDirectory(at url: URL) throws {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
}
/// Puts a folder's POSIX permissions and its creation and modification dates back, **after** its
/// contents have landed writing into a folder is itself a modification, and its mode may be
/// what stops the writing (above), so both wait for the subtree to be finished.
///
/// Best effort by design: a volume that will not take a date back (or a file system with no
/// creation dates at all) is not a reason to fail a duplicate that otherwise worked, and neither
/// a folder's timestamp nor its mode is something the storage format reads.
private static func restoreAttributes(from attributes: [FileAttributeKey: Any], onto url: URL) {
var carried: [FileAttributeKey: Any] = [:]
if let permissions = attributes[.posixPermissions] {
carried[.posixPermissions] = permissions
}
if let created = attributes[.creationDate] {
carried[.creationDate] = created
}
if let modified = attributes[.modificationDate] {
carried[.modificationDate] = modified
}
guard !carried.isEmpty else { return }
try? FileManager.default.setAttributes(carried, ofItemAtPath: url.path)
}
// 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)
}
}