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
+143 -22
View File
@@ -108,10 +108,27 @@ struct OpenRecentMenu: View {
/// 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
/// 2. **The copy** `BoardDuplicator`, off the main actor so the spinner can spin, and cancellable:
/// the in-progress row carries Cancel, which cancels the copy task, and the walk removes its own
/// partial sibling on the way out ("a cancelled duplicate never happened").
/// 3. **The save panel, but only on a refusal** "the silent Finder-style sibling is attempted
/// first; on a permission refusal a save panel opens pre-filled with the parent folder and the
/// 'copy' name the panel's grant is the sandbox's own answer, and it doubles as a
/// choose-another-location affordance" (03, settled). The board's bookmark grants its own subtree,
/// not its parent, so the sibling may simply be unwritable; that is a question about *where*, and
/// the panel is where the sandbox answers it.
/// 4. **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.
///
/// ### Three endings, and only one of them speaks
///
/// A copy that lands opens. A copy the user **cancelled** the row's Cancel, or the save panel's
/// says nothing at all: "cancelling the panel cancels the duplicate quietly (no banner the user
/// declined, nothing failed)", and the row's Cancel is the same sentence about the same gesture.
/// Everything else a full disk, a name already taken is the ordinary one-shot banner
/// (02-architecture.md § Write-failure surfacing). `BoardDuplicator.Failure`'s three cases are those
/// three endings, switched exhaustively below so a fourth could not be forgotten.
///
/// ### Validation
///
/// Board window only, so a welcome-selected recent can never be duplicated by accident 03 says it
@@ -151,37 +168,141 @@ struct DuplicateBoardCommand: View {
let source = store.rootURL
Task { @MainActor in
let cancellation = DuplicateCancellation()
// 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)'…")
// tone, pinned, cleared on completion, swapped for the error row on failure and
// carrying Cancel, which 02 promises on copy-shaped work ("remove the partial copy,
// nothing lost") and 03 spends on this command by name.
let operation = store.banners.beginOperation(
label: "Duplicating '\(name)'…",
cancel: { cancellation.cancel() }
)
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
// 1. The silent Finder-style sibling.
var outcome = await copy(source, titled: name, into: nil, cancellation: cancellation)
// 2. The save panel, and only where 03 puts it: a *permission* refusal of that sibling.
if case let .failure(.refused(refusal)) = outcome {
Self.logger.notice("duplicate refused: \(refusal.description, privacy: .public)")
guard let chosen = Self.chooseDestination(for: source) else {
// The user declined. Nothing failed, so nothing is said.
return
}
outcome = await copy(source, titled: name, into: chosen, cancellation: cancellation)
}
switch outcome {
case let .success(copy):
appModel.openBoard(at: copy)
} catch let error as BoardWriteError {
case .failure(.cancelled):
// The walk removed its own partial sibling; the row leaves with the `defer`.
Self.logger.notice("duplicate cancelled — the partial copy was removed")
// `.refused` cannot reach here (the panel path either returns above or asks a
// destination that never refuses again), but it carries a real failure, so if it ever
// did, it would be said out loud rather than swallowed.
case let .failure(.failed(error)), let .failure(.refused(error)):
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)
))
}
}
}
/// One attempt at the copy off the main actor, wired to the banner row's Cancel.
///
/// `Task.detached` rather than a child task, for both halves of the reason: 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 running it off the actor is safe); and a detached
/// task's cancellation is *only* the Cancel button's, never something inherited from whatever
/// else the enclosing task is doing.
///
/// `destination` is `nil` for the Finder-style sibling and the panel's answer otherwise the two
/// `BoardDuplicator` entry points, which differ only in who chose the location and therefore in
/// whether a permission failure is a question or an answer.
private func copy(
_ source: URL,
titled name: String,
into destination: URL?,
cancellation: DuplicateCancellation
) async -> Result<URL, BoardDuplicator.Failure> {
// Cancelled during the flush: the copy never starts, rather than starting and being told to
// stop the same outcome, reached without making a folder to delete.
guard !cancellation.isCancelled else { return .failure(.cancelled) }
let task = Task.detached(priority: .userInitiated) {
if let destination {
return try BoardDuplicator.duplicate(boardAt: source, titled: name, into: destination)
}
return try BoardDuplicator.duplicate(boardAt: source, titled: name)
}
cancellation.attach(task)
do {
return .success(try await task.value)
} catch let failure as BoardDuplicator.Failure {
return .failure(failure)
} catch {
return .failure(.failed(BoardWriteError(
operation: .duplicateBoard(title: name),
path: source.path,
reason: .io(message: error.localizedDescription)
)))
}
}
/// The save panel a refusal hands the question to (03, settled) "pre-filled with the parent
/// folder and the 'copy' name".
///
/// Both pre-fills are the sibling the app just failed to write, so the panel opens showing
/// exactly what would have happened silently, and one Return makes it happen. Whatever the user
/// changes is then honored verbatim (`BoardDuplicator.duplicate(boardAt:titled:into:)`): the
/// panel is a location grant *and* a choose-another-location affordance, and second-guessing the
/// name it returns would break the second half.
///
/// `nil` is the user declining, which this command answers with silence. The panel is modal,
/// like the template chooser's the in-progress row stays up behind it, because the duplicate
/// genuinely is still in progress.
private static func chooseDestination(for source: URL) -> URL? {
let panel = NSSavePanel()
panel.directoryURL = source.deletingLastPathComponent()
panel.nameFieldStringValue = BoardDuplicator.copyDestination(for: source).lastPathComponent
panel.canCreateDirectories = true
panel.isExtensionHidden = false
panel.allowsOtherFileTypes = true
panel.prompt = "Duplicate"
panel.message = "Choose where to keep the duplicate."
guard panel.runModal() == .OK, let url = panel.url else { return nil }
return url
}
}
/// The Cancel button's end of a running duplicate: the one piece of state the banner row's `cancel`
/// closure and the copy task have to share.
///
/// **A main-actor box rather than a lock**, because there is nothing here to race over: the row's
/// `cancel` is `@MainActor @Sendable`, and the task is created and attached on the same actor. The
/// walk itself reads no shared state at all it reads its own `Task.isCancelled`, which `cancel()`
/// sets by cancelling the task so this type exists only to close the window between the row
/// appearing and the copy task existing. A Cancel pressed during the flush must not be forgotten by
/// the task that starts after it, which is what `isCancelled` is for.
@MainActor
private final class DuplicateCancellation {
private var task: Task<URL, any Error>?
private(set) var isCancelled = false
func attach(_ task: Task<URL, any Error>) {
self.task = task
if isCancelled { task.cancel() }
}
func cancel() {
isCancelled = true
task?.cancel()
}
}
// MARK: - Reveal in Finder