Files
lanework/Kanban/App/AppCommands.swift
T
rzen 4b97ecf3f0 Build the welcome screen
The welcome window becomes the real thing: Xcode-style, hidden title
bar with background drag, branding and actions left, recents right —
rows carrying the board symbol, name, location, and the registry's
cached lane/card counts (stamped at close, never a scan at welcome
time), sorted by last opened. Launch failures surface row-level per
02: a failure joins its recents row as a warning caption, an
unresolvable bookmark renders unavailable with Forget its one
affordance, and only a failure with no row to carry it falls back to
a compact list; a board opening again heals its row. New Board
(Opt-Cmd-N) opens the Pages-style template chooser — shipped with
the single Basic template and the m9 seams marked — flowing through
the save panel into createBoard/createLane and straight into a board
window. Open Recent gains its submenu with Clear Menu (byte-identical
to forgetting every row, pinned by test), and File > Duplicate forks
the frontmost board to a Finder-style copy sibling: pending work
flushes first through the close flush's step two alone (sessions stay
open — 09's stated exception), every GUID and tombstone carries (the
whole-board carve-out from copies-remint), and the copy opens in its
own window while the original stays put. 36 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 16:50:39 -04:00

213 lines
8.8 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.
/// 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 scope** (11-command-nexus.md: "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.
///
// m5-context-menus, m6-card-window: the item's other two scopes. Board window — the selection's
// folder(s), or the board root with nothing selected — arrives with the board's own context menus;
// card window — the card's folder, or the selected attachment's file when the attachments section is
// focused — with the card window. Each adds a focused value and a branch here; the welcome branch
// does not move.
struct RevealInFinderCommand: View {
@FocusedValue(\.welcomeSelection) private var selection
var body: some View {
Button("Reveal in Finder") {
guard let url = selection?.url else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
}
.disabled(selection?.canReveal != true)
}
}