refactor: unify shortcut command routing

This commit is contained in:
lucaronin
2026-04-11 10:39:08 +02:00
parent c24f60c594
commit b1bc056afb
15 changed files with 793 additions and 297 deletions

View File

@@ -671,6 +671,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `save_image` | Save base64 image to vault |
| `copy_image_to_vault` | Copy image file to vault |
| `update_menu_state` | Update native menu checkmarks and enabled/disabled state for selection-dependent actions |
| `trigger_menu_command` | Emit a native menu command ID for deterministic shortcut QA |
## Mock Layer
@@ -706,6 +707,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 (API keys, GitHub token) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
| `appCommandDispatcher` | Canonical 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`.
@@ -725,6 +727,12 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
Selection-dependent note actions are wired through both the command palette and the native Note menu. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled.
Shortcut ownership is explicit:
- Renderer-owned shortcuts flow through `useAppKeyboard` into `appCommandDispatcher.ts`
- Native-owned shortcuts flow through `menu.rs` into `useMenuEvents`, then into `appCommandDispatcher.ts`
- Deterministic QA uses `trigger_menu_command` in Tauri and `window.__laputaTest.triggerMenuCommand()` in browser runs to exercise the native-menu command path without flaky macOS key synthesis
## Auto-Release & In-App Updates
### Release Pipeline

View File

@@ -97,6 +97,7 @@ laputa-app/
│ │ ├── useCommandRegistry.ts # Command palette registry
│ │ ├── useAppCommands.ts # App-level commands
│ │ ├── useAppKeyboard.ts # Keyboard shortcuts
│ │ ├── appCommandDispatcher.ts # Shared shortcut/menu command IDs + dispatch
│ │ ├── useSettings.ts # App settings
│ │ ├── useOnboarding.ts # First-launch flow
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
@@ -280,10 +281,12 @@ type SidebarSelection =
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. The native macOS menu bar also triggers commands via `useMenuEvents`.
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Renderer shortcuts (`useAppKeyboard`) and native menu events (`useMenuEvents`) both route through `appCommandDispatcher.ts`, so shortcut behavior is owned by shared command IDs instead of duplicate handler paths.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
For automated QA, prefer the `window.__laputaTest.triggerMenuCommand()` bridge for native-owned shortcuts and `window.__laputaTest.dispatchAppCommand()` only for renderer-only command paths. Do not treat flaky synthesized macOS keystrokes as proof that a native accelerator works.
## Running Tests
```bash
@@ -337,7 +340,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
1. Register the command in `useAppCommands.ts` via the command registry
2. Add a corresponding menu bar item in `menu.rs` for discoverability
3. If it has a keyboard shortcut, register it in `useAppKeyboard.ts`
3. If it has a keyboard shortcut, map it to the canonical command ID in `appCommandDispatcher.ts` and register ownership in `useAppKeyboard.ts`
4. If its enabled state depends on runtime selection (active note, deleted preview, Git status, etc.), thread that flag through `useMenuEvents.ts` and `update_menu_state` so the native menu enables/disables correctly
### Modify styling

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0050"
title: "Deterministic shortcut command routing"
status: active
date: 2026-04-11
---
## Context
Laputa is keyboard-first, but shortcut execution had split ownership: `useAppKeyboard` handled some shortcuts in the renderer while `menu.rs` owned others as native Tauri menu accelerators. That split made QA unreliable. Browser tests could prove the renderer path, but not the native menu path, and flaky macOS key synthesis made `Cmd+Shift+L`, `Cmd+Shift+I`, and `Cmd+N` regressions easy to miss.
## Decision
**Keyboard shortcuts and native menu accelerators now dispatch through the same canonical app command IDs. Renderer-owned shortcuts call the shared dispatcher directly; native menu items emit the same IDs into the frontend, and tests get a deterministic menu-command trigger that exercises that route without relying on synthesized native keystrokes.**
## Options considered
- **Option A** (chosen): Shared command IDs plus deterministic menu-command trigger — keeps native desktop UX while making menu-owned commands testable in unit tests, Playwright, and native QA. Downside: one more command layer to maintain.
- **Option B**: Move every shortcut to the renderer — simpler automated testing, but worse macOS menu-bar parity and weaker native UX.
- **Option C**: Keep renderer and native shortcuts separate — lowest code churn, but continues to produce false confidence and shortcut regressions.
## Consequences
- `appCommandDispatcher.ts` owns the canonical shortcut command IDs and the shared execution path used by `useAppKeyboard` and `useMenuEvents`.
- Native menu routing remains explicit in `menu.rs`; adding or changing a native shortcut now requires wiring the accelerator and the matching command ID in one place.
- Automated QA can trigger menu-owned commands deterministically through the shared `window.__laputaTest.triggerMenuCommand()` bridge in browser runs and through the native `trigger_menu_command` Tauri command in desktop runs.
- Keyboard QA should prefer real menu selection or the deterministic menu-command trigger for native-owned shortcuts, and reserve synthesized keystrokes for renderer-owned shortcuts or true end-to-end spot checks.
- This decision supersedes the blanket assumption in ADR 0020 that all shortcut verification can be treated as plain keyboard-event testing.

View File

@@ -105,3 +105,4 @@ proposed → active → superseded
| [0047](0047-regex-mode-for-view-filter-conditions.md) | Regex mode for view filter conditions | active |
| [0048](0048-relative-date-expressions-in-view-filters.md) | Relative date expressions in view filter conditions | active |
| [0049](0049-per-note-icon-property.md) | Per-note icon property (_icon on individual notes) | active |
| [0050](0050-deterministic-shortcut-command-routing.md) | Deterministic shortcut command routing | active |

View File

@@ -13,7 +13,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "vitest run --coverage",

View File

@@ -3,6 +3,7 @@ use crate::menu;
use crate::settings::Settings;
use crate::vault_list;
use crate::vault_list::VaultList;
use serde::Deserialize;
use super::parse_build_label;
@@ -41,23 +42,29 @@ pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
// ── Menu commands ───────────────────────────────────────────────────────────
#[cfg(desktop)]
#[tauri::command]
pub fn update_menu_state(
app_handle: tauri::AppHandle,
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MenuStateUpdate {
has_active_note: bool,
has_modified_files: Option<bool>,
has_conflicts: Option<bool>,
has_restorable_deleted_note: Option<bool>,
}
#[cfg(desktop)]
#[tauri::command]
pub fn update_menu_state(
app_handle: tauri::AppHandle,
state: MenuStateUpdate,
) -> Result<(), String> {
menu::set_note_items_enabled(&app_handle, has_active_note);
if let Some(v) = has_modified_files {
menu::set_note_items_enabled(&app_handle, state.has_active_note);
if let Some(v) = state.has_modified_files {
menu::set_git_commit_items_enabled(&app_handle, v);
}
if let Some(v) = has_conflicts {
if let Some(v) = state.has_conflicts {
menu::set_git_conflict_items_enabled(&app_handle, v);
}
if let Some(v) = has_restorable_deleted_note {
if let Some(v) = state.has_restorable_deleted_note {
menu::set_restore_deleted_item_enabled(&app_handle, v);
}
Ok(())
@@ -67,14 +74,23 @@ pub fn update_menu_state(
#[tauri::command]
pub fn update_menu_state(
_app_handle: tauri::AppHandle,
_has_active_note: bool,
_has_modified_files: Option<bool>,
_has_conflicts: Option<bool>,
_has_restorable_deleted_note: Option<bool>,
_state: MenuStateUpdate,
) -> Result<(), String> {
Ok(())
}
#[cfg(desktop)]
#[tauri::command]
pub fn trigger_menu_command(app_handle: tauri::AppHandle, id: String) -> Result<(), String> {
menu::emit_custom_menu_event(&app_handle, &id)
}
#[cfg(mobile)]
#[tauri::command]
pub fn trigger_menu_command(_app_handle: tauri::AppHandle, _id: String) -> Result<(), String> {
Err("Native menu commands are not available on mobile".into())
}
// ── Settings & config commands ──────────────────────────────────────────────
#[tauri::command]

View File

@@ -113,6 +113,77 @@ fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn with_invoke_handler(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
builder.invoke_handler(tauri::generate_handler![
commands::list_vault,
commands::list_vault_folders,
commands::get_note_content,
commands::save_note_content,
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::rename_note_filename,
commands::auto_rename_untitled,
commands::detect_renames,
commands::update_wikilinks_for_renames,
commands::get_file_history,
commands::get_modified_files,
commands::get_file_diff,
commands::get_file_diff_at_commit,
commands::get_vault_pulse,
commands::git_commit,
commands::get_build_number,
commands::get_last_commit_info,
commands::git_pull,
commands::git_push,
commands::git_remote_status,
commands::get_conflict_files,
commands::get_conflict_mode,
commands::git_resolve_conflict,
commands::git_commit_conflict_resolution,
commands::git_discard_file,
commands::is_git_repo,
commands::init_git_repo,
commands::check_claude_cli,
commands::stream_claude_chat,
commands::stream_claude_agent,
commands::reload_vault,
commands::reload_vault_entry,
commands::sync_note_title,
commands::save_image,
commands::copy_image_to_vault,
commands::delete_note,
commands::batch_delete_notes,
commands::migrate_is_a_to_type,
commands::create_vault_folder,
commands::batch_archive_notes,
commands::get_settings,
commands::update_menu_state,
commands::trigger_menu_command,
commands::save_settings,
commands::load_vault_list,
commands::save_vault_list,
commands::github_list_repos,
commands::github_create_repo,
commands::clone_repo,
commands::github_device_flow_start,
commands::github_device_flow_poll,
commands::github_get_user,
commands::search_vault,
commands::create_empty_vault,
commands::create_getting_started_vault,
commands::check_vault_exists,
commands::get_default_vault_path,
commands::register_mcp_tools,
commands::check_mcp_status,
commands::repair_vault,
commands::reinit_telemetry,
commands::list_views,
commands::save_view_cmd,
commands::delete_view_cmd
])
}
#[cfg(desktop)]
fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
use tauri::Manager;
@@ -134,75 +205,8 @@ pub fn run() {
#[cfg(desktop)]
let builder = builder.manage(WsBridgeChild(Mutex::new(None)));
builder
with_invoke_handler(builder)
.setup(setup_app)
.invoke_handler(tauri::generate_handler![
commands::list_vault,
commands::list_vault_folders,
commands::get_note_content,
commands::save_note_content,
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::rename_note_filename,
commands::auto_rename_untitled,
commands::detect_renames,
commands::update_wikilinks_for_renames,
commands::get_file_history,
commands::get_modified_files,
commands::get_file_diff,
commands::get_file_diff_at_commit,
commands::get_vault_pulse,
commands::git_commit,
commands::get_build_number,
commands::get_last_commit_info,
commands::git_pull,
commands::git_push,
commands::git_remote_status,
commands::get_conflict_files,
commands::get_conflict_mode,
commands::git_resolve_conflict,
commands::git_commit_conflict_resolution,
commands::git_discard_file,
commands::is_git_repo,
commands::init_git_repo,
commands::check_claude_cli,
commands::stream_claude_chat,
commands::stream_claude_agent,
commands::reload_vault,
commands::reload_vault_entry,
commands::sync_note_title,
commands::save_image,
commands::copy_image_to_vault,
commands::delete_note,
commands::batch_delete_notes,
commands::migrate_is_a_to_type,
commands::create_vault_folder,
commands::batch_archive_notes,
commands::get_settings,
commands::update_menu_state,
commands::save_settings,
commands::load_vault_list,
commands::save_vault_list,
commands::github_list_repos,
commands::github_create_repo,
commands::clone_repo,
commands::github_device_flow_start,
commands::github_device_flow_poll,
commands::github_get_user,
commands::search_vault,
commands::create_empty_vault,
commands::create_getting_started_vault,
commands::check_vault_exists,
commands::get_default_vault_path,
commands::register_mcp_tools,
commands::check_mcp_status,
commands::repair_vault,
commands::reinit_telemetry,
commands::list_views,
commands::save_view_cmd,
commands::delete_view_cmd
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {

View File

@@ -78,6 +78,7 @@ const CUSTOM_IDS: &[&str] = &[
GO_ALL_NOTES,
GO_ARCHIVED,
GO_CHANGES,
GO_INBOX,
NOTE_TOGGLE_ORGANIZED,
NOTE_ARCHIVE,
NOTE_DELETE,
@@ -213,6 +214,7 @@ fn build_view_menu(app: &App) -> MenuResult {
.build(app)?;
let toggle_properties = MenuItemBuilder::new("Toggle Properties Panel")
.id(VIEW_TOGGLE_PROPERTIES)
.accelerator("CmdOrCtrl+Shift+I")
.build(app)?;
let command_palette = MenuItemBuilder::new("Command Palette")
.id(VIEW_COMMAND_PALETTE)
@@ -404,14 +406,21 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
app.on_menu_event(|app_handle, event| {
let id = event.id().0.as_str();
if CUSTOM_IDS.contains(&id) {
let _ = app_handle.emit("menu-event", id);
}
let _ = emit_custom_menu_event(app_handle, id);
});
Ok(())
}
pub fn emit_custom_menu_event(app_handle: &AppHandle, id: &str) -> Result<(), String> {
if !CUSTOM_IDS.contains(&id) {
return Err(format!("Unknown custom menu event: {id}"));
}
app_handle
.emit("menu-event", id)
.map_err(|err| format!("Failed to emit menu-event {id}: {err}"))
}
fn set_items_enabled(app_handle: &AppHandle, ids: &[&str], enabled: bool) {
let Some(menu) = app_handle.menu() else {
return;

View File

@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from 'vitest'
import {
APP_COMMAND_IDS,
dispatchAppCommand,
isAppCommandId,
isNativeMenuCommandId,
type AppCommandHandlers,
} from './appCommandDispatcher'
function makeHandlers(): AppCommandHandlers {
return {
onSetViewMode: vi.fn(),
onCreateNote: vi.fn(),
onCreateType: vi.fn(),
onOpenDailyNote: vi.fn(),
onQuickOpen: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onToggleInspector: vi.fn(),
onCommandPalette: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
onToggleOrganized: vi.fn(),
onToggleFavorite: vi.fn(),
onArchiveNote: vi.fn(),
onDeleteNote: vi.fn(),
onSearch: vi.fn(),
onToggleRawEditor: vi.fn(),
onToggleDiff: vi.fn(),
onToggleAIChat: vi.fn(),
onGoBack: vi.fn(),
onGoForward: vi.fn(),
onCheckForUpdates: vi.fn(),
onSelectFilter: vi.fn(),
onOpenVault: vi.fn(),
onRemoveActiveVault: vi.fn(),
onRestoreGettingStarted: vi.fn(),
onCommitPush: vi.fn(),
onPull: vi.fn(),
onResolveConflicts: vi.fn(),
onViewChanges: vi.fn(),
onInstallMcp: vi.fn(),
onOpenInNewWindow: vi.fn(),
onReloadVault: vi.fn(),
onRepairVault: vi.fn(),
onRestoreDeletedNote: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' },
}
}
describe('appCommandDispatcher', () => {
it('recognizes valid command ids', () => {
expect(isAppCommandId(APP_COMMAND_IDS.fileNewNote)).toBe(true)
expect(isAppCommandId('not-a-command')).toBe(false)
})
it('distinguishes native menu ids from keyboard-only ids', () => {
expect(isNativeMenuCommandId(APP_COMMAND_IDS.fileNewNote)).toBe(true)
expect(isNativeMenuCommandId(APP_COMMAND_IDS.noteToggleFavorite)).toBe(false)
})
it('dispatches create note through the shared command path', () => {
const handlers = makeHandlers()
expect(dispatchAppCommand(APP_COMMAND_IDS.fileNewNote, handlers)).toBe(true)
expect(handlers.onCreateNote).toHaveBeenCalled()
})
it('dispatches inspector toggle through the shared command path', () => {
const handlers = makeHandlers()
expect(dispatchAppCommand(APP_COMMAND_IDS.viewToggleProperties, handlers)).toBe(true)
expect(handlers.onToggleInspector).toHaveBeenCalled()
})
it('dispatches AI panel toggle through the shared command path', () => {
const handlers = makeHandlers()
expect(dispatchAppCommand(APP_COMMAND_IDS.viewToggleAiChat, handlers)).toBe(true)
expect(handlers.onToggleAIChat).toHaveBeenCalled()
})
it('uses the active note for note-scoped commands', () => {
const handlers = makeHandlers()
expect(dispatchAppCommand(APP_COMMAND_IDS.noteToggleFavorite, handlers)).toBe(true)
expect(dispatchAppCommand(APP_COMMAND_IDS.noteToggleOrganized, handlers)).toBe(true)
expect(dispatchAppCommand(APP_COMMAND_IDS.noteDelete, handlers)).toBe(true)
expect(handlers.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
expect(handlers.onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
expect(handlers.onDeleteNote).toHaveBeenCalledWith('/vault/test.md')
})
it('no-ops note-scoped commands when there is no active note', () => {
const handlers = makeHandlers()
handlers.activeTabPathRef.current = null
expect(dispatchAppCommand(APP_COMMAND_IDS.noteToggleFavorite, handlers)).toBe(false)
expect(dispatchAppCommand(APP_COMMAND_IDS.noteToggleOrganized, handlers)).toBe(false)
expect(dispatchAppCommand(APP_COMMAND_IDS.noteDelete, handlers)).toBe(false)
expect(handlers.onToggleFavorite).not.toHaveBeenCalled()
expect(handlers.onToggleOrganized).not.toHaveBeenCalled()
expect(handlers.onDeleteNote).not.toHaveBeenCalled()
})
it('dispatches navigation filters through the same shared command path', () => {
const handlers = makeHandlers()
expect(dispatchAppCommand(APP_COMMAND_IDS.goChanges, handlers)).toBe(true)
expect(handlers.onSelectFilter).toHaveBeenCalledWith('changes')
})
})

View File

@@ -0,0 +1,290 @@
import type { MutableRefObject } from 'react'
import type { SidebarFilter } from '../types'
import type { ViewMode } from './useViewMode'
export const APP_COMMAND_EVENT_NAME = 'laputa:dispatch-command'
export const APP_MENU_EVENT_NAME = 'laputa:dispatch-menu-command'
export const APP_COMMAND_IDS = {
appSettings: 'app-settings',
appCheckForUpdates: 'app-check-for-updates',
fileNewNote: 'file-new-note',
fileNewType: 'file-new-type',
fileDailyNote: 'file-daily-note',
fileQuickOpen: 'file-quick-open',
fileSave: 'file-save',
editFindInVault: 'edit-find-in-vault',
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',
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
export type AppCommandId = (typeof APP_COMMAND_IDS)[keyof typeof APP_COMMAND_IDS]
const VIEW_MODE_COMMANDS: Partial<Record<AppCommandId, ViewMode>> = {
[APP_COMMAND_IDS.viewEditorOnly]: 'editor-only',
[APP_COMMAND_IDS.viewEditorList]: 'editor-list',
[APP_COMMAND_IDS.viewAll]: 'all',
}
const FILTER_COMMANDS: Partial<Record<AppCommandId, SidebarFilter>> = {
[APP_COMMAND_IDS.goAllNotes]: 'all',
[APP_COMMAND_IDS.goArchived]: 'archived',
[APP_COMMAND_IDS.goChanges]: 'changes',
[APP_COMMAND_IDS.goInbox]: 'inbox',
}
const APP_COMMAND_SET = new Set<string>(Object.values(APP_COMMAND_IDS))
const NATIVE_MENU_COMMAND_IDS = [
APP_COMMAND_IDS.appSettings,
APP_COMMAND_IDS.appCheckForUpdates,
APP_COMMAND_IDS.fileNewNote,
APP_COMMAND_IDS.fileNewType,
APP_COMMAND_IDS.fileDailyNote,
APP_COMMAND_IDS.fileQuickOpen,
APP_COMMAND_IDS.fileSave,
APP_COMMAND_IDS.editFindInVault,
APP_COMMAND_IDS.editToggleRawEditor,
APP_COMMAND_IDS.editToggleDiff,
APP_COMMAND_IDS.viewEditorOnly,
APP_COMMAND_IDS.viewEditorList,
APP_COMMAND_IDS.viewAll,
APP_COMMAND_IDS.viewToggleProperties,
APP_COMMAND_IDS.viewToggleAiChat,
APP_COMMAND_IDS.viewToggleBacklinks,
APP_COMMAND_IDS.viewCommandPalette,
APP_COMMAND_IDS.viewZoomIn,
APP_COMMAND_IDS.viewZoomOut,
APP_COMMAND_IDS.viewZoomReset,
APP_COMMAND_IDS.viewGoBack,
APP_COMMAND_IDS.viewGoForward,
APP_COMMAND_IDS.goAllNotes,
APP_COMMAND_IDS.goArchived,
APP_COMMAND_IDS.goChanges,
APP_COMMAND_IDS.goInbox,
APP_COMMAND_IDS.noteToggleOrganized,
APP_COMMAND_IDS.noteArchive,
APP_COMMAND_IDS.noteDelete,
APP_COMMAND_IDS.noteOpenInNewWindow,
APP_COMMAND_IDS.noteRestoreDeleted,
APP_COMMAND_IDS.vaultOpen,
APP_COMMAND_IDS.vaultRemove,
APP_COMMAND_IDS.vaultRestoreGettingStarted,
APP_COMMAND_IDS.vaultCommitPush,
APP_COMMAND_IDS.vaultPull,
APP_COMMAND_IDS.vaultResolveConflicts,
APP_COMMAND_IDS.vaultViewChanges,
APP_COMMAND_IDS.vaultInstallMcp,
APP_COMMAND_IDS.vaultReload,
APP_COMMAND_IDS.vaultRepair,
] as const
const NATIVE_MENU_COMMAND_SET = new Set<string>(NATIVE_MENU_COMMAND_IDS)
export type NativeMenuCommandId = (typeof NATIVE_MENU_COMMAND_IDS)[number]
export interface AppCommandHandlers {
onSetViewMode: (mode: ViewMode) => void
onCreateNote: () => void
onCreateType?: () => void
onOpenDailyNote: () => void
onQuickOpen: () => void
onSave: () => void
onOpenSettings: () => void
onToggleInspector: () => void
onCommandPalette: () => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
onToggleOrganized?: (path: string) => void
onToggleFavorite?: (path: string) => void
onArchiveNote: (path: string) => void
onDeleteNote: (path: string) => void
onSearch: () => void
onToggleRawEditor?: () => void
onToggleDiff?: () => void
onToggleAIChat?: () => void
onGoBack?: () => void
onGoForward?: () => void
onCheckForUpdates?: () => void
onSelectFilter?: (filter: SidebarFilter) => void
onOpenVault?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
onCommitPush?: () => void
onPull?: () => void
onResolveConflicts?: () => void
onViewChanges?: () => void
onInstallMcp?: () => void
onOpenInNewWindow?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onRestoreDeletedNote?: () => void
activeTabPathRef: MutableRefObject<string | null>
}
function dispatchActiveTabCommand(
pathRef: MutableRefObject<string | null>,
onPath: (path: string) => void,
): boolean {
const path = pathRef.current
if (!path) return false
onPath(path)
return true
}
export function isAppCommandId(value: string): value is AppCommandId {
return APP_COMMAND_SET.has(value)
}
export function isNativeMenuCommandId(value: string): value is NativeMenuCommandId {
return NATIVE_MENU_COMMAND_SET.has(value)
}
export function dispatchAppCommand(id: AppCommandId, handlers: AppCommandHandlers): boolean {
const viewMode = VIEW_MODE_COMMANDS[id]
if (viewMode) {
handlers.onSetViewMode(viewMode)
return true
}
const filter = FILTER_COMMANDS[id]
if (filter) {
handlers.onSelectFilter?.(filter)
return true
}
switch (id) {
case APP_COMMAND_IDS.appSettings:
handlers.onOpenSettings()
return true
case APP_COMMAND_IDS.appCheckForUpdates:
handlers.onCheckForUpdates?.()
return true
case APP_COMMAND_IDS.fileNewNote:
handlers.onCreateNote()
return true
case APP_COMMAND_IDS.fileNewType:
handlers.onCreateType?.()
return true
case APP_COMMAND_IDS.fileDailyNote:
handlers.onOpenDailyNote()
return true
case APP_COMMAND_IDS.fileQuickOpen:
handlers.onQuickOpen()
return true
case APP_COMMAND_IDS.fileSave:
handlers.onSave()
return true
case APP_COMMAND_IDS.editFindInVault:
handlers.onSearch()
return true
case APP_COMMAND_IDS.editToggleRawEditor:
handlers.onToggleRawEditor?.()
return true
case APP_COMMAND_IDS.editToggleDiff:
handlers.onToggleDiff?.()
return true
case APP_COMMAND_IDS.viewToggleProperties:
case APP_COMMAND_IDS.viewToggleBacklinks:
handlers.onToggleInspector()
return true
case APP_COMMAND_IDS.viewToggleAiChat:
handlers.onToggleAIChat?.()
return true
case APP_COMMAND_IDS.viewCommandPalette:
handlers.onCommandPalette()
return true
case APP_COMMAND_IDS.viewZoomIn:
handlers.onZoomIn()
return true
case APP_COMMAND_IDS.viewZoomOut:
handlers.onZoomOut()
return true
case APP_COMMAND_IDS.viewZoomReset:
handlers.onZoomReset()
return true
case APP_COMMAND_IDS.viewGoBack:
handlers.onGoBack?.()
return true
case APP_COMMAND_IDS.viewGoForward:
handlers.onGoForward?.()
return true
case APP_COMMAND_IDS.noteToggleOrganized:
return dispatchActiveTabCommand(handlers.activeTabPathRef, (path) => handlers.onToggleOrganized?.(path))
case APP_COMMAND_IDS.noteToggleFavorite:
return dispatchActiveTabCommand(handlers.activeTabPathRef, (path) => handlers.onToggleFavorite?.(path))
case APP_COMMAND_IDS.noteArchive:
return dispatchActiveTabCommand(handlers.activeTabPathRef, handlers.onArchiveNote)
case APP_COMMAND_IDS.noteDelete:
return dispatchActiveTabCommand(handlers.activeTabPathRef, handlers.onDeleteNote)
case APP_COMMAND_IDS.noteOpenInNewWindow:
handlers.onOpenInNewWindow?.()
return true
case APP_COMMAND_IDS.noteRestoreDeleted:
handlers.onRestoreDeletedNote?.()
return true
case APP_COMMAND_IDS.vaultOpen:
handlers.onOpenVault?.()
return true
case APP_COMMAND_IDS.vaultRemove:
handlers.onRemoveActiveVault?.()
return true
case APP_COMMAND_IDS.vaultRestoreGettingStarted:
handlers.onRestoreGettingStarted?.()
return true
case APP_COMMAND_IDS.vaultCommitPush:
handlers.onCommitPush?.()
return true
case APP_COMMAND_IDS.vaultPull:
handlers.onPull?.()
return true
case APP_COMMAND_IDS.vaultResolveConflicts:
handlers.onResolveConflicts?.()
return true
case APP_COMMAND_IDS.vaultViewChanges:
handlers.onViewChanges?.()
return true
case APP_COMMAND_IDS.vaultInstallMcp:
handlers.onInstallMcp?.()
return true
case APP_COMMAND_IDS.vaultReload:
handlers.onReloadVault?.()
return true
case APP_COMMAND_IDS.vaultRepair:
handlers.onRepairVault?.()
return true
}
}

View File

@@ -1,40 +1,45 @@
import type { MutableRefObject } from 'react'
import type { ViewMode } from './useViewMode'
import { trackEvent } from '../lib/telemetry'
import { isTauri } from '../mock-tauri'
import {
APP_COMMAND_IDS,
dispatchAppCommand,
type AppCommandId,
type AppCommandHandlers,
} from './appCommandDispatcher'
export interface KeyboardActions {
onQuickOpen: () => void
onCommandPalette: () => void
onSearch: () => void
onCreateNote: () => void
onOpenDailyNote: () => void
onSave: () => void
onOpenSettings: () => void
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
onSetViewMode: (mode: ViewMode) => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
onGoBack?: () => void
onGoForward?: () => void
onToggleAIChat?: () => void
onToggleRawEditor?: () => void
onToggleInspector?: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onOpenInNewWindow?: () => void
activeTabPathRef: MutableRefObject<string | null>
}
export type KeyboardActions = Pick<
AppCommandHandlers,
| 'onQuickOpen'
| 'onCommandPalette'
| 'onSearch'
| 'onCreateNote'
| 'onOpenDailyNote'
| 'onSave'
| 'onOpenSettings'
| 'onDeleteNote'
| 'onArchiveNote'
| 'onSetViewMode'
| 'onZoomIn'
| 'onZoomOut'
| 'onZoomReset'
| 'onGoBack'
| 'onGoForward'
| 'onToggleAIChat'
| 'onToggleRawEditor'
| 'onToggleInspector'
| 'onToggleFavorite'
| 'onToggleOrganized'
| 'onOpenInNewWindow'
| 'activeTabPathRef'
>
type ShortcutHandler = () => void
type ShortcutMap = Record<string, ShortcutHandler>
type ShortcutMap = Record<string, AppCommandId>
type NativeMenuCombo = 'command' | 'command-shift'
const TEXT_EDITING_KEYS = new Set(['Backspace', 'Delete'])
const TAURI_NATIVE_MENU_KEYS: Record<NativeMenuCombo, Set<string>> = {
command: new Set(['1', '2', '3', 'n', 'j', 'p', 's', 'k', '=', '+', '-', '0', '[', ']', '\\', 'Backspace', 'Delete']),
command: new Set([',', '1', '2', '3', 'n', 'j', 'p', 's', 'k', '=', '+', '-', '0', '[', ']', '\\', 'e', 'Backspace', 'Delete']),
'command-shift': new Set(['f', 'i', 'o', 'l']),
}
@@ -77,48 +82,33 @@ function shouldDeferToNativeMenu(e: KeyboardEvent): boolean {
return TAURI_NATIVE_MENU_KEYS[combo].has(normalizedKey)
}
function withActiveTab(
activeTabPathRef: MutableRefObject<string | null>,
handler: (path: string) => void,
): ShortcutHandler {
return () => {
const path = activeTabPathRef.current
if (path) handler(path)
export function createCommandKeyMap(): ShortcutMap {
return {
k: APP_COMMAND_IDS.viewCommandPalette,
p: APP_COMMAND_IDS.fileQuickOpen,
n: APP_COMMAND_IDS.fileNewNote,
j: APP_COMMAND_IDS.fileDailyNote,
s: APP_COMMAND_IDS.fileSave,
',': APP_COMMAND_IDS.appSettings,
d: APP_COMMAND_IDS.noteToggleFavorite,
e: APP_COMMAND_IDS.noteToggleOrganized,
Backspace: APP_COMMAND_IDS.noteDelete,
Delete: APP_COMMAND_IDS.noteDelete,
'[': APP_COMMAND_IDS.viewGoBack,
']': APP_COMMAND_IDS.viewGoForward,
'=': APP_COMMAND_IDS.viewZoomIn,
'+': APP_COMMAND_IDS.viewZoomIn,
'-': APP_COMMAND_IDS.viewZoomOut,
'0': APP_COMMAND_IDS.viewZoomReset,
'\\': APP_COMMAND_IDS.editToggleRawEditor,
}
}
export function createCommandKeyMap(actions: KeyboardActions): ShortcutMap {
const { activeTabPathRef } = actions
export function createShiftCommandKeyMap(): ShortcutMap {
return {
k: actions.onCommandPalette,
p: actions.onQuickOpen,
n: actions.onCreateNote,
j: actions.onOpenDailyNote,
s: actions.onSave,
',': actions.onOpenSettings,
d: withActiveTab(activeTabPathRef, (path) => actions.onToggleFavorite?.(path)),
e: withActiveTab(activeTabPathRef, (path) => actions.onToggleOrganized?.(path)),
Backspace: withActiveTab(activeTabPathRef, actions.onDeleteNote),
Delete: withActiveTab(activeTabPathRef, actions.onDeleteNote),
'[': () => actions.onGoBack?.(),
']': () => actions.onGoForward?.(),
'=': actions.onZoomIn,
'+': actions.onZoomIn,
'-': actions.onZoomOut,
'0': actions.onZoomReset,
'\\': () => actions.onToggleRawEditor?.(),
}
}
export function createShiftCommandKeyMap(actions: KeyboardActions): ShortcutMap {
return {
f: () => {
trackEvent('search_used')
actions.onSearch()
},
i: () => actions.onToggleInspector?.(),
o: () => actions.onOpenInNewWindow?.(),
f: APP_COMMAND_IDS.editFindInVault,
i: APP_COMMAND_IDS.viewToggleProperties,
o: APP_COMMAND_IDS.noteOpenInNewWindow,
}
}
@@ -131,39 +121,42 @@ export function handleViewModeKey(e: KeyboardEvent, onSetViewMode: (mode: ViewMo
return true
}
export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean {
export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap, actions: KeyboardActions): boolean {
if (isCommandOrCtrlOnly(e) === false || e.shiftKey) return false
const handler = keyMap[e.key]
if (handler === undefined) return false
const commandId = keyMap[e.key]
if (commandId === undefined) return false
if (TEXT_EDITING_KEYS.has(e.key) && isTextInputFocused()) return false
e.preventDefault()
handler()
dispatchAppCommand(commandId, actions)
return true
}
export function handleAiPanelKey(e: KeyboardEvent, onToggleAIChat?: () => void): boolean {
export function handleAiPanelKey(e: KeyboardEvent, actions: KeyboardActions): boolean {
const matchesAiPanelShortcut = e.code === 'KeyL' || e.key.toLowerCase() === 'l'
if (isCommandShiftOnly(e) === false || matchesAiPanelShortcut === false || onToggleAIChat === undefined) return false
if (isCommandShiftOnly(e) === false || matchesAiPanelShortcut === false || actions.onToggleAIChat === undefined) return false
e.preventDefault()
onToggleAIChat()
dispatchAppCommand(APP_COMMAND_IDS.viewToggleAiChat, actions)
return true
}
export function handleShiftCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean {
export function handleShiftCommandKey(e: KeyboardEvent, keyMap: ShortcutMap, actions: KeyboardActions): boolean {
if (isCommandOrCtrlShiftOnly(e) === false) return false
const handler = keyMap[e.key.toLowerCase()]
if (handler === undefined) return false
const commandId = keyMap[e.key.toLowerCase()]
if (commandId === undefined) return false
e.preventDefault()
handler()
if (commandId === APP_COMMAND_IDS.editFindInVault) {
trackEvent('search_used')
}
dispatchAppCommand(commandId, actions)
return true
}
export function handleAppKeyboardEvent(actions: KeyboardActions, event: KeyboardEvent) {
if (shouldDeferToNativeMenu(event)) return
if (handleAiPanelKey(event, actions.onToggleAIChat)) return
const shiftKeyMap = createShiftCommandKeyMap(actions)
if (handleShiftCommandKey(event, shiftKeyMap)) return
if (handleAiPanelKey(event, actions)) return
const shiftKeyMap = createShiftCommandKeyMap()
if (handleShiftCommandKey(event, shiftKeyMap, actions)) return
if (handleViewModeKey(event, actions.onSetViewMode)) return
const cmdKeyMap = createCommandKeyMap(actions)
handleCommandKey(event, cmdKeyMap)
const cmdKeyMap = createCommandKeyMap()
handleCommandKey(event, cmdKeyMap, actions)
}

View File

@@ -1,148 +1,67 @@
import { useEffect, useRef } from 'react'
import { isTauri } from '../mock-tauri'
import type { SidebarFilter } from '../types'
import type { ViewMode } from './useViewMode'
import {
APP_COMMAND_EVENT_NAME,
APP_MENU_EVENT_NAME,
dispatchAppCommand,
isAppCommandId,
type AppCommandHandlers,
} from './appCommandDispatcher'
export interface MenuEventHandlers {
onSetViewMode: (mode: ViewMode) => void
onCreateNote: () => void
onCreateType?: () => void
onOpenDailyNote: () => void
onQuickOpen: () => void
onSave: () => void
onOpenSettings: () => void
onToggleInspector: () => void
onCommandPalette: () => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
onToggleOrganized?: (path: string) => void
onArchiveNote: (path: string) => void
onDeleteNote: (path: string) => void
onSearch: () => void
onToggleRawEditor?: () => void
onToggleDiff?: () => void
onToggleAIChat?: () => void
onGoBack?: () => void
onGoForward?: () => void
onCheckForUpdates?: () => void
onSelectFilter?: (filter: SidebarFilter) => void
onOpenVault?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
onCommitPush?: () => void
onPull?: () => void
onResolveConflicts?: () => void
onViewChanges?: () => void
onInstallMcp?: () => void
onOpenInNewWindow?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onRestoreDeletedNote?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
export interface MenuEventHandlers extends AppCommandHandlers {
activeTabPath: string | null
modifiedCount?: number
conflictCount?: number
hasRestorableDeletedNote?: boolean
}
const VIEW_MODE_MAP: Record<string, ViewMode> = {
'view-editor-only': 'editor-only',
'view-editor-list': 'editor-list',
'view-all': 'all',
interface MenuStatePayload {
hasActiveNote: boolean
hasModifiedFiles?: boolean
hasConflicts?: boolean
hasRestorableDeletedNote?: boolean
}
type SimpleHandler = 'onCreateNote' | 'onOpenDailyNote' | 'onQuickOpen' | 'onSave' | 'onOpenSettings' | 'onToggleInspector' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' | 'onZoomReset' | 'onSearch'
const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
'file-new-note': 'onCreateNote',
'file-daily-note': 'onOpenDailyNote',
'file-quick-open': 'onQuickOpen',
'file-save': 'onSave',
'app-settings': 'onOpenSettings',
'view-toggle-properties': 'onToggleInspector',
'view-toggle-backlinks': 'onToggleInspector',
'view-command-palette': 'onCommandPalette',
'view-zoom-in': 'onZoomIn',
'view-zoom-out': 'onZoomOut',
'view-zoom-reset': 'onZoomReset',
'edit-find-in-vault': 'onSearch',
function readCustomEventDetail(event: Event): string | null {
if (!(event instanceof CustomEvent) || typeof event.detail !== 'string') {
return null
}
return event.detail
}
const FILTER_MAP: Record<string, SidebarFilter> = {
'go-all-notes': 'all',
'go-archived': 'archived',
'go-changes': 'changes',
'go-inbox': 'inbox',
function createWindowCommandListener(
dispatch: (id: string) => void,
): (event: Event) => void {
return (event: Event) => {
const detail = readCustomEventDetail(event)
if (detail) {
dispatch(detail)
}
}
}
type OptionalHandler =
| 'onGoBack' | 'onGoForward' | 'onCheckForUpdates'
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
| 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault'
| 'onOpenInNewWindow' | 'onRestoreDeletedNote'
function syncNativeMenuState(state: MenuStatePayload): void {
if (!isTauri()) return
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'view-go-back': 'onGoBack',
'view-go-forward': 'onGoForward',
'app-check-for-updates': 'onCheckForUpdates',
'file-new-type': 'onCreateType',
'edit-toggle-raw-editor': 'onToggleRawEditor',
'edit-toggle-diff': 'onToggleDiff',
'view-toggle-ai-chat': 'onToggleAIChat',
'vault-open': 'onOpenVault',
'vault-remove': 'onRemoveActiveVault',
'vault-restore-getting-started': 'onRestoreGettingStarted',
'vault-commit-push': 'onCommitPush',
'vault-pull': 'onPull',
'vault-resolve-conflicts': 'onResolveConflicts',
'vault-view-changes': 'onViewChanges',
'vault-install-mcp': 'onInstallMcp',
'vault-reload': 'onReloadVault',
'vault-repair': 'onRepairVault',
'note-open-in-new-window': 'onOpenInNewWindow',
'note-restore-deleted': 'onRestoreDeletedNote',
}
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
const path = h.activeTabPathRef.current
if (!path) return id === 'note-toggle-organized' || id === 'note-archive' || id === 'note-delete'
if (id === 'note-toggle-organized') { h.onToggleOrganized?.(path); return true }
if (id === 'note-archive') { h.onArchiveNote(path); return true }
if (id === 'note-delete') { h.onDeleteNote(path); return true }
return false
}
function dispatchOptionalEvent(id: string, h: MenuEventHandlers): boolean {
const handler = OPTIONAL_EVENT_MAP[id]
if (handler) { h[handler]?.(); return true }
return false
}
function dispatchFilterEvent(id: string, h: MenuEventHandlers): boolean {
const filter = FILTER_MAP[id]
if (filter) { h.onSelectFilter?.(filter); return true }
return false
import('@tauri-apps/api/core')
.then(({ invoke }) => invoke('update_menu_state', { state }))
.catch(() => {})
}
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
const viewMode = VIEW_MODE_MAP[id]
if (viewMode) { h.onSetViewMode(viewMode); return }
const simple = SIMPLE_EVENT_MAP[id]
if (simple) { h[simple](); return }
if (dispatchActiveTabEvent(id, h)) return
if (dispatchFilterEvent(id, h)) return
dispatchOptionalEvent(id, h)
if (!isAppCommandId(id)) return
dispatchAppCommand(id, h)
}
/** Listen for native macOS menu events and dispatch them to the appropriate handlers. */
export function useMenuEvents(handlers: MenuEventHandlers) {
const ref = useRef(handlers)
ref.current = handlers
const hasActiveNote = handlers.activeTabPath !== null
const hasModifiedFiles = handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined
const hasConflicts = handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined
const hasRestorableDeletedNote = handlers.hasRestorableDeletedNote
// Subscribe once to Tauri menu events
useEffect(() => {
@@ -159,16 +78,33 @@ export function useMenuEvents(handlers: MenuEventHandlers) {
return () => cleanup?.()
}, [])
useEffect(() => {
const handleCommandEvent = createWindowCommandListener((detail) => {
if (isAppCommandId(detail)) {
dispatchAppCommand(detail, ref.current)
}
})
window.addEventListener(APP_COMMAND_EVENT_NAME, handleCommandEvent)
return () => window.removeEventListener(APP_COMMAND_EVENT_NAME, handleCommandEvent)
}, [])
useEffect(() => {
const handleMenuCommandEvent = createWindowCommandListener((detail) => {
dispatchMenuEvent(detail, ref.current)
})
window.addEventListener(APP_MENU_EVENT_NAME, handleMenuCommandEvent)
return () => window.removeEventListener(APP_MENU_EVENT_NAME, handleMenuCommandEvent)
}, [])
// Sync menu item enabled state when active note or git state changes
useEffect(() => {
if (!isTauri()) return
import('@tauri-apps/api/core').then(({ invoke }) => {
invoke('update_menu_state', {
hasActiveNote: handlers.activeTabPath !== null,
hasModifiedFiles: handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined,
hasConflicts: handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined,
hasRestorableDeletedNote: handlers.hasRestorableDeletedNote,
})
}).catch(() => {})
}, [handlers.activeTabPath, handlers.modifiedCount, handlers.conflictCount, handlers.hasRestorableDeletedNote])
syncNativeMenuState({
hasActiveNote,
hasModifiedFiles,
hasConflicts,
hasRestorableDeletedNote,
})
}, [hasActiveNote, hasModifiedFiles, hasConflicts, hasRestorableDeletedNote])
}

View File

@@ -3,6 +3,21 @@ import { createRoot } from 'react-dom/client'
import { TooltipProvider } from '@/components/ui/tooltip'
import './index.css'
import App from './App.tsx'
import {
APP_COMMAND_EVENT_NAME,
APP_MENU_EVENT_NAME,
isAppCommandId,
isNativeMenuCommandId,
} from './hooks/appCommandDispatcher'
declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
}
}
}
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
// at native level before React's synthetic events can call preventDefault).
@@ -12,6 +27,28 @@ if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) {
document.addEventListener('contextmenu', (e) => e.preventDefault(), true)
}
window.__laputaTest = {
dispatchAppCommand(id: string) {
if (!isAppCommandId(id)) {
throw new Error(`Unknown app command: ${id}`)
}
window.dispatchEvent(new CustomEvent(APP_COMMAND_EVENT_NAME, { detail: id }))
},
async triggerMenuCommand(id: string) {
if (!isNativeMenuCommandId(id)) {
throw new Error(`Unknown native menu command: ${id}`)
}
if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) {
const { invoke } = await import('@tauri-apps/api/core')
return invoke('trigger_menu_command', { id })
}
window.dispatchEvent(new CustomEvent(APP_MENU_EVENT_NAME, { detail: id }))
return undefined
},
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TooltipProvider>

View File

@@ -0,0 +1,53 @@
import { test, expect, type Page } from '@playwright/test'
import { triggerMenuCommand } from './testBridge'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
let tempVaultDir: string
function untitledRow(page: Page, typeLabel: string) {
return page.getByText(new RegExp(`^Untitled ${typeLabel}(?: \\d+)?$`, 'i')).first()
}
test.describe('keyboard command routing', () => {
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('native menu trigger creates a note through the shared command path @smoke', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await openFixtureVault(page, tempVaultDir)
await triggerMenuCommand(page, 'file-new-note')
await expect(untitledRow(page, 'note')).toBeVisible({ timeout: 5_000 })
expect(errors).toEqual([])
})
test('native menu trigger toggles the properties panel through the shared command path', async ({ page }) => {
await openFixtureVault(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await triggerMenuCommand(page, 'view-toggle-properties')
await expect(page.getByTitle('Close Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
await triggerMenuCommand(page, 'view-toggle-properties')
await expect(page.getByTitle('Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
})
test('native menu trigger toggles the AI panel through the shared command path', async ({ page }) => {
await openFixtureVault(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await triggerMenuCommand(page, 'view-toggle-ai-chat')
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTitle('Close AI panel')).toBeVisible()
await triggerMenuCommand(page, 'view-toggle-ai-chat')
await expect(page.getByTestId('ai-panel')).not.toBeVisible({ timeout: 5_000 })
})
})

10
tests/smoke/testBridge.ts Normal file
View File

@@ -0,0 +1,10 @@
import { type Page } from '@playwright/test'
export async function triggerMenuCommand(page: Page, id: string): Promise<void> {
await page.evaluate(async (commandId) => {
if (!window.__laputaTest?.triggerMenuCommand) {
throw new Error('Laputa test bridge is missing triggerMenuCommand')
}
await window.__laputaTest.triggerMenuCommand(commandId)
}, id)
}