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:
+143
-22
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user