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. /// 3. **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. /// /// ### 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 // 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)'…") 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 appModel.openBoard(at: copy) } catch let error as BoardWriteError { 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) )) } } } } // 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] } }