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
353 lines
16 KiB
Swift
353 lines
16 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import os
|
|
|
|
// MARK: - Focused values
|
|
|
|
/// The frontmost board window's **identity**, published beside its store by `BoardWindowHost`.
|
|
///
|
|
/// `FocusedBoardStoreKey` answers "which board is in front"; this answers "which *window*", which is
|
|
/// a different question and the one File ▸ Duplicate has to ask: the flush that precedes a copy is
|
|
/// keyed on the window's session, not on the store (`AppModel.flushPendingWork(for:)`).
|
|
struct FocusedBoardWindowRefKey: FocusedValueKey {
|
|
typealias Value = BoardWindowRef
|
|
}
|
|
|
|
/// The welcome window's selected recents row — File ▸ Reveal in Finder's welcome scope.
|
|
struct FocusedWelcomeSelectionKey: FocusedValueKey {
|
|
typealias Value = WelcomeRow
|
|
}
|
|
|
|
extension FocusedValues {
|
|
var boardWindowRef: BoardWindowRef? {
|
|
get { self[FocusedBoardWindowRefKey.self] }
|
|
set { self[FocusedBoardWindowRefKey.self] = newValue }
|
|
}
|
|
|
|
var welcomeSelection: WelcomeRow? {
|
|
get { self[FocusedWelcomeSelectionKey.self] }
|
|
set { self[FocusedWelcomeSelectionKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// MARK: - New Board
|
|
|
|
/// File ▸ New Board… (⌥⌘N) — the template chooser's entry point (11-command-nexus.md;
|
|
/// 09-templates.md).
|
|
///
|
|
/// **⌥⌘N, not ⌘N**: ⌘N is New *Card*, which is the command a board window user reaches for a hundred
|
|
/// times a day, so the rarer creation wears the modifier. Available everywhere — a new board needs
|
|
/// no board in front, and the welcome window's own button is this item's twin.
|
|
struct NewBoardCommand: View {
|
|
|
|
let appModel: AppModel
|
|
|
|
var body: some View {
|
|
Button("New Board…") {
|
|
appModel.showTemplateChooser()
|
|
}
|
|
.keyboardShortcut("n", modifiers: [.option, .command])
|
|
}
|
|
}
|
|
|
|
// MARK: - Open Recent
|
|
|
|
/// File ▸ Open Recent ▸ (11-command-nexus.md: "Everywhere; reads the board registry").
|
|
///
|
|
/// The registry, rendered as a menu — same rows as the welcome list, through the same derivation, so
|
|
/// the two can never disagree about a board's name or about whether it can be opened. An
|
|
/// unavailable board is **listed and disabled** rather than hidden, which is the recents row's own
|
|
/// posture (02 § Graceful orphaning) applied to a menu: a board that has gone missing is information,
|
|
/// and a menu that quietly shortened itself would be the app forgetting on the user's behalf.
|
|
///
|
|
/// Clear Menu sits at the bottom, where Finder puts it. See `AppModel.clearRecents` for the
|
|
/// equivalence it rests on — the registry *is* this menu, so clearing the menu clears the registry.
|
|
struct OpenRecentMenu: View {
|
|
|
|
let appModel: AppModel
|
|
|
|
/// The failures are deliberately not joined in here: a menu item has no room for fail-fast's
|
|
/// specifics, and a board that failed to open is still a board the user may want to try again.
|
|
/// The failure's surface is the welcome row (02 § Launch and window lifecycle).
|
|
private var rows: [WelcomeRow] {
|
|
WelcomeRow.derive(recents: appModel.recents, failures: []).rows
|
|
}
|
|
|
|
var body: some View {
|
|
let rows = self.rows
|
|
|
|
Menu("Open Recent") {
|
|
ForEach(rows) { row in
|
|
Button(row.displayName) {
|
|
guard let url = row.url else { return }
|
|
appModel.openBoard(at: url)
|
|
}
|
|
.disabled(!row.canOpen)
|
|
}
|
|
|
|
if !rows.isEmpty {
|
|
Divider()
|
|
}
|
|
|
|
Button("Clear Menu") {
|
|
appModel.clearRecents()
|
|
}
|
|
.disabled(rows.isEmpty)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Duplicate
|
|
|
|
/// File ▸ Duplicate (⇧⌘S) — **the board**, never the selection (11-command-nexus.md, 03-board-ui.md
|
|
/// § Welcome screen & templates).
|
|
///
|
|
/// ### What it does, in the order 03 fixes
|
|
///
|
|
/// 1. **The flush first** — "The copy is preceded by the close flush ... so neither the tree nor the
|
|
/// 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, 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
|
|
/// "never acts on a welcome-selected recent", and scoping the item to the focused board window is
|
|
/// how that is enforced rather than remembered.
|
|
///
|
|
/// **Disabled under the read-only lock in every state** (03: "the flush can't run and the sibling
|
|
/// destination shares the board's fate"). It uses `acceptsBoardMutations`, which adds the
|
|
/// focused-inline-editor half of 04's rule to the lock 03 names — a deliberate reading rather than a
|
|
/// slip: an open title editor holds the one pending change no flush can reach, and a duplicate taken
|
|
/// mid-rename would be a fork missing the edit the user is in the middle of making.
|
|
struct DuplicateBoardCommand: View {
|
|
|
|
let appModel: AppModel
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
@FocusedValue(\.boardWindowRef) private var ref
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "duplicate")
|
|
|
|
var body: some View {
|
|
Button("Duplicate") {
|
|
duplicate()
|
|
}
|
|
.keyboardShortcut("s", modifiers: [.shift, .command])
|
|
.disabled(!canDuplicate)
|
|
}
|
|
|
|
private var canDuplicate: Bool {
|
|
guard let store, ref != nil else { return false }
|
|
return store.acceptsBoardMutations
|
|
}
|
|
|
|
private func duplicate() {
|
|
guard canDuplicate, let store, let ref else { return }
|
|
let name = AppModel.displayName(of: store)
|
|
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 — 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)
|
|
|
|
// 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)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
|
|
/// File ▸ Reveal in Finder — the welcome and board scopes (11-command-nexus.md: "Board window: the
|
|
/// selection's folder(s), or the board root with nothing selected; … welcome: the selected recent's
|
|
/// folder (disabled on unavailable rows) — the context-menu entry's required twin").
|
|
///
|
|
/// It is here because the welcome row's context menu is: 11 files the menu-bar item as that entry's
|
|
/// *required* twin, so shipping one without the other would leave the context menu as the only path
|
|
/// to a command — the thing 04's contract forbids.
|
|
///
|
|
/// **The board scope reveals either side of the trash boundary and ignores every lock.** Reveal "is
|
|
/// not edit-shaped and stays enabled on tombstoned selections" (04 ▸ The trash), and inspection is a
|
|
/// read, so neither the read-only lock nor the focused-editor rule applies — the same posture the
|
|
/// trash row's own Reveal takes. A selection whose ids resolve to no folders (one the next reload
|
|
/// will drop) disables rather than falling back to the root: revealing the wrong thing is worse
|
|
/// than nothing, and only a genuinely empty selection means "the board".
|
|
///
|
|
// m6-card-window: the item's third scope — the card's folder, or the selected attachment's file
|
|
// when the attachments section is focused — adds a focused value and a branch here; the two below
|
|
// do not move.
|
|
struct RevealInFinderCommand: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
@FocusedValue(\.welcomeSelection) private var selection
|
|
|
|
var body: some View {
|
|
Button("Reveal in Finder") {
|
|
NSWorkspace.shared.activateFileViewerSelecting(urls)
|
|
}
|
|
.disabled(urls.isEmpty)
|
|
}
|
|
|
|
/// What the item would reveal, and therefore whether it is enabled — one answer for both, the
|
|
/// codebase's usual shape. The board in front wins; the welcome branch stands when no board is.
|
|
private var urls: [URL] {
|
|
if let store {
|
|
let ids = store.selection.ids
|
|
guard !ids.isEmpty else { return [store.rootURL] }
|
|
return TrashModel.paths(of: ids, on: store.selection.liveness, in: store.snapshot)
|
|
.map { $0.folder(under: store.rootURL) }
|
|
}
|
|
guard let selection, selection.canReveal, let url = selection.url else { return [] }
|
|
return [url]
|
|
}
|
|
}
|