From 1e5d83d4a320272e240714e82ecc0df9a58633dd Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 2 May 2026 02:57:45 +0200 Subject: [PATCH] refactor(commands): share app command manifest --- docs/ABSTRACTIONS.md | 6 + docs/ARCHITECTURE.md | 7 +- docs/adr/0106-shared-app-command-manifest.md | 31 + docs/adr/README.md | 1 + src-tauri/src/menu.rs | 872 +++++++++---------- src/components/LinuxMenuButton.tsx | 132 +-- src/components/LinuxTitlebar.test.tsx | 1 + src/hooks/appCommandCatalog.ts | 463 ++++------ src/hooks/appCommandDispatcher.test.ts | 27 + src/shared/appCommandManifest.json | 536 ++++++++++++ 10 files changed, 1199 insertions(+), 877 deletions(-) create mode 100644 docs/adr/0106-shared-app-command-manifest.md create mode 100644 src/shared/appCommandManifest.json diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index c8f43b16..1e6c41d9 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -334,6 +334,12 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move - Plain click / `Enter` open the focused note without replacing the current Neighborhood. - Cmd/Ctrl-click and Cmd/Ctrl-`Enter` open the note and pivot the note list into that note's Neighborhood. +## Command Surface + +`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, and toggle state-dependent menu items from manifest groups. + +Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation. + ## File System Integration ### Vault Scanning (Rust) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3b6025dd..a13e533e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -822,7 +822,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useUnifiedSearch` | Query, results, loading state | Keyword search | | `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings | | `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config | -| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands | +| `appCommandDispatcher` | Manifest-backed shortcut/menu command IDs | Shared execution path for renderer and native menu commands | Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`. @@ -847,13 +847,14 @@ Selection-dependent actions are wired through the command palette and the native Shortcut routing is explicit: -- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata +- `src/shared/appCommandManifest.json` is the shared command/menu metadata source for command IDs, menu labels, accelerators, native-menu enablement groups, and deterministic QA flags +- `appCommandCatalog.ts` derives renderer command IDs, shortcut lookup maps, Linux menu sections, and QA metadata from that manifest - `formatShortcutDisplay()` derives platform-accurate visible shortcut labels (`⌘` on macOS, `Ctrl` on Windows/Linux) from that same manifest so menus, tooltips, and command-palette copy stay aligned with real accelerators - `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs - macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path - `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control - `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active -- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same command IDs for native menu clicks, accelerators, and custom titlebar menu actions +- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions - `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once - Deterministic QA uses two explicit proof paths from the shared manifest: - renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()` diff --git a/docs/adr/0106-shared-app-command-manifest.md b/docs/adr/0106-shared-app-command-manifest.md new file mode 100644 index 00000000..b5093a29 --- /dev/null +++ b/docs/adr/0106-shared-app-command-manifest.md @@ -0,0 +1,31 @@ +--- +type: ADR +id: "0106" +title: "Shared app command manifest" +status: active +date: 2026-05-02 +--- + +## Context + +Tolaria command metadata was split across several runtime surfaces: TypeScript owned shortcut lookup and command-palette shortcut display, Rust owned native menu IDs, labels, accelerators, aliases, and enablement groups, and the Linux titlebar fallback menu duplicated another command list. Adding or changing a command required carefully editing multiple files that could drift while still compiling. + +The existing renderer-first shortcut model and native-menu dedupe remain correct, but they need a single source for metadata that must be identical across those surfaces. + +## Decision + +**Tolaria stores cross-runtime app command metadata in `src/shared/appCommandManifest.json`, and both the renderer and Tauri native menu derive their command/menu IDs, accelerators, menu labels, menu aliases, enablement groups, and deterministic QA metadata from it.** Context-sensitive command-palette builders still own availability and execution callbacks, and OS-native menu entries remain local to the native menu implementation. + +## Options considered + +- **Shared JSON manifest included by TypeScript and Rust** (chosen): works in both runtimes without code generation, keeps menu metadata reviewable, and lets tests validate drift directly. +- **Generate TypeScript and Rust constants from a schema**: gives stronger compile-time types but adds a build step and a generated-file maintenance burden for a small manifest. +- **Keep duplicated constants with more tests**: reduces immediate refactor scope, but still forces every command change through parallel manual edits. + +## Consequences + +- New app commands that appear in native menus or shortcut QA must be added to `src/shared/appCommandManifest.json`. +- `appCommandCatalog.ts` is responsible for turning the manifest into typed renderer helpers such as `APP_COMMAND_IDS`, shortcut lookup maps, Linux menu sections, and deterministic QA definitions. +- `src-tauri/src/menu.rs` includes the same manifest JSON, builds custom menu items from it, maps overridden menu item IDs such as `file-quick-open-alias` back to their primary command IDs, and resolves state-dependent enablement groups from manifest entries. +- Platform-native menu items such as Undo, Redo, Copy, Paste, Select All, Services, Quit, and Window controls stay in Rust because they are OS affordances, not Tolaria app commands. +- Command-palette builders continue to own dynamic labels, filtering, enabled state, and callbacks where those depend on current app state. diff --git a/docs/adr/README.md b/docs/adr/README.md index 249dae67..9e8a6955 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -158,3 +158,4 @@ proposed → active → superseded | [0103](0103-adapter-specific-ai-permission-semantics.md) | Adapter-specific AI permission semantics | active | | [0104](0104-tauri-frontend-readiness-watchdog.md) | Tauri frontend readiness watchdog | active | | [0105](0105-editor-correctness-and-responsiveness-contract.md) | Editor correctness and responsiveness contract | active | +| [0106](0106-shared-app-command-manifest.md) | Shared app command manifest | active | diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index d8fc5499..b21a3385 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -1,163 +1,289 @@ +use serde::{Deserialize, Deserializer}; +use std::{ + collections::{BTreeMap, HashSet}, + error::Error, + sync::OnceLock, +}; use tauri::{ - menu::{MenuBuilder, MenuItemBuilder, MenuItemKind, Submenu, SubmenuBuilder}, + menu::{MenuBuilder, MenuItem, MenuItemBuilder, MenuItemKind, Submenu, SubmenuBuilder}, App, AppHandle, Emitter, }; -// Custom menu item IDs that emit events to the frontend. -const APP_SETTINGS: &str = "app-settings"; -const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates"; +const APP_COMMAND_MANIFEST_JSON: &str = include_str!("../../src/shared/appCommandManifest.json"); +const NOTE_DEPENDENT_GROUP: &str = "noteDependent"; +const EDITOR_FIND_DEPENDENT_GROUP: &str = "editorFindDependent"; +const NOTE_LIST_SEARCH_DEPENDENT_GROUP: &str = "noteListSearchDependent"; +const RESTORE_DELETED_DEPENDENT_GROUP: &str = "restoreDeletedDependent"; +const GIT_COMMIT_DEPENDENT_GROUP: &str = "gitCommitDependent"; +const GIT_CONFLICT_DEPENDENT_GROUP: &str = "gitConflictDependent"; +const GIT_NO_REMOTE_DEPENDENT_GROUP: &str = "gitNoRemoteDependent"; -const FILE_NEW_NOTE: &str = "file-new-note"; -const FILE_NEW_TYPE: &str = "file-new-type"; -const FILE_QUICK_OPEN: &str = "file-quick-open"; -const FILE_QUICK_OPEN_ALIAS: &str = "file-quick-open-alias"; -const FILE_SAVE: &str = "file-save"; +type MenuResult = Result, Box>; +type AppSubmenuBuilder<'a> = SubmenuBuilder<'a, tauri::Wry, App>; -const EDIT_FIND_IN_NOTE: &str = "edit-find-in-note"; -const EDIT_REPLACE_IN_NOTE: &str = "edit-replace-in-note"; -const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault"; -const EDIT_PASTE_PLAIN_TEXT: &str = "edit-paste-plain-text"; -const EDIT_TOGGLE_NOTE_LIST_SEARCH: &str = "edit-toggle-note-list-search"; -const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor"; -const EDIT_TOGGLE_DIFF: &str = "edit-toggle-diff"; +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AppCommandManifest { + commands: BTreeMap, + menus: Vec, + app_menu: Vec, + menu_state_groups: BTreeMap>, +} -const VIEW_EDITOR_ONLY: &str = "view-editor-only"; -const VIEW_EDITOR_LIST: &str = "view-editor-list"; -const VIEW_ALL: &str = "view-all"; -const VIEW_TOGGLE_PROPERTIES: &str = "view-toggle-properties"; -const VIEW_TOGGLE_AI_CHAT: &str = "view-toggle-ai-chat"; -const VIEW_TOGGLE_BACKLINKS: &str = "view-toggle-backlinks"; -const VIEW_COMMAND_PALETTE: &str = "view-command-palette"; -const VIEW_ZOOM_IN: &str = "view-zoom-in"; -const VIEW_ZOOM_OUT: &str = "view-zoom-out"; -const VIEW_ZOOM_RESET: &str = "view-zoom-reset"; -const VIEW_GO_BACK: &str = "view-go-back"; -const VIEW_GO_FORWARD: &str = "view-go-forward"; +#[derive(Debug, Deserialize)] +struct ManifestCommand { + id: String, + shortcut: Option, +} -const GO_ALL_NOTES: &str = "go-all-notes"; -const GO_ARCHIVED: &str = "go-archived"; -const GO_CHANGES: &str = "go-changes"; -const GO_INBOX: &str = "go-inbox"; +#[derive(Debug, Deserialize)] +struct ManifestShortcut { + accelerator: String, +} -const NOTE_TOGGLE_ORGANIZED: &str = "note-toggle-organized"; -const NOTE_ARCHIVE: &str = "note-archive"; -const NOTE_DELETE: &str = "note-delete"; -const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window"; -const NOTE_RESTORE_DELETED: &str = "note-restore-deleted"; +#[derive(Debug, Deserialize)] +struct ManifestMenuSection { + label: String, + items: Vec, +} -const VAULT_OPEN: &str = "vault-open"; -const VAULT_REMOVE: &str = "vault-remove"; -const VAULT_RESTORE_GETTING_STARTED: &str = "vault-restore-getting-started"; -const VAULT_ADD_REMOTE: &str = "vault-add-remote"; -const VAULT_COMMIT_PUSH: &str = "vault-commit-push"; -const VAULT_PULL: &str = "vault-pull"; -const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts"; -const VAULT_VIEW_CHANGES: &str = "vault-view-changes"; -const VAULT_INSTALL_MCP: &str = "vault-install-mcp"; -const VAULT_RELOAD: &str = "vault-reload"; -const VAULT_REPAIR: &str = "vault-repair"; +#[derive(Debug, Deserialize)] +#[serde(tag = "kind")] +enum ManifestMenuItem { + #[serde(rename = "separator")] + Separator, + #[serde(rename = "command")] + Command { + command: String, + id: Option, + label: PlatformLabel, + #[serde(default, deserialize_with = "deserialize_accelerator")] + accelerator: ManifestAccelerator, + enabled: Option, + }, + #[serde(rename = "menu-event")] + MenuEvent { + id: String, + label: PlatformLabel, + #[serde(default, deserialize_with = "deserialize_accelerator")] + accelerator: ManifestAccelerator, + enabled: Option, + }, +} -const CUSTOM_IDS: &[&str] = &[ - APP_SETTINGS, - APP_CHECK_FOR_UPDATES, - FILE_NEW_NOTE, - FILE_NEW_TYPE, - FILE_QUICK_OPEN, - FILE_QUICK_OPEN_ALIAS, - FILE_SAVE, - EDIT_FIND_IN_NOTE, - EDIT_REPLACE_IN_NOTE, - EDIT_FIND_IN_VAULT, - EDIT_PASTE_PLAIN_TEXT, - EDIT_TOGGLE_NOTE_LIST_SEARCH, - EDIT_TOGGLE_RAW_EDITOR, - EDIT_TOGGLE_DIFF, - VIEW_EDITOR_ONLY, - VIEW_EDITOR_LIST, - VIEW_ALL, - VIEW_TOGGLE_PROPERTIES, - VIEW_TOGGLE_AI_CHAT, - VIEW_TOGGLE_BACKLINKS, - VIEW_COMMAND_PALETTE, - VIEW_ZOOM_IN, - VIEW_ZOOM_OUT, - VIEW_ZOOM_RESET, - VIEW_GO_BACK, - VIEW_GO_FORWARD, - GO_ALL_NOTES, - GO_ARCHIVED, - GO_CHANGES, - GO_INBOX, - NOTE_TOGGLE_ORGANIZED, - NOTE_ARCHIVE, - NOTE_DELETE, - NOTE_OPEN_IN_NEW_WINDOW, - NOTE_RESTORE_DELETED, - VAULT_OPEN, - VAULT_REMOVE, - VAULT_RESTORE_GETTING_STARTED, - VAULT_ADD_REMOTE, - VAULT_COMMIT_PUSH, - VAULT_PULL, - VAULT_RESOLVE_CONFLICTS, - VAULT_VIEW_CHANGES, - VAULT_INSTALL_MCP, - VAULT_RELOAD, - VAULT_REPAIR, -]; +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PlatformLabel { + Plain(String), + Platform { + macos: Option, + windows: Option, + linux: Option, + default: String, + }, +} -/// IDs of menu items that should be disabled when no note tab is active. -const NOTE_DEPENDENT_IDS: &[&str] = &[ - FILE_SAVE, - NOTE_TOGGLE_ORGANIZED, - NOTE_ARCHIVE, - NOTE_DELETE, - EDIT_TOGGLE_RAW_EDITOR, - EDIT_TOGGLE_DIFF, - VIEW_TOGGLE_BACKLINKS, - NOTE_OPEN_IN_NEW_WINDOW, -]; +#[derive(Debug, Default)] +enum ManifestAccelerator { + #[default] + Inherit, + Suppressed, + Explicit(String), +} -/// IDs of menu items that depend on the editor being the active surface. -const EDITOR_FIND_DEPENDENT_IDS: &[&str] = &[EDIT_FIND_IN_NOTE, EDIT_REPLACE_IN_NOTE]; +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum MenuStateGroupReference { + Command { command: String }, + Id { id: String }, +} -/// IDs of menu items that depend on the note list being the active surface. -const NOTE_LIST_SEARCH_DEPENDENT_IDS: &[&str] = &[EDIT_TOGGLE_NOTE_LIST_SEARCH]; +fn deserialize_accelerator<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|accelerator| match accelerator { + Some(accelerator) => ManifestAccelerator::Explicit(accelerator), + None => ManifestAccelerator::Suppressed, + }) +} -/// IDs of menu items that depend on a deleted-note preview being active. -const RESTORE_DELETED_DEPENDENT_IDS: &[&str] = &[NOTE_RESTORE_DELETED]; +impl PlatformLabel { + fn resolve(&self, target_os: &str) -> &str { + match self { + Self::Plain(label) => label.as_str(), + Self::Platform { + macos, + windows, + linux, + default, + } => match target_os { + "macos" => macos.as_deref().unwrap_or(default.as_str()), + "windows" => windows.as_deref().unwrap_or(default.as_str()), + "linux" => linux.as_deref().unwrap_or(default.as_str()), + _ => default.as_str(), + }, + } + } +} -/// IDs of menu items that depend on having uncommitted changes. -const GIT_COMMIT_DEPENDENT_IDS: &[&str] = &[VAULT_COMMIT_PUSH]; +impl ManifestMenuItem { + fn command_id<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> { + match self { + Self::Command { command, .. } => manifest + .commands + .get(command) + .map(|command| command.id.as_str()), + Self::MenuEvent { id, .. } => Some(id.as_str()), + Self::Separator => None, + } + } -/// IDs of menu items that depend on having merge conflicts. -const GIT_CONFLICT_DEPENDENT_IDS: &[&str] = &[VAULT_RESOLVE_CONFLICTS]; + fn menu_item_id<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> { + match self { + Self::Command { command, id, .. } => id.as_deref().or_else(|| { + manifest + .commands + .get(command) + .map(|command| command.id.as_str()) + }), + Self::MenuEvent { id, .. } => Some(id.as_str()), + Self::Separator => None, + } + } -/// IDs of menu items that depend on the active vault having no remote configured. -const GIT_NO_REMOTE_DEPENDENT_IDS: &[&str] = &[VAULT_ADD_REMOTE]; + fn label(&self, target_os: &str) -> Option<&str> { + match self { + Self::Command { label, .. } | Self::MenuEvent { label, .. } => { + Some(label.resolve(target_os)) + } + Self::Separator => None, + } + } -type MenuResult = Result, Box>; + fn accelerator<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> { + match self { + Self::Command { + command, + accelerator, + .. + } => match accelerator { + ManifestAccelerator::Explicit(accelerator) => Some(accelerator.as_str()), + ManifestAccelerator::Suppressed => None, + ManifestAccelerator::Inherit => manifest + .commands + .get(command) + .and_then(|command| command.shortcut.as_ref()) + .map(|shortcut| shortcut.accelerator.as_str()), + }, + Self::MenuEvent { accelerator, .. } => match accelerator { + ManifestAccelerator::Explicit(accelerator) => Some(accelerator.as_str()), + ManifestAccelerator::Suppressed | ManifestAccelerator::Inherit => None, + }, + Self::Separator => None, + } + } + + fn enabled(&self) -> bool { + match self { + Self::Command { enabled, .. } | Self::MenuEvent { enabled, .. } => { + enabled.unwrap_or(true) + } + Self::Separator => true, + } + } +} + +static APP_COMMAND_MANIFEST: OnceLock = OnceLock::new(); +static CUSTOM_MENU_IDS: OnceLock> = OnceLock::new(); + +fn manifest() -> &'static AppCommandManifest { + APP_COMMAND_MANIFEST.get_or_init(|| { + serde_json::from_str(APP_COMMAND_MANIFEST_JSON) + .expect("shared app command manifest must be valid JSON") + }) +} + +fn manifest_menu_items() -> impl Iterator { + let manifest = manifest(); + manifest + .menus + .iter() + .flat_map(|section| section.items.iter()) + .chain(manifest.app_menu.iter()) +} + +fn custom_menu_ids() -> &'static HashSet { + CUSTOM_MENU_IDS.get_or_init(|| { + manifest_menu_items() + .filter_map(|item| item.menu_item_id(manifest())) + .map(str::to_owned) + .collect() + }) +} + +fn manifest_section(label: &str) -> Result<&'static ManifestMenuSection, Box> { + manifest() + .menus + .iter() + .find(|section| section.label == label) + .ok_or_else(|| format!("Missing menu section in command manifest: {label}").into()) +} fn app_menu_includes_services(target_os: &str) -> bool { target_os == "macos" } -fn build_app_menu(app: &App) -> MenuResult { - let settings_item = MenuItemBuilder::new("Settings...") - .id(APP_SETTINGS) - .accelerator("CmdOrCtrl+,") - .build(app)?; - let check_updates_item = MenuItemBuilder::new("Check for Updates...") - .id(APP_CHECK_FOR_UPDATES) - .build(app)?; +fn build_manifest_menu_item( + app: &App, + item: &ManifestMenuItem, +) -> Result>, Box> { + let Some(id) = item.menu_item_id(manifest()) else { + return Ok(None); + }; + let Some(label) = item.label(std::env::consts::OS) else { + return Ok(None); + }; - let mut builder = SubmenuBuilder::new(app, "Tolaria") - .about(None) - .separator() - .item(&check_updates_item) - .separator() - .item(&settings_item) - .separator(); + let mut builder = MenuItemBuilder::new(label).id(id).enabled(item.enabled()); + if let Some(accelerator) = item.accelerator(manifest()) { + builder = builder.accelerator(accelerator); + } + Ok(Some(builder.build(app)?)) +} + +fn append_manifest_item<'a>( + app: &'a App, + builder: AppSubmenuBuilder<'a>, + item: &ManifestMenuItem, +) -> Result, Box> { + if matches!(item, ManifestMenuItem::Separator) { + return Ok(builder.separator()); + } + + let Some(item) = build_manifest_menu_item(app, item)? else { + return Ok(builder); + }; + Ok(builder.item(&item)) +} + +fn build_manifest_menu(app: &App, label: &str) -> MenuResult { + let section = manifest_section(label)?; + let mut builder = SubmenuBuilder::new(app, section.label.as_str()); + for item in §ion.items { + builder = append_manifest_item(app, builder, item)?; + } + Ok(builder.build()?) +} + +fn build_app_menu(app: &App) -> MenuResult { + let mut builder = SubmenuBuilder::new(app, "Tolaria").about(None).separator(); + + for item in &manifest().app_menu { + builder = append_manifest_item(app, builder, item)?; + } + + builder = builder.separator(); if app_menu_includes_services(std::env::consts::OS) { builder = builder @@ -173,263 +299,51 @@ fn build_app_menu(app: &App) -> MenuResult { } fn build_file_menu(app: &App) -> MenuResult { - let quick_open_alias_label = if cfg!(target_os = "macos") { - "Quick Open (Cmd+O)" - } else { - "Quick Open (Ctrl+O)" - }; - let new_note = MenuItemBuilder::new("New Note") - .id(FILE_NEW_NOTE) - .accelerator("CmdOrCtrl+N") - .build(app)?; - let new_type = MenuItemBuilder::new("New Type") - .id(FILE_NEW_TYPE) - .build(app)?; - let quick_open = MenuItemBuilder::new("Quick Open") - .id(FILE_QUICK_OPEN) - .accelerator("CmdOrCtrl+P") - .build(app)?; - let quick_open_alias = MenuItemBuilder::new(quick_open_alias_label) - .id(FILE_QUICK_OPEN_ALIAS) - .accelerator("CmdOrCtrl+O") - .build(app)?; - let save = MenuItemBuilder::new("Save") - .id(FILE_SAVE) - .accelerator("CmdOrCtrl+S") - .build(app)?; - Ok(SubmenuBuilder::new(app, "File") - .item(&new_note) - .item(&new_type) - .item(&quick_open) - .item(&quick_open_alias) - .separator() - .item(&save) - .build()?) + build_manifest_menu(app, "File") } fn build_edit_menu(app: &App) -> MenuResult { - let find_in_note = MenuItemBuilder::new("Find in Note") - .id(EDIT_FIND_IN_NOTE) - .accelerator("CmdOrCtrl+F") - .enabled(false) - .build(app)?; - let replace_in_note = MenuItemBuilder::new("Replace in Note") - .id(EDIT_REPLACE_IN_NOTE) - .enabled(false) - .build(app)?; - let find_in_vault = MenuItemBuilder::new("Find in Vault") - .id(EDIT_FIND_IN_VAULT) - .accelerator("CmdOrCtrl+Shift+F") - .build(app)?; - let toggle_note_list_search = MenuItemBuilder::new("Toggle Note List Search") - .id(EDIT_TOGGLE_NOTE_LIST_SEARCH) - .accelerator("CmdOrCtrl+F") - .enabled(false) - .build(app)?; - let toggle_diff = MenuItemBuilder::new("Toggle Diff Mode") - .id(EDIT_TOGGLE_DIFF) - .build(app)?; - let paste_plain_text = MenuItemBuilder::new("Paste without Formatting") - .id(EDIT_PASTE_PLAIN_TEXT) - .accelerator("CmdOrCtrl+Shift+V") - .build(app)?; - - Ok(SubmenuBuilder::new(app, "Edit") + let section = manifest_section("Edit")?; + let mut items = section.items.iter(); + let mut builder = SubmenuBuilder::new(app, "Edit") .undo() .redo() .separator() .cut() .copy() - .paste() - .item(&paste_plain_text) - .separator() - .select_all() - .separator() - .item(&find_in_note) - .item(&replace_in_note) - .item(&find_in_vault) - .item(&toggle_note_list_search) - .item(&toggle_diff) - .build()?) + .paste(); + + if let Some(paste_plain_text) = items.next() { + builder = append_manifest_item(app, builder, paste_plain_text)?; + } + + builder = builder.separator().select_all().separator(); + + if matches!(items.clone().next(), Some(ManifestMenuItem::Separator)) { + items.next(); + } + + for item in items { + builder = append_manifest_item(app, builder, item)?; + } + + Ok(builder.build()?) } fn build_view_menu(app: &App) -> MenuResult { - let editor_only = MenuItemBuilder::new("Editor Only") - .id(VIEW_EDITOR_ONLY) - .accelerator("CmdOrCtrl+1") - .build(app)?; - let editor_list = MenuItemBuilder::new("Editor + Notes") - .id(VIEW_EDITOR_LIST) - .accelerator("CmdOrCtrl+2") - .build(app)?; - let all_panels = MenuItemBuilder::new("All Panels") - .id(VIEW_ALL) - .accelerator("CmdOrCtrl+3") - .build(app)?; - // Keep Cmd+Shift+I on the renderer path. The menu item stays available, - // but the native accelerator has proven unreliable for this command. - let toggle_properties = MenuItemBuilder::new("Toggle Properties Panel") - .id(VIEW_TOGGLE_PROPERTIES) - .build(app)?; - let command_palette = MenuItemBuilder::new("Command Palette") - .id(VIEW_COMMAND_PALETTE) - .accelerator("CmdOrCtrl+K") - .build(app)?; - let zoom_in = MenuItemBuilder::new("Zoom In") - .id(VIEW_ZOOM_IN) - .accelerator("CmdOrCtrl+=") - .build(app)?; - let zoom_out = MenuItemBuilder::new("Zoom Out") - .id(VIEW_ZOOM_OUT) - .accelerator("CmdOrCtrl+-") - .build(app)?; - let zoom_reset = MenuItemBuilder::new("Actual Size") - .id(VIEW_ZOOM_RESET) - .accelerator("CmdOrCtrl+0") - .build(app)?; - - Ok(SubmenuBuilder::new(app, "View") - .item(&editor_only) - .item(&editor_list) - .item(&all_panels) - .separator() - .item(&toggle_properties) - .separator() - .item(&zoom_in) - .item(&zoom_out) - .item(&zoom_reset) - .separator() - .item(&command_palette) - .build()?) + build_manifest_menu(app, "View") } fn build_go_menu(app: &App) -> MenuResult { - let all_notes = MenuItemBuilder::new("All Notes") - .id(GO_ALL_NOTES) - .build(app)?; - let archived = MenuItemBuilder::new("Archived") - .id(GO_ARCHIVED) - .build(app)?; - let changes = MenuItemBuilder::new("Changes").id(GO_CHANGES).build(app)?; - let inbox = MenuItemBuilder::new("Inbox").id(GO_INBOX).build(app)?; - let go_back = MenuItemBuilder::new("Go Back") - .id(VIEW_GO_BACK) - .accelerator("CmdOrCtrl+Left") - .build(app)?; - let go_forward = MenuItemBuilder::new("Go Forward") - .id(VIEW_GO_FORWARD) - .accelerator("CmdOrCtrl+Right") - .build(app)?; - - Ok(SubmenuBuilder::new(app, "Go") - .item(&all_notes) - .item(&archived) - .item(&changes) - .item(&inbox) - .separator() - .item(&go_back) - .item(&go_forward) - .build()?) + build_manifest_menu(app, "Go") } fn build_note_menu(app: &App) -> MenuResult { - let toggle_organized = MenuItemBuilder::new("Toggle Organized") - .id(NOTE_TOGGLE_ORGANIZED) - .accelerator("CmdOrCtrl+E") - .build(app)?; - let archive_note = MenuItemBuilder::new("Archive Note") - .id(NOTE_ARCHIVE) - .build(app)?; - let delete_note = MenuItemBuilder::new("Delete Note") - .id(NOTE_DELETE) - .accelerator("CmdOrCtrl+Backspace") - .build(app)?; - let restore_deleted_note = MenuItemBuilder::new("Restore Deleted Note") - .id(NOTE_RESTORE_DELETED) - .enabled(false) - .build(app)?; - let open_new_window = MenuItemBuilder::new("Open in New Window") - .id(NOTE_OPEN_IN_NEW_WINDOW) - .accelerator("CmdOrCtrl+Shift+O") - .build(app)?; - let toggle_raw_editor = MenuItemBuilder::new("Toggle Raw Editor") - .id(EDIT_TOGGLE_RAW_EDITOR) - .accelerator("CmdOrCtrl+\\") - .build(app)?; - let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel") - .id(VIEW_TOGGLE_AI_CHAT) - .accelerator("CmdOrCtrl+Shift+L") - .build(app)?; - let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks") - .id(VIEW_TOGGLE_BACKLINKS) - .build(app)?; - - Ok(SubmenuBuilder::new(app, "Note") - .item(&toggle_organized) - .item(&archive_note) - .item(&delete_note) - .item(&restore_deleted_note) - .separator() - .item(&open_new_window) - .separator() - .item(&toggle_raw_editor) - .item(&toggle_ai_chat) - .item(&toggle_backlinks) - .build()?) + build_manifest_menu(app, "Note") } fn build_vault_menu(app: &App) -> MenuResult { - let open_vault = MenuItemBuilder::new("Open Vault…") - .id(VAULT_OPEN) - .build(app)?; - let remove_vault = MenuItemBuilder::new("Remove Vault from List") - .id(VAULT_REMOVE) - .build(app)?; - let restore_getting_started = MenuItemBuilder::new("Restore Getting Started") - .id(VAULT_RESTORE_GETTING_STARTED) - .build(app)?; - let add_remote = MenuItemBuilder::new("Add Remote…") - .id(VAULT_ADD_REMOTE) - .enabled(false) - .build(app)?; - let commit_push = MenuItemBuilder::new("Commit & Push") - .id(VAULT_COMMIT_PUSH) - .build(app)?; - let pull = MenuItemBuilder::new("Pull from Remote") - .id(VAULT_PULL) - .build(app)?; - let resolve_conflicts = MenuItemBuilder::new("Resolve Conflicts") - .id(VAULT_RESOLVE_CONFLICTS) - .enabled(false) - .build(app)?; - let view_changes = MenuItemBuilder::new("View Pending Changes") - .id(VAULT_VIEW_CHANGES) - .build(app)?; - let install_mcp = MenuItemBuilder::new("Set Up External AI Tools…") - .id(VAULT_INSTALL_MCP) - .build(app)?; - let reload = MenuItemBuilder::new("Reload Vault") - .id(VAULT_RELOAD) - .build(app)?; - let repair = MenuItemBuilder::new("Repair Vault") - .id(VAULT_REPAIR) - .build(app)?; - - Ok(SubmenuBuilder::new(app, "Vault") - .item(&open_vault) - .item(&remove_vault) - .item(&restore_getting_started) - .separator() - .item(&add_remote) - .item(&commit_push) - .item(&pull) - .item(&resolve_conflicts) - .item(&view_changes) - .separator() - .item(&reload) - .item(&repair) - .item(&install_mcp) - .build()?) + build_manifest_menu(app, "Vault") } fn build_window_menu(app: &App) -> MenuResult { @@ -441,7 +355,7 @@ fn build_window_menu(app: &App) -> MenuResult { .build()?) } -pub fn setup_menu(app: &App) -> Result<(), Box> { +pub fn setup_menu(app: &App) -> Result<(), Box> { let app_menu = build_app_menu(app)?; let file_menu = build_file_menu(app)?; let edit_menu = build_edit_menu(app)?; @@ -472,176 +386,174 @@ pub fn setup_menu(app: &App) -> Result<(), Box> { Ok(()) } +fn emitted_menu_event_id(id: &str) -> Option<&'static str> { + manifest_menu_items().find_map(|item| { + if item.menu_item_id(manifest()) == Some(id) { + item.command_id(manifest()) + } else { + None + } + }) +} + pub fn emit_custom_menu_event(app_handle: &AppHandle, id: &str) -> Result<(), String> { - if !CUSTOM_IDS.contains(&id) { + if !custom_menu_ids().contains(id) { return Err(format!("Unknown custom menu event: {id}")); } - let emitted_id = match id { - FILE_QUICK_OPEN_ALIAS => FILE_QUICK_OPEN, - _ => id, - }; + let emitted_id = emitted_menu_event_id(id) + .ok_or_else(|| format!("Missing emitted command for custom menu event: {id}"))?; app_handle .emit("menu-event", emitted_id) .map_err(|err| format!("Failed to emit menu-event {emitted_id}: {err}")) } -fn set_items_enabled(app_handle: &AppHandle, ids: &[&str], enabled: bool) { +fn menu_state_group_ids(group_name: &str) -> Vec<&'static str> { + manifest() + .menu_state_groups + .get(group_name) + .into_iter() + .flatten() + .filter_map(|reference| match reference { + MenuStateGroupReference::Command { command } => manifest() + .commands + .get(command) + .map(|command| command.id.as_str()), + MenuStateGroupReference::Id { id } => Some(id.as_str()), + }) + .collect() +} + +fn set_items_enabled<'a>( + app_handle: &AppHandle, + ids: impl IntoIterator, + enabled: bool, +) { let Some(menu) = app_handle.menu() else { return; }; for id in ids { - if let Some(MenuItemKind::MenuItem(mi)) = menu.get(*id) { + if let Some(MenuItemKind::MenuItem(mi)) = menu.get(id) { let _ = mi.set_enabled(enabled); } } } +fn set_menu_state_group_enabled(app_handle: &AppHandle, group_name: &str, enabled: bool) { + set_items_enabled(app_handle, menu_state_group_ids(group_name), enabled); +} + /// Enable or disable menu items that depend on having an active note tab. pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) { - set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled); + set_menu_state_group_enabled(app_handle, NOTE_DEPENDENT_GROUP, enabled); } /// Enable or disable menu items that depend on the editor being the active surface. pub fn set_editor_find_items_enabled(app_handle: &AppHandle, enabled: bool) { - set_items_enabled(app_handle, EDITOR_FIND_DEPENDENT_IDS, enabled); + set_menu_state_group_enabled(app_handle, EDITOR_FIND_DEPENDENT_GROUP, enabled); } /// Enable or disable menu items that depend on the note list being the active surface. pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) { - set_items_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_IDS, enabled); + set_menu_state_group_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_GROUP, enabled); } /// Enable or disable menu items that depend on having uncommitted changes. pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) { - set_items_enabled(app_handle, GIT_COMMIT_DEPENDENT_IDS, enabled); + set_menu_state_group_enabled(app_handle, GIT_COMMIT_DEPENDENT_GROUP, enabled); } /// Enable or disable menu items that depend on having merge conflicts. pub fn set_git_conflict_items_enabled(app_handle: &AppHandle, enabled: bool) { - set_items_enabled(app_handle, GIT_CONFLICT_DEPENDENT_IDS, enabled); + set_menu_state_group_enabled(app_handle, GIT_CONFLICT_DEPENDENT_GROUP, enabled); } /// Enable or disable menu items that depend on the active vault having no remote. pub fn set_git_no_remote_items_enabled(app_handle: &AppHandle, enabled: bool) { - set_items_enabled(app_handle, GIT_NO_REMOTE_DEPENDENT_IDS, enabled); + set_menu_state_group_enabled(app_handle, GIT_NO_REMOTE_DEPENDENT_GROUP, enabled); } /// Enable or disable menu items that depend on a deleted note preview being active. pub fn set_restore_deleted_item_enabled(app_handle: &AppHandle, enabled: bool) { - set_items_enabled(app_handle, RESTORE_DELETED_DEPENDENT_IDS, enabled); + set_menu_state_group_enabled(app_handle, RESTORE_DELETED_DEPENDENT_GROUP, enabled); } #[cfg(test)] mod tests { use super::*; + fn menu_item_by_id(id: &str) -> &'static ManifestMenuItem { + manifest_menu_items() + .find(|item| item.menu_item_id(manifest()) == Some(id)) + .unwrap_or_else(|| panic!("missing menu item {id}")) + } + #[test] - fn custom_ids_include_all_constants() { - let expected = [ - APP_SETTINGS, - APP_CHECK_FOR_UPDATES, - FILE_NEW_NOTE, - FILE_NEW_TYPE, - FILE_QUICK_OPEN, - FILE_SAVE, - EDIT_FIND_IN_NOTE, - EDIT_REPLACE_IN_NOTE, - EDIT_FIND_IN_VAULT, - EDIT_PASTE_PLAIN_TEXT, - EDIT_TOGGLE_NOTE_LIST_SEARCH, - EDIT_TOGGLE_RAW_EDITOR, - EDIT_TOGGLE_DIFF, - VIEW_EDITOR_ONLY, - VIEW_EDITOR_LIST, - VIEW_ALL, - VIEW_TOGGLE_PROPERTIES, - VIEW_TOGGLE_AI_CHAT, - VIEW_TOGGLE_BACKLINKS, - VIEW_COMMAND_PALETTE, - VIEW_ZOOM_IN, - VIEW_ZOOM_OUT, - VIEW_ZOOM_RESET, - VIEW_GO_BACK, - VIEW_GO_FORWARD, - GO_ALL_NOTES, - GO_ARCHIVED, - GO_CHANGES, - NOTE_ARCHIVE, - NOTE_DELETE, - NOTE_OPEN_IN_NEW_WINDOW, - VAULT_OPEN, - VAULT_REMOVE, - VAULT_RESTORE_GETTING_STARTED, - VAULT_ADD_REMOTE, - VAULT_COMMIT_PUSH, - VAULT_PULL, - VAULT_RESOLVE_CONFLICTS, - VAULT_VIEW_CHANGES, - VAULT_INSTALL_MCP, - VAULT_RELOAD, - VAULT_REPAIR, - ]; - for id in &expected { - assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}"); + fn custom_ids_are_manifest_menu_item_ids() { + let expected: HashSet<_> = manifest_menu_items() + .filter_map(|item| item.menu_item_id(manifest())) + .map(str::to_owned) + .collect(); + + assert_eq!(custom_menu_ids(), &expected); + assert!(custom_menu_ids().contains("file-quick-open-alias")); + } + + #[test] + fn manifest_command_items_reference_known_commands() { + for item in manifest_menu_items() { + if let ManifestMenuItem::Command { command, .. } = item { + assert!( + manifest().commands.contains_key(command), + "menu item references missing command key {command}" + ); + } } } #[test] - fn note_dependent_ids_are_subset_of_custom_ids() { - for id in NOTE_DEPENDENT_IDS { - assert!( - CUSTOM_IDS.contains(id), - "note-dependent ID {id} not in CUSTOM_IDS" - ); + fn overridden_menu_item_ids_emit_their_primary_command() { + assert_eq!( + emitted_menu_event_id("file-quick-open-alias"), + Some("file-quick-open") + ); + assert_eq!( + emitted_menu_event_id("edit-toggle-note-list-search"), + Some("edit-toggle-note-list-search") + ); + assert_eq!(emitted_menu_event_id("file-save"), Some("file-save")); + } + + #[test] + fn state_group_ids_are_manifest_menu_items() { + for (group, references) in &manifest().menu_state_groups { + for reference in references { + let id = match reference { + MenuStateGroupReference::Command { command } => manifest() + .commands + .get(command) + .map(|command| command.id.as_str()) + .unwrap_or_else(|| panic!("state group {group} references {command}")), + MenuStateGroupReference::Id { id } => id.as_str(), + }; + assert!( + custom_menu_ids().contains(id), + "state group {group} references non-menu item {id}" + ); + } } } #[test] - fn note_list_search_dependent_ids_are_subset_of_custom_ids() { - for id in NOTE_LIST_SEARCH_DEPENDENT_IDS { - assert!( - CUSTOM_IDS.contains(id), - "note-list-search-dependent ID {id} not in CUSTOM_IDS" - ); - } - } + fn view_toggle_properties_keeps_renderer_owned_accelerator() { + let item = menu_item_by_id("view-toggle-properties"); - #[test] - fn editor_find_dependent_ids_are_subset_of_custom_ids() { - for id in EDITOR_FIND_DEPENDENT_IDS { - assert!( - CUSTOM_IDS.contains(id), - "editor-find-dependent ID {id} not in CUSTOM_IDS" - ); - } - } - - #[test] - fn git_dependent_ids_are_subset_of_custom_ids() { - for id in GIT_COMMIT_DEPENDENT_IDS { - assert!( - CUSTOM_IDS.contains(id), - "git-commit-dependent ID {id} not in CUSTOM_IDS" - ); - } - for id in GIT_CONFLICT_DEPENDENT_IDS { - assert!( - CUSTOM_IDS.contains(id), - "git-conflict-dependent ID {id} not in CUSTOM_IDS" - ); - } - for id in GIT_NO_REMOTE_DEPENDENT_IDS { - assert!( - CUSTOM_IDS.contains(id), - "git-no-remote-dependent ID {id} not in CUSTOM_IDS" - ); - } + assert_eq!(item.accelerator(manifest()), None); } #[test] fn no_duplicate_custom_ids() { - let mut seen = std::collections::HashSet::new(); - for id in CUSTOM_IDS { + let mut seen = HashSet::new(); + for id in manifest_menu_items().filter_map(|item| item.menu_item_id(manifest())) { assert!(seen.insert(id), "duplicate custom ID: {id}"); } } diff --git a/src/components/LinuxMenuButton.tsx b/src/components/LinuxMenuButton.tsx index afa4f825..8bcaf671 100644 --- a/src/components/LinuxMenuButton.tsx +++ b/src/components/LinuxMenuButton.tsx @@ -1,7 +1,6 @@ import { invoke } from '@tauri-apps/api/core' import { getCurrentWindow } from '@tauri-apps/api/window' -import type { AppCommandId } from '../hooks/appCommandCatalog' -import { APP_COMMAND_DEFINITIONS, APP_COMMAND_IDS } from '../hooks/appCommandCatalog' +import { APP_COMMAND_MENU_SECTIONS } from '../hooks/appCommandCatalog' import { Button } from './ui/button' import { DropdownMenu, @@ -17,7 +16,13 @@ import { type MenuItem = | { kind: 'separator' } - | { kind: 'command'; commandId: MenuCommandId; label: string } + | { + kind: 'command' + commandId: string + label: string + menuItemId: string + shortcut?: string + } | { kind: 'action'; action: () => void; label: string; shortcut?: string } type MenuSection = { @@ -25,91 +30,8 @@ type MenuSection = { label: string } -type MenuCommandId = AppCommandId | 'edit-toggle-note-list-search' - const MENU_SECTIONS: ReadonlyArray = [ - { - label: 'File', - items: [ - { kind: 'command', label: 'New Note', commandId: APP_COMMAND_IDS.fileNewNote }, - { kind: 'command', label: 'New Type', commandId: APP_COMMAND_IDS.fileNewType }, - { kind: 'command', label: 'Quick Open', commandId: APP_COMMAND_IDS.fileQuickOpen }, - { kind: 'separator' }, - { kind: 'command', label: 'Save', commandId: APP_COMMAND_IDS.fileSave }, - ], - }, - { - label: 'Edit', - items: [ - { kind: 'command', label: 'Find in Note', commandId: APP_COMMAND_IDS.editFindInNote }, - { kind: 'command', label: 'Replace in Note', commandId: APP_COMMAND_IDS.editReplaceInNote }, - { kind: 'command', label: 'Find in Vault', commandId: APP_COMMAND_IDS.editFindInVault }, - { kind: 'command', label: 'Paste without Formatting', commandId: APP_COMMAND_IDS.editPastePlainText }, - { kind: 'command', label: 'Toggle Note List Search', commandId: 'edit-toggle-note-list-search' }, - { kind: 'command', label: 'Toggle Diff Mode', commandId: APP_COMMAND_IDS.editToggleDiff }, - ], - }, - { - label: 'View', - items: [ - { kind: 'command', label: 'Editor Only', commandId: APP_COMMAND_IDS.viewEditorOnly }, - { kind: 'command', label: 'Editor + Notes', commandId: APP_COMMAND_IDS.viewEditorList }, - { kind: 'command', label: 'All Panels', commandId: APP_COMMAND_IDS.viewAll }, - { kind: 'separator' }, - { kind: 'command', label: 'Toggle Properties Panel', commandId: APP_COMMAND_IDS.viewToggleProperties }, - { kind: 'separator' }, - { kind: 'command', label: 'Zoom In', commandId: APP_COMMAND_IDS.viewZoomIn }, - { kind: 'command', label: 'Zoom Out', commandId: APP_COMMAND_IDS.viewZoomOut }, - { kind: 'command', label: 'Actual Size', commandId: APP_COMMAND_IDS.viewZoomReset }, - { kind: 'separator' }, - { kind: 'command', label: 'Command Palette', commandId: APP_COMMAND_IDS.viewCommandPalette }, - ], - }, - { - label: 'Go', - items: [ - { kind: 'command', label: 'All Notes', commandId: APP_COMMAND_IDS.goAllNotes }, - { kind: 'command', label: 'Archived', commandId: APP_COMMAND_IDS.goArchived }, - { kind: 'command', label: 'Changes', commandId: APP_COMMAND_IDS.goChanges }, - { kind: 'command', label: 'Inbox', commandId: APP_COMMAND_IDS.goInbox }, - { kind: 'separator' }, - { kind: 'command', label: 'Go Back', commandId: APP_COMMAND_IDS.viewGoBack }, - { kind: 'command', label: 'Go Forward', commandId: APP_COMMAND_IDS.viewGoForward }, - ], - }, - { - label: 'Note', - items: [ - { kind: 'command', label: 'Toggle Organized', commandId: APP_COMMAND_IDS.noteToggleOrganized }, - { kind: 'command', label: 'Archive Note', commandId: APP_COMMAND_IDS.noteArchive }, - { kind: 'command', label: 'Delete Note', commandId: APP_COMMAND_IDS.noteDelete }, - { kind: 'command', label: 'Restore Deleted Note', commandId: APP_COMMAND_IDS.noteRestoreDeleted }, - { kind: 'separator' }, - { kind: 'command', label: 'Open in New Window', commandId: APP_COMMAND_IDS.noteOpenInNewWindow }, - { kind: 'separator' }, - { kind: 'command', label: 'Toggle Raw Editor', commandId: APP_COMMAND_IDS.editToggleRawEditor }, - { kind: 'command', label: 'Toggle AI Panel', commandId: APP_COMMAND_IDS.viewToggleAiChat }, - { kind: 'command', label: 'Toggle Backlinks', commandId: APP_COMMAND_IDS.viewToggleBacklinks }, - ], - }, - { - label: 'Vault', - items: [ - { kind: 'command', label: 'Open Vault…', commandId: APP_COMMAND_IDS.vaultOpen }, - { kind: 'command', label: 'Remove Vault from List', commandId: APP_COMMAND_IDS.vaultRemove }, - { kind: 'command', label: 'Restore Getting Started', commandId: APP_COMMAND_IDS.vaultRestoreGettingStarted }, - { kind: 'separator' }, - { kind: 'command', label: 'Add Remote…', commandId: APP_COMMAND_IDS.vaultAddRemote }, - { kind: 'command', label: 'Commit & Push', commandId: APP_COMMAND_IDS.vaultCommitPush }, - { kind: 'command', label: 'Pull from Remote', commandId: APP_COMMAND_IDS.vaultPull }, - { kind: 'command', label: 'Resolve Conflicts', commandId: APP_COMMAND_IDS.vaultResolveConflicts }, - { kind: 'command', label: 'View Pending Changes', commandId: APP_COMMAND_IDS.vaultViewChanges }, - { kind: 'separator' }, - { kind: 'command', label: 'Reload Vault', commandId: APP_COMMAND_IDS.vaultReload }, - { kind: 'command', label: 'Repair Vault', commandId: APP_COMMAND_IDS.vaultRepair }, - { kind: 'command', label: 'Set Up External AI Tools…', commandId: APP_COMMAND_IDS.vaultInstallMcp }, - ], - }, + ...APP_COMMAND_MENU_SECTIONS, { label: 'Window', items: [ @@ -121,31 +43,8 @@ const MENU_SECTIONS: ReadonlyArray = [ }, ] -function formatShortcutKey(key: string): string { - switch (key) { - case 'ArrowLeft': - return '←' - case 'ArrowRight': - return '→' - default: - return key.length === 1 ? key.toUpperCase() : key - } -} - -function getLinuxShortcut(commandId: MenuCommandId): string | null { - if (commandId === 'edit-toggle-note-list-search') { - return 'Ctrl+F' - } - - const shortcut = APP_COMMAND_DEFINITIONS[commandId].shortcut - if (!shortcut) return null - - const modifier = shortcut.combo === 'command-or-ctrl' ? 'Ctrl' : 'Ctrl+Shift' - return `${modifier}+${formatShortcutKey(shortcut.key)}` -} - -function triggerMenuCommand(commandId: MenuCommandId): void { - void invoke('trigger_menu_command', { id: commandId }).catch(() => {}) +function triggerMenuCommand(menuItemId: string): void { + void invoke('trigger_menu_command', { id: menuItemId }).catch(() => {}) } function HamburgerIcon() { @@ -184,15 +83,14 @@ export function LinuxMenuButton() { } if (item.kind === 'command') { - const shortcut = getLinuxShortcut(item.commandId) return ( triggerMenuCommand(item.commandId)} + key={item.menuItemId} + onSelect={() => triggerMenuCommand(item.menuItemId)} > {item.label} - {shortcut && ( - {shortcut} + {item.shortcut && ( + {item.shortcut} )} ) diff --git a/src/components/LinuxTitlebar.test.tsx b/src/components/LinuxTitlebar.test.tsx index ab4dd93b..b716f4e6 100644 --- a/src/components/LinuxTitlebar.test.tsx +++ b/src/components/LinuxTitlebar.test.tsx @@ -24,6 +24,7 @@ const { })) vi.mock('../utils/platform', () => ({ + isMac: () => false, shouldUseLinuxWindowChrome: vi.fn(), })) diff --git a/src/hooks/appCommandCatalog.ts b/src/hooks/appCommandCatalog.ts index fe3f0205..11cdb7b5 100644 --- a/src/hooks/appCommandCatalog.ts +++ b/src/hooks/appCommandCatalog.ts @@ -1,56 +1,11 @@ +import appCommandManifest from '../shared/appCommandManifest.json' with { type: 'json' } import type { SidebarFilter } from '../types' import { isMac } from '../utils/platform' import type { ViewMode } from './useViewMode' -export const APP_COMMAND_IDS = { - appSettings: 'app-settings', - appCheckForUpdates: 'app-check-for-updates', - fileNewNote: 'file-new-note', - fileNewType: 'file-new-type', - fileQuickOpen: 'file-quick-open', - fileSave: 'file-save', - editFindInNote: 'edit-find-in-note', - editReplaceInNote: 'edit-replace-in-note', - editFindInVault: 'edit-find-in-vault', - editPastePlainText: 'edit-paste-plain-text', - editToggleRawEditor: 'edit-toggle-raw-editor', - editToggleDiff: 'edit-toggle-diff', - viewEditorOnly: 'view-editor-only', - viewEditorList: 'view-editor-list', - viewAll: 'view-all', - viewToggleProperties: 'view-toggle-properties', - viewToggleAiChat: 'view-toggle-ai-chat', - viewToggleBacklinks: 'view-toggle-backlinks', - viewCommandPalette: 'view-command-palette', - viewZoomIn: 'view-zoom-in', - viewZoomOut: 'view-zoom-out', - viewZoomReset: 'view-zoom-reset', - viewGoBack: 'view-go-back', - viewGoForward: 'view-go-forward', - goAllNotes: 'go-all-notes', - goArchived: 'go-archived', - goChanges: 'go-changes', - goInbox: 'go-inbox', - noteToggleOrganized: 'note-toggle-organized', - noteToggleFavorite: 'note-toggle-favorite', - noteArchive: 'note-archive', - noteDelete: 'note-delete', - noteOpenInNewWindow: 'note-open-in-new-window', - noteRestoreDeleted: 'note-restore-deleted', - vaultOpen: 'vault-open', - vaultRemove: 'vault-remove', - vaultRestoreGettingStarted: 'vault-restore-getting-started', - vaultAddRemote: 'vault-add-remote', - vaultCommitPush: 'vault-commit-push', - vaultPull: 'vault-pull', - vaultResolveConflicts: 'vault-resolve-conflicts', - vaultViewChanges: 'vault-view-changes', - vaultInstallMcp: 'vault-install-mcp', - vaultReload: 'vault-reload', - vaultRepair: 'vault-repair', -} as const +type AppCommandKey = keyof typeof appCommandManifest.commands +type ShortcutEventLike = Pick -export type AppCommandId = (typeof APP_COMMAND_IDS)[keyof typeof APP_COMMAND_IDS] export type AppCommandShortcutCombo = | 'command-or-ctrl' | 'command-or-ctrl-shift' @@ -58,7 +13,6 @@ export type AppCommandShortcutCombo = export type AppCommandDeterministicQaMode = | 'renderer-shortcut-event' | 'native-menu-command' -type ShortcutEventLike = Pick export interface AppCommandDeterministicQaDefinition { preferredMode: AppCommandDeterministicQaMode @@ -131,6 +85,19 @@ interface AppCommandShortcutDefinition { display: string } +interface AppCommandManifestShortcutDefinition extends AppCommandShortcutDefinition { + accelerator: string + requiresManualNativeAcceleratorQa?: boolean +} + +interface AppCommandManifestDefinition { + id: string + route: AppCommandRoute + menuOwned: boolean + shortcut?: AppCommandManifestShortcutDefinition + preferredShortcutQaMode?: AppCommandDeterministicQaMode +} + export interface AppCommandDefinition { route: AppCommandRoute menuOwned: boolean @@ -138,241 +105,183 @@ export interface AppCommandDefinition { preferredShortcutQaMode?: AppCommandDeterministicQaMode } -export const APP_COMMAND_DEFINITIONS: Record = { - [APP_COMMAND_IDS.appSettings]: { - route: { kind: 'handler', handler: 'onOpenSettings' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: ',', display: '⌘,' }, - }, - [APP_COMMAND_IDS.appCheckForUpdates]: { - route: { kind: 'handler', handler: 'onCheckForUpdates' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.fileNewNote]: { - route: { kind: 'handler', handler: 'onCreateNote' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: 'n', code: 'KeyN', display: '⌘N' }, - }, - [APP_COMMAND_IDS.fileNewType]: { - route: { kind: 'handler', handler: 'onCreateType' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.fileQuickOpen]: { - route: { kind: 'handler', handler: 'onQuickOpen' }, - menuOwned: true, - shortcut: { - combo: 'command-or-ctrl', - key: 'p', - aliases: ['o'], - code: 'KeyP', - display: '⌘P / ⌘O', - }, - }, - [APP_COMMAND_IDS.fileSave]: { - route: { kind: 'handler', handler: 'onSave' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: 's', code: 'KeyS', display: '⌘S' }, - }, - [APP_COMMAND_IDS.editFindInNote]: { - route: { kind: 'handler', handler: 'onFindInNote' }, - menuOwned: true, - preferredShortcutQaMode: 'renderer-shortcut-event', - shortcut: { combo: 'command-or-ctrl', key: 'f', code: 'KeyF', display: '⌘F' }, - }, - [APP_COMMAND_IDS.editReplaceInNote]: { - route: { kind: 'handler', handler: 'onReplaceInNote' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.editFindInVault]: { - route: { kind: 'handler', handler: 'onSearch' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl-shift', key: 'f', code: 'KeyF', display: '⌘⇧F' }, - }, - [APP_COMMAND_IDS.editPastePlainText]: { - route: { kind: 'handler', handler: 'onPastePlainText' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl-shift', key: 'v', code: 'KeyV', display: '⌘⇧V' }, - }, - [APP_COMMAND_IDS.editToggleRawEditor]: { - route: { kind: 'handler', handler: 'onToggleRawEditor' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: '\\', display: '⌘\\' }, - }, - [APP_COMMAND_IDS.editToggleDiff]: { - route: { kind: 'handler', handler: 'onToggleDiff' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.viewEditorOnly]: { - route: { kind: 'view-mode', value: 'editor-only' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: '1', display: '⌘1' }, - }, - [APP_COMMAND_IDS.viewEditorList]: { - route: { kind: 'view-mode', value: 'editor-list' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: '2', display: '⌘2' }, - }, - [APP_COMMAND_IDS.viewAll]: { - route: { kind: 'view-mode', value: 'all' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: '3', display: '⌘3' }, - }, - [APP_COMMAND_IDS.viewToggleProperties]: { - route: { kind: 'handler', handler: 'onToggleInspector' }, - menuOwned: true, - preferredShortcutQaMode: 'renderer-shortcut-event', - shortcut: { combo: 'command-or-ctrl-shift', key: 'i', code: 'KeyI', display: '⌘⇧I' }, - }, - [APP_COMMAND_IDS.viewToggleAiChat]: { - route: { kind: 'handler', handler: 'onToggleAIChat' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl-shift', key: 'l', code: 'KeyL', display: '⌘⇧L' }, - }, - [APP_COMMAND_IDS.viewToggleBacklinks]: { - route: { kind: 'handler', handler: 'onToggleInspector' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.viewCommandPalette]: { - route: { kind: 'handler', handler: 'onCommandPalette' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: 'k', code: 'KeyK', display: '⌘K' }, - }, - [APP_COMMAND_IDS.viewZoomIn]: { - route: { kind: 'handler', handler: 'onZoomIn' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: '=', aliases: ['+'], display: '⌘=' }, - }, - [APP_COMMAND_IDS.viewZoomOut]: { - route: { kind: 'handler', handler: 'onZoomOut' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: '-', display: '⌘-' }, - }, - [APP_COMMAND_IDS.viewZoomReset]: { - route: { kind: 'handler', handler: 'onZoomReset' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: '0', display: '⌘0' }, - }, - [APP_COMMAND_IDS.viewGoBack]: { - route: { kind: 'handler', handler: 'onGoBack' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: 'ArrowLeft', code: 'ArrowLeft', display: '⌘←' }, - }, - [APP_COMMAND_IDS.viewGoForward]: { - route: { kind: 'handler', handler: 'onGoForward' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: 'ArrowRight', code: 'ArrowRight', display: '⌘→' }, - }, - [APP_COMMAND_IDS.goAllNotes]: { - route: { kind: 'filter', value: 'all' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.goArchived]: { - route: { kind: 'filter', value: 'archived' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.goChanges]: { - route: { kind: 'filter', value: 'changes' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.goInbox]: { - route: { kind: 'filter', value: 'inbox' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.noteToggleOrganized]: { - route: { kind: 'active-tab-handler', handler: 'onToggleOrganized' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: 'e', code: 'KeyE', display: '⌘E' }, - }, - [APP_COMMAND_IDS.noteToggleFavorite]: { - route: { kind: 'active-tab-handler', handler: 'onToggleFavorite' }, - menuOwned: false, - shortcut: { combo: 'command-or-ctrl', key: 'd', code: 'KeyD', display: '⌘D' }, - }, - [APP_COMMAND_IDS.noteArchive]: { - route: { kind: 'active-tab-handler', handler: 'onArchiveNote' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.noteDelete]: { - route: { kind: 'active-tab-handler', handler: 'onDeleteNote' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl', key: 'Backspace', aliases: ['Delete'], display: '⌘⌫' }, - }, - [APP_COMMAND_IDS.noteOpenInNewWindow]: { - route: { kind: 'handler', handler: 'onOpenInNewWindow' }, - menuOwned: true, - shortcut: { combo: 'command-or-ctrl-shift', key: 'o', code: 'KeyO', display: '⌘⇧O' }, - }, - [APP_COMMAND_IDS.noteRestoreDeleted]: { - route: { kind: 'handler', handler: 'onRestoreDeletedNote' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.vaultOpen]: { - route: { kind: 'handler', handler: 'onOpenVault' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.vaultRemove]: { - route: { kind: 'handler', handler: 'onRemoveActiveVault' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.vaultRestoreGettingStarted]: { - route: { kind: 'handler', handler: 'onRestoreGettingStarted' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.vaultAddRemote]: { - route: { kind: 'handler', handler: 'onAddRemote' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.vaultCommitPush]: { - route: { kind: 'handler', handler: 'onCommitPush' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.vaultPull]: { - route: { kind: 'handler', handler: 'onPull' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.vaultResolveConflicts]: { - route: { kind: 'handler', handler: 'onResolveConflicts' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.vaultViewChanges]: { - route: { kind: 'handler', handler: 'onViewChanges' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.vaultInstallMcp]: { - route: { kind: 'handler', handler: 'onInstallMcp' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.vaultReload]: { - route: { kind: 'handler', handler: 'onReloadVault' }, - menuOwned: true, - }, - [APP_COMMAND_IDS.vaultRepair]: { - route: { kind: 'handler', handler: 'onRepairVault' }, - menuOwned: true, - }, +type PlatformLabel = string | { + macos?: string + windows?: string + linux?: string + default: string } +type AppCommandMenuManifestItem = + | { kind: 'separator' } + | { + kind: 'command' + command: AppCommandKey + id?: string + label: PlatformLabel + accelerator?: string | null + enabled?: boolean + } + | { + kind: 'menu-event' + id: string + label: PlatformLabel + accelerator?: string | null + enabled?: boolean + } + +interface AppCommandMenuManifestSection { + label: string + items: AppCommandMenuManifestItem[] +} + +export type AppCommandMenuItem = + | { kind: 'separator' } + | { + kind: 'command' + commandId: string + menuItemId: string + label: string + shortcut?: string + enabled?: boolean + } + +type AppCommandMenuStateGroupReference = + | { command: AppCommandKey } + | { id: string } + +type AppCommandMenuStateGroupName = keyof typeof appCommandManifest.menuStateGroups + +const APP_COMMAND_MANIFEST_COMMANDS = appCommandManifest.commands as Record +const APP_COMMAND_MANIFEST_MENUS = appCommandManifest.menus as AppCommandMenuManifestSection[] +const APP_COMMAND_MANIFEST_APP_MENU = appCommandManifest.appMenu as AppCommandMenuManifestItem[] +const APP_COMMAND_MANIFEST_STATE_GROUPS = appCommandManifest.menuStateGroups as Record< + AppCommandMenuStateGroupName, + AppCommandMenuStateGroupReference[] +> + +export const APP_COMMAND_IDS = Object.fromEntries( + Object.entries(APP_COMMAND_MANIFEST_COMMANDS).map(([key, command]) => [key, command.id]), +) as { readonly [K in AppCommandKey]: string } + +export type AppCommandId = (typeof APP_COMMAND_IDS)[AppCommandKey] + const APP_COMMAND_SET = new Set(Object.values(APP_COMMAND_IDS)) +function toShortcutDefinition( + shortcut: AppCommandManifestShortcutDefinition | undefined, +): AppCommandShortcutDefinition | undefined { + if (!shortcut) return undefined + + return { + combo: shortcut.combo, + key: shortcut.key, + aliases: shortcut.aliases, + code: shortcut.code, + display: shortcut.display, + } +} + +export const APP_COMMAND_DEFINITIONS = Object.fromEntries( + Object.values(APP_COMMAND_MANIFEST_COMMANDS).map((command) => [ + command.id, + { + route: command.route, + menuOwned: command.menuOwned, + shortcut: toShortcutDefinition(command.shortcut), + preferredShortcutQaMode: command.preferredShortcutQaMode, + }, + ]), +) as Record + +function resolvePlatformLabel(label: PlatformLabel): string { + if (typeof label === 'string') return label + if (isMac() && label.macos) return label.macos + return label.default +} + +function formatAcceleratorDisplay(accelerator: string): string { + const commandPrefix = isMac() ? '⌘' : 'Ctrl+' + const commandShiftPrefix = isMac() ? '⌘⇧' : 'Ctrl+Shift+' + + return accelerator + .replaceAll('CmdOrCtrl+Shift+', commandShiftPrefix) + .replaceAll('CmdOrCtrl+', commandPrefix) + .replaceAll('Backspace', isMac() ? '⌫' : 'Backspace') + .replaceAll('Delete', isMac() ? '⌦' : 'Delete') + .replaceAll('Left', isMac() ? '←' : 'Left') + .replaceAll('Right', isMac() ? '→' : 'Right') + .replaceAll('Enter', isMac() ? '↵' : 'Enter') +} + +function menuShortcutForCommand( + item: Extract, + command: AppCommandManifestDefinition, +): string | undefined { + if (typeof item.accelerator === 'string') return formatAcceleratorDisplay(item.accelerator) + if (command.shortcut) return formatShortcutDisplay(command.shortcut) + return undefined +} + +function toMenuItem(item: AppCommandMenuManifestItem): AppCommandMenuItem { + if (item.kind === 'separator') return { kind: 'separator' } + + if (item.kind === 'menu-event') { + return { + kind: 'command', + commandId: item.id, + menuItemId: item.id, + label: resolvePlatformLabel(item.label), + shortcut: typeof item.accelerator === 'string' + ? formatAcceleratorDisplay(item.accelerator) + : undefined, + enabled: item.enabled, + } + } + + const command = APP_COMMAND_MANIFEST_COMMANDS[item.command] + return { + kind: 'command', + commandId: command.id, + menuItemId: item.id ?? command.id, + label: resolvePlatformLabel(item.label), + shortcut: menuShortcutForCommand(item, command), + enabled: item.enabled, + } +} + +function menuCommandIds(items: AppCommandMenuItem[]): string[] { + return items.flatMap(item => item.kind === 'command' ? [item.commandId] : []) +} + +export const APP_COMMAND_MENU_SECTIONS = APP_COMMAND_MANIFEST_MENUS.map(section => ({ + label: section.label, + items: section.items.map(toMenuItem), +})) + +const APP_COMMAND_APP_MENU_ITEMS = APP_COMMAND_MANIFEST_APP_MENU.map(toMenuItem) + +export const APP_COMMAND_MENU_STATE_GROUPS = Object.fromEntries( + Object.entries(APP_COMMAND_MANIFEST_STATE_GROUPS).map(([name, references]) => [ + name, + references.map(reference => 'command' in reference + ? APP_COMMAND_MANIFEST_COMMANDS[reference.command].id + : reference.id), + ]), +) as Record + const NATIVE_MENU_COMMAND_SET = new Set( - (Object.entries(APP_COMMAND_DEFINITIONS) as Array<[AppCommandId, AppCommandDefinition]>) - .filter(([, definition]) => definition.menuOwned) - .map(([id]) => id), + [ + ...APP_COMMAND_MENU_SECTIONS.flatMap(section => menuCommandIds(section.items)), + ...menuCommandIds(APP_COMMAND_APP_MENU_ITEMS), + ].filter(id => APP_COMMAND_SET.has(id)), ) -const MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET = new Set([ - APP_COMMAND_IDS.appSettings, - APP_COMMAND_IDS.fileNewNote, - APP_COMMAND_IDS.fileQuickOpen, - APP_COMMAND_IDS.fileSave, - APP_COMMAND_IDS.editFindInNote, - APP_COMMAND_IDS.editFindInVault, - APP_COMMAND_IDS.editPastePlainText, - APP_COMMAND_IDS.viewToggleAiChat, - APP_COMMAND_IDS.viewCommandPalette, - APP_COMMAND_IDS.noteToggleOrganized, - APP_COMMAND_IDS.noteToggleFavorite, -]) +const MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET = new Set( + Object.values(APP_COMMAND_MANIFEST_COMMANDS) + .filter(command => command.shortcut?.requiresManualNativeAcceleratorQa) + .map(command => command.id), +) const shortcutKeyMaps = { 'command-or-ctrl': new Map(), diff --git a/src/hooks/appCommandDispatcher.test.ts b/src/hooks/appCommandDispatcher.test.ts index 8e466704..12b61d03 100644 --- a/src/hooks/appCommandDispatcher.test.ts +++ b/src/hooks/appCommandDispatcher.test.ts @@ -13,6 +13,8 @@ import { } from './appCommandDispatcher' import { getDeterministicShortcutQaDefinition, + APP_COMMAND_MENU_SECTIONS, + APP_COMMAND_MENU_STATE_GROUPS, getShortcutEventInit, } from './appCommandCatalog' @@ -103,6 +105,31 @@ describe('appCommandDispatcher', () => { expect(isNativeMenuCommandId(APP_COMMAND_IDS.noteToggleFavorite)).toBe(false) }) + it('derives native menu command IDs from the shared command menu manifest', () => { + const menuCommandIds = APP_COMMAND_MENU_SECTIONS.flatMap(section => + section.items.flatMap(item => item.kind === 'command' ? [item.commandId] : []), + ) + + expect(menuCommandIds).toContain(APP_COMMAND_IDS.fileNewNote) + expect(menuCommandIds).toContain(APP_COMMAND_IDS.editPastePlainText) + expect(menuCommandIds).toContain(APP_COMMAND_IDS.viewGoBack) + expect(menuCommandIds).not.toContain(APP_COMMAND_IDS.noteToggleFavorite) + }) + + it('keeps native menu state groups inside the shared command menu manifest', () => { + const menuCommandIds = new Set( + APP_COMMAND_MENU_SECTIONS.flatMap(section => + section.items.flatMap(item => item.kind === 'command' ? [item.menuItemId] : []), + ), + ) + + for (const commandIds of Object.values(APP_COMMAND_MENU_STATE_GROUPS)) { + for (const commandId of commandIds) { + expect(menuCommandIds.has(commandId)).toBe(true) + } + } + }) + it('finds raw editor, AI, and plain-text paste shortcuts from the shared catalog', () => { expect(findShortcutCommandId('command-or-ctrl', 'o', 'KeyO')).toBe(APP_COMMAND_IDS.fileQuickOpen) expect(findShortcutCommandId('command-or-ctrl', '\\')).toBe(APP_COMMAND_IDS.editToggleRawEditor) diff --git a/src/shared/appCommandManifest.json b/src/shared/appCommandManifest.json new file mode 100644 index 00000000..4aed67e1 --- /dev/null +++ b/src/shared/appCommandManifest.json @@ -0,0 +1,536 @@ +{ + "commands": { + "appSettings": { + "id": "app-settings", + "route": { "kind": "handler", "handler": "onOpenSettings" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": ",", + "display": "⌘,", + "accelerator": "CmdOrCtrl+,", + "requiresManualNativeAcceleratorQa": true + } + }, + "appCheckForUpdates": { + "id": "app-check-for-updates", + "route": { "kind": "handler", "handler": "onCheckForUpdates" }, + "menuOwned": true + }, + "fileNewNote": { + "id": "file-new-note", + "route": { "kind": "handler", "handler": "onCreateNote" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "n", + "code": "KeyN", + "display": "⌘N", + "accelerator": "CmdOrCtrl+N", + "requiresManualNativeAcceleratorQa": true + } + }, + "fileNewType": { + "id": "file-new-type", + "route": { "kind": "handler", "handler": "onCreateType" }, + "menuOwned": true + }, + "fileQuickOpen": { + "id": "file-quick-open", + "route": { "kind": "handler", "handler": "onQuickOpen" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "p", + "aliases": ["o"], + "code": "KeyP", + "display": "⌘P / ⌘O", + "accelerator": "CmdOrCtrl+P", + "requiresManualNativeAcceleratorQa": true + } + }, + "fileSave": { + "id": "file-save", + "route": { "kind": "handler", "handler": "onSave" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "s", + "code": "KeyS", + "display": "⌘S", + "accelerator": "CmdOrCtrl+S", + "requiresManualNativeAcceleratorQa": true + } + }, + "editFindInNote": { + "id": "edit-find-in-note", + "route": { "kind": "handler", "handler": "onFindInNote" }, + "menuOwned": true, + "preferredShortcutQaMode": "renderer-shortcut-event", + "shortcut": { + "combo": "command-or-ctrl", + "key": "f", + "code": "KeyF", + "display": "⌘F", + "accelerator": "CmdOrCtrl+F", + "requiresManualNativeAcceleratorQa": true + } + }, + "editReplaceInNote": { + "id": "edit-replace-in-note", + "route": { "kind": "handler", "handler": "onReplaceInNote" }, + "menuOwned": true + }, + "editFindInVault": { + "id": "edit-find-in-vault", + "route": { "kind": "handler", "handler": "onSearch" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl-shift", + "key": "f", + "code": "KeyF", + "display": "⌘⇧F", + "accelerator": "CmdOrCtrl+Shift+F", + "requiresManualNativeAcceleratorQa": true + } + }, + "editPastePlainText": { + "id": "edit-paste-plain-text", + "route": { "kind": "handler", "handler": "onPastePlainText" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl-shift", + "key": "v", + "code": "KeyV", + "display": "⌘⇧V", + "accelerator": "CmdOrCtrl+Shift+V", + "requiresManualNativeAcceleratorQa": true + } + }, + "editToggleRawEditor": { + "id": "edit-toggle-raw-editor", + "route": { "kind": "handler", "handler": "onToggleRawEditor" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "\\", + "display": "⌘\\", + "accelerator": "CmdOrCtrl+\\" + } + }, + "editToggleDiff": { + "id": "edit-toggle-diff", + "route": { "kind": "handler", "handler": "onToggleDiff" }, + "menuOwned": true + }, + "viewEditorOnly": { + "id": "view-editor-only", + "route": { "kind": "view-mode", "value": "editor-only" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "1", + "display": "⌘1", + "accelerator": "CmdOrCtrl+1" + } + }, + "viewEditorList": { + "id": "view-editor-list", + "route": { "kind": "view-mode", "value": "editor-list" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "2", + "display": "⌘2", + "accelerator": "CmdOrCtrl+2" + } + }, + "viewAll": { + "id": "view-all", + "route": { "kind": "view-mode", "value": "all" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "3", + "display": "⌘3", + "accelerator": "CmdOrCtrl+3" + } + }, + "viewToggleProperties": { + "id": "view-toggle-properties", + "route": { "kind": "handler", "handler": "onToggleInspector" }, + "menuOwned": true, + "preferredShortcutQaMode": "renderer-shortcut-event", + "shortcut": { + "combo": "command-or-ctrl-shift", + "key": "i", + "code": "KeyI", + "display": "⌘⇧I", + "accelerator": "CmdOrCtrl+Shift+I" + } + }, + "viewToggleAiChat": { + "id": "view-toggle-ai-chat", + "route": { "kind": "handler", "handler": "onToggleAIChat" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl-shift", + "key": "l", + "code": "KeyL", + "display": "⌘⇧L", + "accelerator": "CmdOrCtrl+Shift+L", + "requiresManualNativeAcceleratorQa": true + } + }, + "viewToggleBacklinks": { + "id": "view-toggle-backlinks", + "route": { "kind": "handler", "handler": "onToggleInspector" }, + "menuOwned": true + }, + "viewCommandPalette": { + "id": "view-command-palette", + "route": { "kind": "handler", "handler": "onCommandPalette" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "k", + "code": "KeyK", + "display": "⌘K", + "accelerator": "CmdOrCtrl+K", + "requiresManualNativeAcceleratorQa": true + } + }, + "viewZoomIn": { + "id": "view-zoom-in", + "route": { "kind": "handler", "handler": "onZoomIn" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "=", + "aliases": ["+"], + "display": "⌘=", + "accelerator": "CmdOrCtrl+=" + } + }, + "viewZoomOut": { + "id": "view-zoom-out", + "route": { "kind": "handler", "handler": "onZoomOut" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "-", + "display": "⌘-", + "accelerator": "CmdOrCtrl+-" + } + }, + "viewZoomReset": { + "id": "view-zoom-reset", + "route": { "kind": "handler", "handler": "onZoomReset" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "0", + "display": "⌘0", + "accelerator": "CmdOrCtrl+0" + } + }, + "viewGoBack": { + "id": "view-go-back", + "route": { "kind": "handler", "handler": "onGoBack" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "ArrowLeft", + "code": "ArrowLeft", + "display": "⌘←", + "accelerator": "CmdOrCtrl+Left" + } + }, + "viewGoForward": { + "id": "view-go-forward", + "route": { "kind": "handler", "handler": "onGoForward" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "ArrowRight", + "code": "ArrowRight", + "display": "⌘→", + "accelerator": "CmdOrCtrl+Right" + } + }, + "goAllNotes": { + "id": "go-all-notes", + "route": { "kind": "filter", "value": "all" }, + "menuOwned": true + }, + "goArchived": { + "id": "go-archived", + "route": { "kind": "filter", "value": "archived" }, + "menuOwned": true + }, + "goChanges": { + "id": "go-changes", + "route": { "kind": "filter", "value": "changes" }, + "menuOwned": true + }, + "goInbox": { + "id": "go-inbox", + "route": { "kind": "filter", "value": "inbox" }, + "menuOwned": true + }, + "noteToggleOrganized": { + "id": "note-toggle-organized", + "route": { "kind": "active-tab-handler", "handler": "onToggleOrganized" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "e", + "code": "KeyE", + "display": "⌘E", + "accelerator": "CmdOrCtrl+E", + "requiresManualNativeAcceleratorQa": true + } + }, + "noteToggleFavorite": { + "id": "note-toggle-favorite", + "route": { "kind": "active-tab-handler", "handler": "onToggleFavorite" }, + "menuOwned": false, + "shortcut": { + "combo": "command-or-ctrl", + "key": "d", + "code": "KeyD", + "display": "⌘D", + "accelerator": "CmdOrCtrl+D", + "requiresManualNativeAcceleratorQa": true + } + }, + "noteArchive": { + "id": "note-archive", + "route": { "kind": "active-tab-handler", "handler": "onArchiveNote" }, + "menuOwned": true + }, + "noteDelete": { + "id": "note-delete", + "route": { "kind": "active-tab-handler", "handler": "onDeleteNote" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl", + "key": "Backspace", + "aliases": ["Delete"], + "display": "⌘⌫", + "accelerator": "CmdOrCtrl+Backspace" + } + }, + "noteOpenInNewWindow": { + "id": "note-open-in-new-window", + "route": { "kind": "handler", "handler": "onOpenInNewWindow" }, + "menuOwned": true, + "shortcut": { + "combo": "command-or-ctrl-shift", + "key": "o", + "code": "KeyO", + "display": "⌘⇧O", + "accelerator": "CmdOrCtrl+Shift+O" + } + }, + "noteRestoreDeleted": { + "id": "note-restore-deleted", + "route": { "kind": "handler", "handler": "onRestoreDeletedNote" }, + "menuOwned": true + }, + "vaultOpen": { + "id": "vault-open", + "route": { "kind": "handler", "handler": "onOpenVault" }, + "menuOwned": true + }, + "vaultRemove": { + "id": "vault-remove", + "route": { "kind": "handler", "handler": "onRemoveActiveVault" }, + "menuOwned": true + }, + "vaultRestoreGettingStarted": { + "id": "vault-restore-getting-started", + "route": { "kind": "handler", "handler": "onRestoreGettingStarted" }, + "menuOwned": true + }, + "vaultAddRemote": { + "id": "vault-add-remote", + "route": { "kind": "handler", "handler": "onAddRemote" }, + "menuOwned": true + }, + "vaultCommitPush": { + "id": "vault-commit-push", + "route": { "kind": "handler", "handler": "onCommitPush" }, + "menuOwned": true + }, + "vaultPull": { + "id": "vault-pull", + "route": { "kind": "handler", "handler": "onPull" }, + "menuOwned": true + }, + "vaultResolveConflicts": { + "id": "vault-resolve-conflicts", + "route": { "kind": "handler", "handler": "onResolveConflicts" }, + "menuOwned": true + }, + "vaultViewChanges": { + "id": "vault-view-changes", + "route": { "kind": "handler", "handler": "onViewChanges" }, + "menuOwned": true + }, + "vaultInstallMcp": { + "id": "vault-install-mcp", + "route": { "kind": "handler", "handler": "onInstallMcp" }, + "menuOwned": true + }, + "vaultReload": { + "id": "vault-reload", + "route": { "kind": "handler", "handler": "onReloadVault" }, + "menuOwned": true + }, + "vaultRepair": { + "id": "vault-repair", + "route": { "kind": "handler", "handler": "onRepairVault" }, + "menuOwned": true + } + }, + "menus": [ + { + "label": "File", + "items": [ + { "kind": "command", "command": "fileNewNote", "label": "New Note" }, + { "kind": "command", "command": "fileNewType", "label": "New Type" }, + { "kind": "command", "command": "fileQuickOpen", "label": "Quick Open" }, + { + "kind": "command", + "command": "fileQuickOpen", + "id": "file-quick-open-alias", + "label": { "macos": "Quick Open (Cmd+O)", "default": "Quick Open (Ctrl+O)" }, + "accelerator": "CmdOrCtrl+O" + }, + { "kind": "separator" }, + { "kind": "command", "command": "fileSave", "label": "Save" } + ] + }, + { + "label": "Edit", + "items": [ + { "kind": "command", "command": "editPastePlainText", "label": "Paste without Formatting" }, + { "kind": "separator" }, + { "kind": "command", "command": "editFindInNote", "label": "Find in Note", "enabled": false }, + { "kind": "command", "command": "editReplaceInNote", "label": "Replace in Note", "enabled": false }, + { "kind": "command", "command": "editFindInVault", "label": "Find in Vault" }, + { + "kind": "menu-event", + "id": "edit-toggle-note-list-search", + "label": "Toggle Note List Search", + "accelerator": "CmdOrCtrl+F", + "enabled": false + }, + { "kind": "command", "command": "editToggleDiff", "label": "Toggle Diff Mode" } + ] + }, + { + "label": "View", + "items": [ + { "kind": "command", "command": "viewEditorOnly", "label": "Editor Only" }, + { "kind": "command", "command": "viewEditorList", "label": "Editor + Notes" }, + { "kind": "command", "command": "viewAll", "label": "All Panels" }, + { "kind": "separator" }, + { + "kind": "command", + "command": "viewToggleProperties", + "label": "Toggle Properties Panel", + "accelerator": null + }, + { "kind": "separator" }, + { "kind": "command", "command": "viewZoomIn", "label": "Zoom In" }, + { "kind": "command", "command": "viewZoomOut", "label": "Zoom Out" }, + { "kind": "command", "command": "viewZoomReset", "label": "Actual Size" }, + { "kind": "separator" }, + { "kind": "command", "command": "viewCommandPalette", "label": "Command Palette" } + ] + }, + { + "label": "Go", + "items": [ + { "kind": "command", "command": "goAllNotes", "label": "All Notes" }, + { "kind": "command", "command": "goArchived", "label": "Archived" }, + { "kind": "command", "command": "goChanges", "label": "Changes" }, + { "kind": "command", "command": "goInbox", "label": "Inbox" }, + { "kind": "separator" }, + { "kind": "command", "command": "viewGoBack", "label": "Go Back" }, + { "kind": "command", "command": "viewGoForward", "label": "Go Forward" } + ] + }, + { + "label": "Note", + "items": [ + { "kind": "command", "command": "noteToggleOrganized", "label": "Toggle Organized" }, + { "kind": "command", "command": "noteArchive", "label": "Archive Note" }, + { "kind": "command", "command": "noteDelete", "label": "Delete Note" }, + { "kind": "command", "command": "noteRestoreDeleted", "label": "Restore Deleted Note", "enabled": false }, + { "kind": "separator" }, + { "kind": "command", "command": "noteOpenInNewWindow", "label": "Open in New Window" }, + { "kind": "separator" }, + { "kind": "command", "command": "editToggleRawEditor", "label": "Toggle Raw Editor" }, + { "kind": "command", "command": "viewToggleAiChat", "label": "Toggle AI Panel" }, + { "kind": "command", "command": "viewToggleBacklinks", "label": "Toggle Backlinks" } + ] + }, + { + "label": "Vault", + "items": [ + { "kind": "command", "command": "vaultOpen", "label": "Open Vault…" }, + { "kind": "command", "command": "vaultRemove", "label": "Remove Vault from List" }, + { "kind": "command", "command": "vaultRestoreGettingStarted", "label": "Restore Getting Started" }, + { "kind": "separator" }, + { "kind": "command", "command": "vaultAddRemote", "label": "Add Remote…", "enabled": false }, + { "kind": "command", "command": "vaultCommitPush", "label": "Commit & Push" }, + { "kind": "command", "command": "vaultPull", "label": "Pull from Remote" }, + { "kind": "command", "command": "vaultResolveConflicts", "label": "Resolve Conflicts", "enabled": false }, + { "kind": "command", "command": "vaultViewChanges", "label": "View Pending Changes" }, + { "kind": "separator" }, + { "kind": "command", "command": "vaultReload", "label": "Reload Vault" }, + { "kind": "command", "command": "vaultRepair", "label": "Repair Vault" }, + { "kind": "command", "command": "vaultInstallMcp", "label": "Set Up External AI Tools…" } + ] + } + ], + "appMenu": [ + { "kind": "command", "command": "appCheckForUpdates", "label": "Check for Updates..." }, + { "kind": "separator" }, + { "kind": "command", "command": "appSettings", "label": "Settings..." } + ], + "menuStateGroups": { + "noteDependent": [ + { "command": "fileSave" }, + { "command": "noteToggleOrganized" }, + { "command": "noteArchive" }, + { "command": "noteDelete" }, + { "command": "editToggleRawEditor" }, + { "command": "editToggleDiff" }, + { "command": "viewToggleBacklinks" }, + { "command": "noteOpenInNewWindow" } + ], + "editorFindDependent": [ + { "command": "editFindInNote" }, + { "command": "editReplaceInNote" } + ], + "noteListSearchDependent": [ + { "id": "edit-toggle-note-list-search" } + ], + "restoreDeletedDependent": [ + { "command": "noteRestoreDeleted" } + ], + "gitCommitDependent": [ + { "command": "vaultCommitPush" } + ], + "gitConflictDependent": [ + { "command": "vaultResolveConflicts" } + ], + "gitNoRemoteDependent": [ + { "command": "vaultAddRemote" } + ] + } +}