Build the menu bar per the Command Nexus
Nexus parity, audited row by row (11-command-nexus.md § Menu commands) — every already-built item's title, chord, and placement matched exactly; this pass fills what remained: - The future-window rows, present with stable titles and validation-driven disablement until their milestones fill the actions: Save as Template (m9), Add Attachment… ⇧⌘A, Find Next/Previous ⌘G/⇧⌘G, the View-menu card triplet Edit Body ⌘E / Raw Source ⌥⌘E / History (m6), Board ▸ Pull/Push (git milestones) — one shared disabled-row shape in FutureCommands.swift so later milestones only flip validation. - No Print story in v1: the print group is removed. - Help carries the Nexus's one remap-teaching line — Customize Keyboard Shortcuts…, opening System Settings' Keyboard ▸ Shortcuts extension directly (the modern extension URL, verified to launch the appex). - The launch-restore decision now runs through a pure, tested AppModel.shouldRestoreAtLaunch gate; the Settings pane's caption rides a proper Form section footer. 904 unit tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -374,6 +374,21 @@ public final class AppModel {
|
|||||||
refreshRecents()
|
refreshRecents()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Launch restoration
|
||||||
|
|
||||||
|
/// The launch-restoration gate, as a pure function (02-architecture.md § Launch and window
|
||||||
|
/// lifecycle: "the preference gates only whether the flagged set is consulted; the flags are
|
||||||
|
/// maintained regardless").
|
||||||
|
///
|
||||||
|
/// `KanbanApp.init()` is where this actually runs — read once, before any scene exists, into a
|
||||||
|
/// `let` rather than a computed property, because `restorables()` costs a bookmark resolution per
|
||||||
|
/// known board and nothing should pay that on every scene-graph evaluation. An `App`'s `init` is
|
||||||
|
/// not itself reachable from a test, so the decision is pulled out to here: two `Bool`s in, one
|
||||||
|
/// out, provable without a real `UserDefaults` domain or a live registry.
|
||||||
|
public static func shouldRestoreAtLaunch(preference: Bool, hasRestorables: Bool) -> Bool {
|
||||||
|
preference && hasRestorables
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Opening
|
// MARK: - Opening
|
||||||
|
|
||||||
public var hasOpenBoards: Bool { !sessions.isEmpty }
|
public var hasOpenBoards: Bool { !sessions.isEmpty }
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - The shared shape
|
||||||
|
|
||||||
|
/// The shape behind every menu row this milestone ships **before** the window that answers it.
|
||||||
|
///
|
||||||
|
/// 11-command-nexus.md's own contract runs both directions: "a command absent here doesn't exist, and
|
||||||
|
/// adding one means adding a row here first" — so once a row *is* in the Nexus, shipping the window
|
||||||
|
/// behind it is a validation-and-action change, not a menu change. `FutureCommand` (a `Button`) and
|
||||||
|
/// `FutureToggleCommand` (a `Toggle`) below are that reading, applied: the row exists now, stably
|
||||||
|
/// titled and stably chorded — `NSUserKeyEquivalents` already resolves it, so a user can remap it
|
||||||
|
/// today — with validation pinned to `false` and the action a no-op until the milestone named at the
|
||||||
|
/// call site fills both in. That milestone's whole diff then reads as "flip `.disabled`, fill the
|
||||||
|
/// closure" rather than "add a menu item", which is also why every call site below carries the
|
||||||
|
/// codebase's `mN-` marker for a component still owed.
|
||||||
|
///
|
||||||
|
/// **The title never moves once a row ships**, disabled or not: a toggle wired live later must not
|
||||||
|
/// gain a second spelling on the way (04-interactions.md ▸ Configurable bindings — "toggles keep one
|
||||||
|
/// stable title, checkmark state only" — which applies to a row that has not started ticking yet
|
||||||
|
/// exactly as it does to one that has).
|
||||||
|
struct FutureCommand: View {
|
||||||
|
let title: String
|
||||||
|
var key: KeyEquivalent?
|
||||||
|
var modifiers: EventModifiers = .command
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(title) {
|
||||||
|
// No-op: the milestone named at the call site wires this in.
|
||||||
|
}
|
||||||
|
.keyboardShortcut(key.map { KeyboardShortcut($0, modifiers: modifiers) })
|
||||||
|
.disabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `FutureCommand`'s checkmark-state twin, for a row the Nexus already marks "(checkmark toggle)".
|
||||||
|
///
|
||||||
|
/// `isOn` is a constant `false` rather than real state: there is no session yet for a binding to
|
||||||
|
/// read, which is exactly the disabled, unchecked state a not-yet-wired toggle should show.
|
||||||
|
struct FutureToggleCommand: View {
|
||||||
|
let title: String
|
||||||
|
var key: KeyEquivalent?
|
||||||
|
var modifiers: EventModifiers = .command
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Toggle(title, isOn: .constant(false))
|
||||||
|
.keyboardShortcut(key.map { KeyboardShortcut($0, modifiers: modifiers) })
|
||||||
|
.disabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File ▸ Save as Template
|
||||||
|
|
||||||
|
/// File ▸ Save as Template — no default chord (11-command-nexus.md; 09-templates.md).
|
||||||
|
///
|
||||||
|
// m9-templates: copies the open board into the user templates store, close-flushed first exactly as
|
||||||
|
// Duplicate is (09 ▸ Save as Template: "The copy is preceded by the close flush"), `.git` stripped,
|
||||||
|
// tombstones dropped, a `template:` key stamped. Validation will be `acceptsBoardMutations` plus 09's
|
||||||
|
// one carve-out from the read-only lock — live under the unwritable-location state unless an open
|
||||||
|
// Edit/raw-source session holds unsaved content — so it cannot simply borrow
|
||||||
|
// `DuplicateBoardCommand`'s predicate outright.
|
||||||
|
struct SaveAsTemplateCommand: View {
|
||||||
|
var body: some View {
|
||||||
|
FutureCommand(title: "Save as Template")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File ▸ Add Attachment…
|
||||||
|
|
||||||
|
/// File ▸ Add Attachment… (⇧⌘A) — card window only (11-command-nexus.md).
|
||||||
|
///
|
||||||
|
// m6-card-window: the menu-bar twin of the attachments section's quiet add affordance
|
||||||
|
// (05-card-window.md § Attachments) and of a whole-window Finder file drop. Validation will be scope
|
||||||
|
// alone — a card window in front, the read-only lock aside — `BoardInfoCommand`'s shape for its own
|
||||||
|
// scope-only item.
|
||||||
|
struct AddAttachmentCommand: View {
|
||||||
|
var body: some View {
|
||||||
|
FutureCommand(title: "Add Attachment…", key: "a", modifiers: [.shift, .command])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Edit ▸ Find Next / Find Previous
|
||||||
|
|
||||||
|
/// Edit ▸ Find Next / Find Previous (⌘G / ⇧⌘G) — the card window's find bar stepping,
|
||||||
|
/// "disabled in the board window — board search is a live filter, not a cursor"
|
||||||
|
/// (11-command-nexus.md).
|
||||||
|
///
|
||||||
|
// m6-card-window: joins `FindCommand` in the Edit menu once the card window's find-in-text exists
|
||||||
|
// (05-card-window.md). Both rows are unconditionally disabled here rather than reading `boardStore`
|
||||||
|
// to prove "board window" disables them: there is no card-window find session anywhere yet for
|
||||||
|
// either validation branch to check.
|
||||||
|
struct FindSteppingCommands: View {
|
||||||
|
var body: some View {
|
||||||
|
FutureCommand(title: "Find Next", key: "g", modifiers: .command)
|
||||||
|
FutureCommand(title: "Find Previous", key: "g", modifiers: [.shift, .command])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View ▸ Edit Body / Raw Source / History
|
||||||
|
|
||||||
|
/// View ▸ Edit Body (⌘E) / Raw Source (⌥⌘E) / History — the card window's three view-state rows
|
||||||
|
/// (11-command-nexus.md).
|
||||||
|
///
|
||||||
|
// m6-card-window: Edit Body and Raw Source are checkmark toggles reading the window's edit-mode
|
||||||
|
// state ("Edit Body disables while Raw Source is active" — 05-card-window.md); History is a plain
|
||||||
|
// command that focuses the sidebar's History section and disables outright on mode `none` /
|
||||||
|
// repo-nested boards once that section exists (05-card-window.md, 07-sync-collab.md). All three are
|
||||||
|
// unconditionally disabled here — there is no card-window mode state anywhere yet.
|
||||||
|
struct CardViewCommands: View {
|
||||||
|
var body: some View {
|
||||||
|
FutureToggleCommand(title: "Edit Body", key: "e", modifiers: .command)
|
||||||
|
FutureToggleCommand(title: "Raw Source", key: "e", modifiers: [.option, .command])
|
||||||
|
FutureCommand(title: "History")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Board ▸ Pull / Push
|
||||||
|
|
||||||
|
/// Board ▸ Pull / Push — no default chord, remote-backed boards only (11-command-nexus.md;
|
||||||
|
/// 07-sync-collab.md: "also Board-menu items").
|
||||||
|
///
|
||||||
|
// m7-git: menu-bar twins of the board popover's own Pull/Push buttons (07-sync-collab.md).
|
||||||
|
// Validation will be remote-mode plus 06's abnormal-state pause ("disabled during 06's
|
||||||
|
// abnormal-state pause ... and on an unresolvable remote" — 11-command-nexus.md). Unconditionally
|
||||||
|
// disabled here: there is no remote model, no popover twin, and no git mode to validate against yet.
|
||||||
|
struct RemoteCommands: View {
|
||||||
|
var body: some View {
|
||||||
|
FutureCommand(title: "Pull")
|
||||||
|
FutureCommand(title: "Push")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -358,7 +358,11 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Toggle("Restore open boards at launch", isOn: $restoreOpenBoardsAtLaunch)
|
Section {
|
||||||
|
Toggle("Restore open boards at launch", isOn: $restoreOpenBoardsAtLaunch)
|
||||||
|
} footer: {
|
||||||
|
Text("On, the boards open at your last quit reopen automatically. Off, every launch starts at Welcome.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.frame(width: 420)
|
.frame(width: 420)
|
||||||
|
|||||||
+63
-25
@@ -1,3 +1,4 @@
|
|||||||
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// The scene graph (02-architecture.md § Windows, § Launch and window lifecycle).
|
/// The scene graph (02-architecture.md § Windows, § Launch and window lifecycle).
|
||||||
@@ -40,8 +41,10 @@ struct KanbanApp: App {
|
|||||||
init() {
|
init() {
|
||||||
let model = AppModel()
|
let model = AppModel()
|
||||||
_appModel = State(initialValue: model)
|
_appModel = State(initialValue: model)
|
||||||
shouldRestoreAtLaunch = AppPreferences.restoreOpenBoardsAtLaunch
|
shouldRestoreAtLaunch = AppModel.shouldRestoreAtLaunch(
|
||||||
&& !model.boardRegistry.restorables().isEmpty
|
preference: AppPreferences.restoreOpenBoardsAtLaunch,
|
||||||
|
hasRestorables: !model.boardRegistry.restorables().isEmpty
|
||||||
|
)
|
||||||
// The delegate is constructed by the adaptor before this runs, so this is the one place the
|
// The delegate is constructed by the adaptor before this runs, so this is the one place the
|
||||||
// app's model and its AppKit half meet.
|
// app's model and its AppKit half meet.
|
||||||
appDelegate.appModel = model
|
appDelegate.appModel = model
|
||||||
@@ -118,20 +121,24 @@ struct KanbanApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The menu items the app has built so far.
|
/// Every menu command 11-command-nexus.md inventories, at full row parity: built and validated
|
||||||
|
/// where the window behind it already exists, present-but-`FutureCommand`-disabled where it
|
||||||
|
/// doesn't (`FutureCommands.swift`).
|
||||||
///
|
///
|
||||||
/// **The titles are API** (04-interactions.md ▸ Configurable bindings): macOS's App Shortcuts
|
/// **The titles are API** (04-interactions.md ▸ Configurable bindings): macOS's App Shortcuts
|
||||||
/// mechanism remaps menu items *by title*, so these strings are the keys a user's custom binding
|
/// mechanism — `NSUserKeyEquivalents` under the hood — remaps menu items *by title*, so these
|
||||||
/// is stored under. They are spelled exactly as 11-command-nexus.md inventories them, and
|
/// strings are the keys a user's custom binding is stored under. They are spelled exactly as
|
||||||
/// changing one silently breaks every remap of it.
|
/// 11-command-nexus.md inventories them, unique across the whole menu bar, and changing one
|
||||||
|
/// silently breaks every remap of it. Nothing below builds that mechanism — a stable title is the
|
||||||
|
/// whole of the contract, and the system supplies the rest.
|
||||||
@CommandsBuilder
|
@CommandsBuilder
|
||||||
private var menuCommands: some Commands {
|
private var menuCommands: some Commands {
|
||||||
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board…,
|
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board…,
|
||||||
// Open…, Open Recent ▸, Board Info, Duplicate, (Save as Template, still owed — 09),
|
// Open…, Open Recent ▸, Board Info, Duplicate, Save as Template, Reveal in Finder, Add
|
||||||
// Reveal in Finder, then the trash trio and Empty Trash…. The board-scoped ones validate
|
// Attachment…, then the trash trio and Empty Trash…. The board-scoped ones validate against
|
||||||
// against the frontmost board through the focus system, so each is simply absent-of-effect
|
// the frontmost board through the focus system, so each is simply absent-of-effect when no
|
||||||
// when no board is in front; New Board… and Open Recent are available everywhere, including
|
// board is in front; New Board… and Open Recent are available everywhere, including with no
|
||||||
// with no window at all.
|
// window at all. Close is the system's own item — no row of ours to add.
|
||||||
CommandGroup(after: .newItem) {
|
CommandGroup(after: .newItem) {
|
||||||
BoardCreationCommands()
|
BoardCreationCommands()
|
||||||
NewBoardCommand(appModel: appModel)
|
NewBoardCommand(appModel: appModel)
|
||||||
@@ -152,37 +159,42 @@ struct KanbanApp: App {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
DuplicateBoardCommand(appModel: appModel)
|
DuplicateBoardCommand(appModel: appModel)
|
||||||
|
SaveAsTemplateCommand()
|
||||||
RevealInFinderCommand()
|
RevealInFinderCommand()
|
||||||
|
AddAttachmentCommand()
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
TrashCommands()
|
TrashCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Edit menu's one row of ours: Find (⌘F), placed after the standard Cut/Copy/Paste/
|
// The Edit menu: Find (⌘F), then its card-window stepping twins, placed after the standard
|
||||||
// Select All group, which is where macOS puts Find. Undo/Redo and the clipboard items are
|
// Cut/Copy/Paste/Select All group, which is where macOS puts Find. Undo/Redo and the
|
||||||
// the system's and the board answers them as a responder (`ClipboardCommands.swift`) — a
|
// clipboard items are the system's and the board answers them as a responder
|
||||||
// second item sharing one of those titles is what titles-are-API forbids.
|
// (`ClipboardCommands.swift`) — a second item sharing one of those titles is what
|
||||||
//
|
// titles-are-API forbids.
|
||||||
// m6-card-window: Find Next / Find Previous (⌘G/⇧⌘G) join here, scoped to the card window's
|
|
||||||
// find bar and "disabled in the board window — board search is a live filter, not a cursor"
|
|
||||||
// (11-command-nexus.md). They wait for the window that owns them rather than shipping as two
|
|
||||||
// permanently disabled rows.
|
|
||||||
CommandGroup(after: .pasteboard) {
|
CommandGroup(after: .pasteboard) {
|
||||||
FindCommand()
|
FindCommand()
|
||||||
|
FindSteppingCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own
|
// The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own
|
||||||
// items live in — which is where 11-command-nexus.md files Show Trash, alongside the card
|
// items live in — which is where 11-command-nexus.md files Show Trash. A divider separates it
|
||||||
// window's Edit Body / Raw Source / History still to come.
|
// from the card window's three view-state rows below: one board-scoped toggle, then a
|
||||||
|
// card-scoped trio.
|
||||||
CommandGroup(after: .toolbar) {
|
CommandGroup(after: .toolbar) {
|
||||||
ShowTrashCommand()
|
ShowTrashCommand()
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
CardViewCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open
|
// The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open
|
||||||
// Card, Rename, Style…, the card moves, the lane moves, the width pair. Its items act on the
|
// Card, Rename, Style…, the card moves, the lane moves, the width pair, then the remote pair.
|
||||||
// frontmost board window, which they reach through the focus system rather than through the
|
// Its items act on the frontmost board window, which they reach through the focus system
|
||||||
// app model — see `BoardCommands.swift`, which also owns their validation.
|
// rather than through the app model — see `BoardCommands.swift`, which also owns their
|
||||||
|
// validation.
|
||||||
CommandMenu("Board") {
|
CommandMenu("Board") {
|
||||||
OpenCardCommand()
|
OpenCardCommand()
|
||||||
BoardRenameCommand()
|
BoardRenameCommand()
|
||||||
@@ -196,6 +208,10 @@ struct KanbanApp: App {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
LaneWidthCommands()
|
LaneWidthCommands()
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
RemoteCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandGroup(after: .windowList) {
|
CommandGroup(after: .windowList) {
|
||||||
@@ -205,5 +221,27 @@ struct KanbanApp: App {
|
|||||||
appModel.showWelcome()
|
appModel.showWelcome()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "No Print story in v1 (⌘P unused)" (11-command-nexus.md ▸ Standard macOS furniture) — the
|
||||||
|
// system's default Print item is removed outright rather than left dead, since a menu item
|
||||||
|
// with nothing behind it is exactly what the Nexus's "a command absent here doesn't exist"
|
||||||
|
// rules out in the other direction too.
|
||||||
|
CommandGroup(replacing: .printItem) {}
|
||||||
|
|
||||||
|
// Help carries "the one line teaching the System Settings remap path" (11-command-nexus.md ▸
|
||||||
|
// Standard macOS furniture) — this app's whole Help menu, since there is no other content to
|
||||||
|
// give it. The pane identifier is the modern System Settings extension id, not the legacy
|
||||||
|
// `com.apple.preference.keyboard` prefpane path: verified on macOS 26 by observing
|
||||||
|
// `KeyboardSettings.appex` (service `com.apple.Keyboard-Settings.extension`) launch in
|
||||||
|
// response to this exact URL. `?Shortcuts` is as deep as a URL reaches; **App Shortcuts**
|
||||||
|
// itself is one more click, the sidebar row inside that pane.
|
||||||
|
CommandGroup(after: .help) {
|
||||||
|
Button("Customize Keyboard Shortcuts…") {
|
||||||
|
guard let url = URL(
|
||||||
|
string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension?Shortcuts"
|
||||||
|
) else { return }
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,4 +300,24 @@ struct AppModelTests {
|
|||||||
|
|
||||||
model.storeRegistry.release(try #require(model.session(for: ref)?.store))
|
model.storeRegistry.release(try #require(model.session(for: ref)?.store))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Launch restoration
|
||||||
|
|
||||||
|
/// App ▸ Settings…'s "Restore open boards at launch" (11-command-nexus.md) gates the flagged set
|
||||||
|
/// by AND, not by either half alone: the preference off never restores even with boards flagged
|
||||||
|
/// (a user who turned it off gets welcome, full stop), and the preference on restores nothing when
|
||||||
|
/// there is nothing flagged (an ordinary first launch, which shows welcome exactly as it always
|
||||||
|
/// has, not an empty restoration pass).
|
||||||
|
@Test(
|
||||||
|
"The launch-restoration gate is the preference AND something to restore",
|
||||||
|
arguments: [
|
||||||
|
(preference: true, hasRestorables: true, expected: true),
|
||||||
|
(preference: true, hasRestorables: false, expected: false),
|
||||||
|
(preference: false, hasRestorables: true, expected: false),
|
||||||
|
(preference: false, hasRestorables: false, expected: false),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
func launchRestorationGate(preference: Bool, hasRestorables: Bool, expected: Bool) {
|
||||||
|
#expect(AppModel.shouldRestoreAtLaunch(preference: preference, hasRestorables: hasRestorables) == expected)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user