refactor: unify shortcut command routing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
29
docs/adr/0050-deterministic-shortcut-command-routing.md
Normal file
29
docs/adr/0050-deterministic-shortcut-command-routing.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
107
src/hooks/appCommandDispatcher.test.ts
Normal file
107
src/hooks/appCommandDispatcher.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
290
src/hooks/appCommandDispatcher.ts
Normal file
290
src/hooks/appCommandDispatcher.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
37
src/main.tsx
37
src/main.tsx
@@ -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>
|
||||
|
||||
53
tests/smoke/keyboard-command-routing.spec.ts
Normal file
53
tests/smoke/keyboard-command-routing.spec.ts
Normal 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
10
tests/smoke/testBridge.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user