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
+219 -5
View File
@@ -12,9 +12,13 @@ import Testing
/// born exactly matching its history";
/// - it doesn't overwrite the Finder-style `copy` ladder renames instead.
///
/// And a fourth, which the per-item walk exists for: **it doesn't leave residue** a cancelled or
/// failed copy removes its own partial sibling, "a cancelled duplicate never happened".
///
/// Tested against real folders, because every one of those is a claim about bytes on disk. The
/// window flow around the copy (the flush, the banner row, the open that follows) is the command's,
/// not this type's.
/// window flow around the copy (the flush, the banner row, the save panel, the open that follows) is
/// the command's, not this type's what belongs here is the vocabulary that flow branches on:
/// cancelled, refused, failed.
// MARK: - Fixtures
@@ -54,6 +58,21 @@ private func tree(of root: URL) throws -> Set<String> {
return paths
}
/// `writeFailure`'s twin for the duplicate's own three-outcome vocabulary: the copy answers
/// `.cancelled`, `.refused` or `.failed`, and which one is the whole question every test below asks.
private func duplicateFailure(_ operation: () throws -> Void) -> BoardDuplicator.Failure? {
do {
try operation()
Issue.record("expected the duplicate to fail, but it succeeded")
return nil
} catch let failure as BoardDuplicator.Failure {
return failure
} catch {
Issue.record("expected a BoardDuplicator.Failure, got \(error)")
return nil
}
}
// MARK: - Tests
@MainActor
@@ -180,10 +199,205 @@ struct BoardDuplicatorTests {
defer { board.fixture.tearDown() }
let missing = board.fixture.url("Never.kanban")
let error = writeFailure { _ = try BoardDuplicator.duplicate(boardAt: missing, titled: "Never") }
let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: missing, titled: "Never") }
#expect(error?.operation == .duplicateBoard(title: "Never"),
// An ordinary failure, not a refusal: a source that isn't there is not a question about
// where the copy should go, so no save panel could help it.
guard case let .failed(error) = failure else {
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
return
}
#expect(error.operation == .duplicateBoard(title: "Never"),
"the user pressed Duplicate; the banner must say so")
#expect(BannerCenter.headline(for: try #require(error)).hasPrefix("Couldn't duplicate 'Never'"))
#expect(BannerCenter.headline(for: error).hasPrefix("Couldn't duplicate 'Never'"))
}
// MARK: - The per-item walk
@Test("Hidden folders, empty ones and nested strays all ride along — the walk is not the loader")
func theWalkCarriesEverythingATreeCanHold() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
// A `.git`-shaped hidden folder with a file in it, an empty folder inside that (the shape a
// fresh repository actually has), and a stray beside a card's index.
try board.fixture.file("Roadmap.kanban/.git/HEAD", Data("ref: refs/heads/main\n".utf8))
try FileManager.default.createDirectory(
at: board.root.appendingPathComponent(".git/objects", isDirectory: true),
withIntermediateDirectories: true
)
try board.fixture.file("Roadmap.kanban/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01, 0x02]))
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
#expect(try tree(of: copy) == tree(of: board.root),
"an item-by-item walk carries exactly what one copyItem would have")
#expect(try Data(contentsOf: copy.appendingPathComponent(".git/HEAD"))
== Data("ref: refs/heads/main\n".utf8))
var isDirectory: ObjCBool = false
#expect(FileManager.default.fileExists(
atPath: copy.appendingPathComponent(".git/objects").path,
isDirectory: &isDirectory
) && isDirectory.boolValue, "an empty folder is a folder, not nothing")
}
// MARK: - Cancel
@Test("Cancelling between items removes the partial sibling — a cancelled duplicate never happened")
func cancellingRemovesThePartialSibling() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
let before = try tree(of: board.root)
// Trips on the fourth read: the root folder exists by then and the walk is two levels deep
// into it, so there is a genuine partial tree to remove rather than nothing to clean up.
var reads = 0
let failure = duplicateFailure {
_ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", isCancelled: {
reads += 1
return reads > 3
})
}
#expect(failure == BoardDuplicator.Failure.cancelled)
#expect(try Set(board.fixture.entryNames("")) == ["Roadmap.kanban"],
"no 'Roadmap copy.kanban' survives — the partial went with the cancellation")
#expect(try tree(of: board.root) == before, "and the original was never touched")
}
@Test("A duplicate cancelled before it began never creates the destination at all")
func cancellingBeforeTheFirstItemCreatesNothing() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
let failure = duplicateFailure {
_ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", isCancelled: { true })
}
#expect(failure == BoardDuplicator.Failure.cancelled)
#expect(try Set(board.fixture.entryNames("")) == ["Roadmap.kanban"])
}
@Test("A failure mid-walk removes the partial sibling too, and says what went wrong")
func aFailedWalkRemovesThePartialSibling() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
// A card folder the walk cannot list, standing in for any I/O that gives out part-way.
let unreadable = board.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)")
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: unreadable.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: unreadable.path) }
let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") }
guard case let .failed(error) = failure else {
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
return
}
#expect(error.operation == .duplicateBoard(title: "Roadmap"))
#expect(error.path == unreadable.path, "fail-fast names the item it stopped on")
#expect(try Set(board.fixture.entryNames("")) == ["Roadmap.kanban"],
"a half-copied board is residue — it goes, and the banner is what remains")
}
// MARK: - Refusal and the panel's answer
@Test("A sandbox refusal of the sibling is a refusal, not a failure — the save panel's cue")
func anUnwritableParentIsARefusal() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
// The board's *parent* is unwritable while the board itself is not: exactly the shape of a
// bookmark that grants a subtree and not the folder above it.
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: board.fixture.root.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: board.fixture.root.path) }
let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") }
guard case let .refused(error) = failure else {
Issue.record("expected a refusal, got \(String(describing: failure))")
return
}
#expect(error.operation == .duplicateBoard(title: "Roadmap"))
}
@Test("A refusal at a destination the user chose is an ordinary failure — the panel isn't asked twice")
func aRefusalAtAnAnsweredDestinationIsAnOrdinaryFailure() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: board.fixture.root.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: board.fixture.root.path) }
let chosen = board.fixture.url("Chosen.kanban")
let failure = duplicateFailure {
_ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", into: chosen)
}
guard case .failed = failure else {
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
return
}
}
@Test("A chosen destination is honored verbatim, name and location alike")
func aChosenDestinationIsHonoredVerbatim() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
let elsewhere = board.fixture.url("Elsewhere")
try FileManager.default.createDirectory(at: elsewhere, withIntermediateDirectories: true)
// Not the "copy" ladder's answer at all: another folder, another name, no extension.
let chosen = elsewhere.appendingPathComponent("Fork", isDirectory: true)
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", into: chosen)
#expect(copy == chosen)
#expect(try tree(of: copy) == tree(of: board.root))
}
@Test("A chosen name already on disk fails, and is never overwritten or removed")
func aChosenNameAlreadyTakenFails() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
let occupied = try board.fixture.file("Taken.kanban", Data("not a board, and not yours to delete".utf8))
let failure = duplicateFailure {
_ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", into: occupied)
}
guard case .failed = failure else {
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
return
}
#expect(try board.fixture.data("Taken.kanban") == Data("not a board, and not yours to delete".utf8),
"the destination this call did not create is the one thing cleanup never removes")
}
// MARK: - Classifying a refusal
@Test("Only a write-permission denial is the panel's question; everything else is a banner")
func permissionRefusalsAreClassifiedNarrowly() {
#expect(BoardDuplicator.isPermissionRefusal(
NSError(domain: NSCocoaErrorDomain, code: NSFileWriteNoPermissionError)
))
#expect(BoardDuplicator.isPermissionRefusal(
NSError(domain: NSPOSIXErrorDomain, code: Int(EACCES))
))
#expect(BoardDuplicator.isPermissionRefusal(NSError(
domain: "SomeWrapper",
code: 1,
userInfo: [NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(EPERM))]
)), "an unclassified wrapper is followed through to the refusal underneath")
#expect(!BoardDuplicator.isPermissionRefusal(
NSError(domain: NSCocoaErrorDomain, code: NSFileWriteOutOfSpaceError)
), "a full disk is a real failure — a save panel would be the app blaming the user")
#expect(!BoardDuplicator.isPermissionRefusal(
NSError(domain: NSCocoaErrorDomain, code: NSFileWriteVolumeReadOnlyError)
))
#expect(!BoardDuplicator.isPermissionRefusal(NSError(
domain: NSCocoaErrorDomain,
code: NSFileReadNoPermissionError,
userInfo: [NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(EACCES))]
)), """
a source we cannot read is a broken board, not a question about where the copy goes — and \
Foundation hangs the same EACCES under it, which is why the chain is not searched blindly
""")
}
}