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 { // 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? private(set) var isCancelled = false func attach(_ task: Task) { 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 trash 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". /// /// **The card-window scope is the third branch**, and it is the one the attachment row's context /// menu twins (11-command-nexus.md ▸ Context menus): "card window: the card's folder — the selected /// attachment's file instead when the attachments section is focused". The rule itself is /// `CardAttachments.revealURLs`, so the menu row and the row's own Reveal cannot disagree about what /// "the selected attachment" means. struct RevealInFinderCommand: View { @FocusedValue(\.boardStore) private var store @FocusedValue(\.cardAttachments) private var attachments @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 card-window branch stands when a card /// window is; the welcome branch stands when neither is. private var urls: [URL] { if let store { let ids = store.selection.ids guard !ids.isEmpty else { return [store.rootURL] } return ItemPath.resolve(ids, in: store.selection.container, snapshot: store.snapshot) .map { $0.folder(under: store.rootURL) } } if let attachments { return CardAttachments.revealURLs( cardFolder: attachments.cardFolder, selectedURL: attachments.selectedURL, isSectionFocused: attachments.isFocused ) } guard let selection, selection.canReveal, let url = selection.url else { return [] } return [url] } }