Compare commits

...

14 Commits

Author SHA1 Message Date
lucaronin
dbf54657f0 fix: harden untitled h1 auto-rename flow 2026-04-11 15:22:34 +02:00
lucaronin
0c365eb7dd test: cover raw editor keyboard toggle 2026-04-11 14:23:25 +02:00
lucaronin
20b789271d fix: verify view row hover state 2026-04-11 14:06:03 +02:00
lucaronin
36d3c8731b fix: hide view counts while row actions are visible 2026-04-11 13:52:35 +02:00
lucaronin
e412fa8fd7 fix: restore breadcrumb action icon size 2026-04-11 13:33:42 +02:00
lucaronin
76c37cf783 fix: narrow shortcut dispatcher route handling 2026-04-11 13:02:31 +02:00
lucaronin
4b60b9539d refactor: centralize shortcut routing metadata 2026-04-11 12:59:11 +02:00
lucaronin
69e520b5aa fix: prevent cmd+n note creation crash 2026-04-11 12:30:05 +02:00
lucaronin
272f2c0b3c design: replace app icon with Tolaria drop icon 2026-04-11 12:12:12 +02:00
lucaronin
d7bdab5b2e fix: relax test bridge command typing 2026-04-11 11:07:52 +02:00
lucaronin
5a5ea8d6f0 fix: align test bridge window typing 2026-04-11 11:04:47 +02:00
lucaronin
86306dc9de test: stabilize keyboard menu smoke bridge 2026-04-11 10:59:38 +02:00
lucaronin
974ca6148b fix: return false for unhandled app commands 2026-04-11 10:44:45 +02:00
lucaronin
b1bc056afb refactor: unify shortcut command routing 2026-04-11 10:39:08 +02:00
70 changed files with 2255 additions and 468 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.68
AVERAGE_THRESHOLD=9.33
HOTSPOT_THRESHOLD=9.7
AVERAGE_THRESHOLD=9.36

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,13 @@ 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:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and ownership
- 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,8 @@ laputa-app/
│ │ ├── useCommandRegistry.ts # Command palette registry
│ │ ├── useAppCommands.ts # App-level commands
│ │ ├── useAppKeyboard.ts # Keyboard shortcuts
│ │ ├── appCommandCatalog.ts # Shortcut ownership + command metadata
│ │ ├── appCommandDispatcher.ts # Shared shortcut/menu command IDs + dispatch
│ │ ├── useSettings.ts # App settings
│ │ ├── useOnboarding.ts # First-launch flow
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
@@ -280,10 +282,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. Shortcut ownership now lives in `appCommandCatalog.ts`: renderer shortcuts (`useAppKeyboard`) resolve commands from that catalog, native menu events (`useMenuEvents`) emit the same command IDs, and `appCommandDispatcher.ts` owns execution rather than duplicate shortcut facts.
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 +341,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, register it in `appCommandCatalog.ts` with the canonical command ID, ownership (`renderer` vs `native-menu`), and modifier rule
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

@@ -0,0 +1,31 @@
---
type: ADR
id: "0051"
title: "Shared shortcut manifest for testable routing"
status: active
date: 2026-04-11
---
## Context
ADR 0050 moved renderer shortcuts and native menu events onto the same command dispatcher, but shortcut ownership still drifted across multiple places: `appKeyboardShortcuts.ts`, `appCommandDispatcher.ts`, command-palette metadata, and `menu.rs`. That made shortcut regressions easy to reintroduce because the same facts had to be updated manually in several files.
The riskiest failures were exactly the native-owned commands that matter most in a keyboard-first app: `Cmd+\` for raw editor, `Cmd+Shift+I` for properties, and `Cmd+Shift+L` for the AI panel. We need one declarative place that says which command owns which shortcut, whether the shortcut is renderer-owned or native-menu-owned, and how tests should trigger it.
## Decision
**Shortcut-capable app commands are now defined in a shared frontend manifest that owns command IDs, routing semantics, and shortcut ownership. Renderer keyboard handling resolves commands from that manifest, native menu routing dispatches the same command IDs, and deterministic QA for native-owned shortcuts targets those IDs rather than duplicating shortcut facts in ad hoc code paths.**
## Options considered
- **Option A** (chosen): Shared shortcut manifest plus shared dispatcher and deterministic menu-command QA. This reduces drift, improves CodeScene on the command router, and makes native-owned shortcuts provable without flaky macOS key synthesis. Downside: one more manifest to maintain.
- **Option B**: Keep the shared dispatcher from ADR 0050 but continue storing shortcut ownership in separate key maps and menu lists. Lower churn, but it keeps the exact source of the regressions we reopened.
- **Option C**: Move all shortcuts into renderer-only handlers. Easier to test, but weaker macOS menu-bar parity and worse native desktop UX.
## Consequences
- `appCommandCatalog.ts` is now the frontend source of truth for shortcut-capable command IDs, ownership, modifier rules, and dispatch kind.
- `appCommandDispatcher.ts` is reduced to route execution instead of carrying a large switch plus duplicated ownership metadata.
- `useAppKeyboard.ts` resolves shortcuts from the shared manifest, including the distinction between `Cmd+Shift+L` (macOS-only) and `CmdOrCtrl+Shift+I/F/O`.
- Native-menu smoke tests should use `window.__laputaTest.triggerMenuCommand()` or the Tauri `trigger_menu_command` bridge to prove the native command path. Renderer-only commands may still be proven with direct keyboard events.
- This ADR supersedes ADR 0050 by replacing “shared command IDs are enough” with “shared command IDs plus shared shortcut ownership metadata are required.”

View File

@@ -105,3 +105,5 @@ 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 | superseded → [0051](0051-shared-shortcut-manifest-for-testable-routing.md) |
| [0051](0051-shared-shortcut-manifest-for-testable-routing.md) | Shared shortcut manifest for testable 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/h1-untitled-auto-rename.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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 115 KiB

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

@@ -280,7 +280,7 @@ function App() {
})
const appSave = useAppSave({
updateEntry: vault.updateEntry, setTabs: notes.setTabs, setToastMessage,
updateEntry: vault.updateEntry, setTabs: notes.setTabs, handleSwitchTab: notes.handleSwitchTab, setToastMessage,
loadModifiedFiles: vault.loadModifiedFiles, reloadViews: vault.reloadViews,
clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths,
tabs: notes.tabs, activeTabPath: notes.activeTabPath,

View File

@@ -49,6 +49,52 @@ interface BreadcrumbBarProps {
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
const BREADCRUMB_ICON_CLASS = 'size-[16px]'
function focusFilenameInput(
isEditing: boolean,
inputRef: React.RefObject<HTMLInputElement | null>,
) {
if (!isEditing) return
inputRef.current?.focus()
inputRef.current?.select()
}
function beginFilenameEditing(
onRenameFilename: BreadcrumbBarProps['onRenameFilename'],
filenameStem: string,
setDraftStem: (value: string) => void,
setIsEditing: (value: boolean) => void,
) {
if (!onRenameFilename) return
setDraftStem(filenameStem)
setIsEditing(true)
}
function resolveFilenameRenameTarget(draftStem: string, filenameStem: string): string | null {
const nextStem = normalizeFilenameStemInput(draftStem)
if (!nextStem || nextStem === filenameStem) return null
return nextStem
}
function handleFilenameInputKeyDown(
event: KeyboardEvent<HTMLInputElement>,
submitRename: () => void,
cancelEditing: () => void,
) {
switch (event.key) {
case 'Enter':
event.preventDefault()
submitRename()
return
case 'Escape':
event.preventDefault()
cancelEditing()
return
default:
return
}
}
function IconActionButton({
title,
@@ -74,7 +120,7 @@ function IconActionButton({
type="button"
variant="ghost"
size="icon-xs"
className={cn('text-muted-foreground', className)}
className={cn('text-muted-foreground [&_svg:not([class*=size-])]:size-4', className)}
style={style}
onClick={onClick}
disabled={disabled}
@@ -94,7 +140,7 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle
onClick={onToggleRaw}
className={cn(rawMode ? 'text-foreground' : 'hover:text-foreground')}
>
<Code size={16} />
<Code size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -106,7 +152,7 @@ function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onT
onClick={onToggleFavorite}
className={cn(favorite ? 'text-yellow-500' : 'hover:text-foreground')}
>
<Star size={16} weight={favorite ? 'fill' : 'regular'} />
<Star size={16} weight={favorite ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -125,7 +171,7 @@ function OrganizedAction({
onClick={onToggleOrganized}
className={cn(organized ? 'text-green-600' : 'hover:text-foreground')}
>
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} />
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -133,7 +179,7 @@ function OrganizedAction({
function SearchAction() {
return (
<IconActionButton title="Search in file" className="hover:text-foreground">
<MagnifyingGlass size={16} />
<MagnifyingGlass size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -147,7 +193,7 @@ function DiffAction({
if (!showDiffToggle) {
return (
<IconActionButton title="No changes" style={DISABLED_ICON_STYLE} tabIndex={-1}>
<GitBranch size={16} />
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -159,7 +205,7 @@ function DiffAction({
disabled={diffLoading}
className={cn(diffMode ? 'text-foreground' : 'hover:text-foreground')}
>
<GitBranch size={16} />
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -179,7 +225,7 @@ function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, '
onClick={onToggleAIChat}
className={cn(showAIChat ? 'text-primary' : 'hover:text-foreground')}
>
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} />
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -192,14 +238,14 @@ function ArchiveAction({
if (archived) {
return (
<IconActionButton title="Unarchive" onClick={onUnarchive} className="hover:text-foreground">
<ArrowUUpLeft size={16} />
<ArrowUUpLeft size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
return (
<IconActionButton title="Archive" onClick={onArchive} className="hover:text-foreground">
<Archive size={16} />
<Archive size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -207,7 +253,7 @@ function ArchiveAction({
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
return (
<IconActionButton title="Delete (Cmd+Delete)" onClick={onDelete} className="hover:text-destructive">
<Trash size={16} />
<Trash size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -219,7 +265,7 @@ function InspectorAction({
if (!inspectorCollapsed) return null
return (
<IconActionButton title="Properties (⌘⇧I)" onClick={onToggleInspector} className="hover:text-foreground">
<SlidersHorizontal size={16} />
<SlidersHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -358,15 +404,11 @@ function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'en
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!isEditing) return
inputRef.current?.focus()
inputRef.current?.select()
focusFilenameInput(isEditing, inputRef)
}, [isEditing])
const startEditing = useCallback(() => {
if (!onRenameFilename) return
setDraftStem(filenameStem)
setIsEditing(true)
beginFilenameEditing(onRenameFilename, filenameStem, setDraftStem, setIsEditing)
}, [onRenameFilename, filenameStem])
const cancelEditing = useCallback(() => {
@@ -375,22 +417,14 @@ function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'en
}, [filenameStem])
const submitRename = useCallback(() => {
const nextStem = normalizeFilenameStemInput(draftStem)
setIsEditing(false)
if (!nextStem || nextStem === filenameStem) return
const nextStem = resolveFilenameRenameTarget(draftStem, filenameStem)
if (!nextStem) return
onRenameFilename?.(entry.path, nextStem)
}, [draftStem, filenameStem, onRenameFilename, entry.path])
const handleInputKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
submitRename()
return
}
if (event.key === 'Escape') {
event.preventDefault()
cancelEditing()
}
handleFilenameInputKeyDown(event, submitRename, cancelEditing)
}, [submitRename, cancelEditing])
if (isEditing) {
@@ -448,14 +482,14 @@ function BreadcrumbActions({
/>
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
<PlaceholderAction title="Coming soon">
<CursorText size={16} />
<CursorText size={16} className={BREADCRUMB_ICON_CLASS} />
</PlaceholderAction>
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
<PlaceholderAction title="Coming soon">
<DotsThree size={16} />
<DotsThree size={16} className={BREADCRUMB_ICON_CLASS} />
</PlaceholderAction>
</div>
)

View File

@@ -1168,12 +1168,15 @@ describe('Sidebar', () => {
const navItem = label.closest('[class*="cursor-pointer"]') as HTMLElement
const countChip = navItem.querySelector('span:last-child') as HTMLElement
expect(countChip).toBeTruthy()
expect(viewItem.className).toContain('[&>div>span:last-child]:transition-opacity')
expect(viewItem.className).toContain('group-hover:[&>div>span:last-child]:opacity-0')
expect(viewItem.className).toContain('group-focus-within:[&>div>span:last-child]:opacity-0')
expect(countChip.className).toContain('transition-opacity')
expect(countChip.className).toContain('group-hover:opacity-0')
expect(countChip.className).toContain('group-focus-within:opacity-0')
const actionButton = within(viewItem).getByTitle('Edit view')
const actionContainer = actionButton.parentElement as HTMLElement
expect(actionContainer.className).toContain('pointer-events-none')
expect(actionContainer.className).toContain('group-hover:pointer-events-auto')
expect(actionContainer.className).toContain('group-focus-within:pointer-events-auto')
expect(actionContainer.className).toContain('group-hover:opacity-100')
expect(actionContainer.className).toContain('group-focus-within:opacity-100')
})

View File

@@ -87,16 +87,17 @@ function ViewItem({
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
return (
<div className="group relative [&>div>span:last-child]:transition-opacity group-hover:[&>div>span:last-child]:opacity-0 group-focus-within:[&>div>span:last-child]:opacity-0">
<div className="group relative">
<NavItem
icon={Funnel}
emoji={view.definition.icon}
label={view.definition.name}
count={count}
badgeClassName="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
isActive={isActive}
onClick={onSelect}
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"

View File

@@ -0,0 +1,393 @@
import type { SidebarFilter } from '../types'
import type { ViewMode } from './useViewMode'
export const APP_COMMAND_IDS = {
appSettings: 'app-settings',
appCheckForUpdates: 'app-check-for-updates',
fileNewNote: 'file-new-note',
fileNewType: 'file-new-type',
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]
export type AppCommandShortcutCombo =
| 'command-or-ctrl'
| 'command-or-ctrl-shift'
| 'command-shift'
export type AppCommandShortcutOwner = 'native-menu' | 'renderer'
type ShortcutEventLike = Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'key' | 'code'>
type SimpleHandlerKey =
| 'onOpenSettings'
| 'onCheckForUpdates'
| 'onCreateNote'
| 'onCreateType'
| 'onOpenDailyNote'
| 'onQuickOpen'
| 'onSave'
| 'onSearch'
| 'onToggleRawEditor'
| 'onToggleDiff'
| 'onToggleInspector'
| 'onToggleAIChat'
| 'onCommandPalette'
| 'onZoomIn'
| 'onZoomOut'
| 'onZoomReset'
| 'onGoBack'
| 'onGoForward'
| 'onOpenVault'
| 'onRemoveActiveVault'
| 'onRestoreGettingStarted'
| 'onCommitPush'
| 'onPull'
| 'onResolveConflicts'
| 'onViewChanges'
| 'onInstallMcp'
| 'onReloadVault'
| 'onRepairVault'
| 'onOpenInNewWindow'
| 'onRestoreDeletedNote'
type ActiveTabHandlerKey =
| 'onToggleOrganized'
| 'onToggleFavorite'
| 'onArchiveNote'
| 'onDeleteNote'
type AppCommandRoute =
| { kind: 'view-mode'; value: ViewMode }
| { kind: 'filter'; value: SidebarFilter }
| { kind: 'handler'; handler: SimpleHandlerKey }
| { kind: 'active-tab-handler'; handler: ActiveTabHandlerKey }
interface AppCommandShortcutDefinition {
combo: AppCommandShortcutCombo
key: string
aliases?: string[]
code?: string
display: string
owner: AppCommandShortcutOwner
}
export interface AppCommandDefinition {
route: AppCommandRoute
menuOwned: boolean
shortcut?: AppCommandShortcutDefinition
}
export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition> = {
[APP_COMMAND_IDS.appSettings]: {
route: { kind: 'handler', handler: 'onOpenSettings' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: ',', display: '⌘,', owner: 'native-menu' },
},
[APP_COMMAND_IDS.appCheckForUpdates]: {
route: { kind: 'handler', handler: 'onCheckForUpdates' },
menuOwned: true,
},
[APP_COMMAND_IDS.fileNewNote]: {
route: { kind: 'handler', handler: 'onCreateNote' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: 'n', code: 'KeyN', display: '⌘N', owner: 'native-menu' },
},
[APP_COMMAND_IDS.fileNewType]: {
route: { kind: 'handler', handler: 'onCreateType' },
menuOwned: true,
},
[APP_COMMAND_IDS.fileDailyNote]: {
route: { kind: 'handler', handler: 'onOpenDailyNote' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: 'j', code: 'KeyJ', display: '⌘J', owner: 'native-menu' },
},
[APP_COMMAND_IDS.fileQuickOpen]: {
route: { kind: 'handler', handler: 'onQuickOpen' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: 'p', code: 'KeyP', display: '⌘P', owner: 'native-menu' },
},
[APP_COMMAND_IDS.fileSave]: {
route: { kind: 'handler', handler: 'onSave' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: 's', code: 'KeyS', display: '⌘S', owner: 'native-menu' },
},
[APP_COMMAND_IDS.editFindInVault]: {
route: { kind: 'handler', handler: 'onSearch' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl-shift', key: 'f', code: 'KeyF', display: '⌘⇧F', owner: 'native-menu' },
},
[APP_COMMAND_IDS.editToggleRawEditor]: {
route: { kind: 'handler', handler: 'onToggleRawEditor' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: '\\', display: '⌘\\', owner: 'native-menu' },
},
[APP_COMMAND_IDS.editToggleDiff]: {
route: { kind: 'handler', handler: 'onToggleDiff' },
menuOwned: true,
},
[APP_COMMAND_IDS.viewEditorOnly]: {
route: { kind: 'view-mode', value: 'editor-only' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: '1', display: '⌘1', owner: 'native-menu' },
},
[APP_COMMAND_IDS.viewEditorList]: {
route: { kind: 'view-mode', value: 'editor-list' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: '2', display: '⌘2', owner: 'native-menu' },
},
[APP_COMMAND_IDS.viewAll]: {
route: { kind: 'view-mode', value: 'all' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: '3', display: '⌘3', owner: 'native-menu' },
},
[APP_COMMAND_IDS.viewToggleProperties]: {
route: { kind: 'handler', handler: 'onToggleInspector' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl-shift', key: 'i', code: 'KeyI', display: '⌘⇧I', owner: 'native-menu' },
},
[APP_COMMAND_IDS.viewToggleAiChat]: {
route: { kind: 'handler', handler: 'onToggleAIChat' },
menuOwned: true,
shortcut: { combo: 'command-shift', key: 'l', code: 'KeyL', display: '⌘⇧L', owner: 'native-menu' },
},
[APP_COMMAND_IDS.viewToggleBacklinks]: {
route: { kind: 'handler', handler: 'onToggleInspector' },
menuOwned: true,
},
[APP_COMMAND_IDS.viewCommandPalette]: {
route: { kind: 'handler', handler: 'onCommandPalette' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: 'k', code: 'KeyK', display: '⌘K', owner: 'native-menu' },
},
[APP_COMMAND_IDS.viewZoomIn]: {
route: { kind: 'handler', handler: 'onZoomIn' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: '=', aliases: ['+'], display: '⌘=', owner: 'native-menu' },
},
[APP_COMMAND_IDS.viewZoomOut]: {
route: { kind: 'handler', handler: 'onZoomOut' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: '-', display: '⌘-', owner: 'native-menu' },
},
[APP_COMMAND_IDS.viewZoomReset]: {
route: { kind: 'handler', handler: 'onZoomReset' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: '0', display: '⌘0', owner: 'native-menu' },
},
[APP_COMMAND_IDS.viewGoBack]: {
route: { kind: 'handler', handler: 'onGoBack' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: '[', display: '⌘[', owner: 'native-menu' },
},
[APP_COMMAND_IDS.viewGoForward]: {
route: { kind: 'handler', handler: 'onGoForward' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: ']', display: '⌘]', owner: 'native-menu' },
},
[APP_COMMAND_IDS.goAllNotes]: {
route: { kind: 'filter', value: 'all' },
menuOwned: true,
},
[APP_COMMAND_IDS.goArchived]: {
route: { kind: 'filter', value: 'archived' },
menuOwned: true,
},
[APP_COMMAND_IDS.goChanges]: {
route: { kind: 'filter', value: 'changes' },
menuOwned: true,
},
[APP_COMMAND_IDS.goInbox]: {
route: { kind: 'filter', value: 'inbox' },
menuOwned: true,
},
[APP_COMMAND_IDS.noteToggleOrganized]: {
route: { kind: 'active-tab-handler', handler: 'onToggleOrganized' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: 'e', code: 'KeyE', display: '⌘E', owner: 'native-menu' },
},
[APP_COMMAND_IDS.noteToggleFavorite]: {
route: { kind: 'active-tab-handler', handler: 'onToggleFavorite' },
menuOwned: false,
shortcut: { combo: 'command-or-ctrl', key: 'd', code: 'KeyD', display: '⌘D', owner: 'renderer' },
},
[APP_COMMAND_IDS.noteArchive]: {
route: { kind: 'active-tab-handler', handler: 'onArchiveNote' },
menuOwned: true,
},
[APP_COMMAND_IDS.noteDelete]: {
route: { kind: 'active-tab-handler', handler: 'onDeleteNote' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: 'Backspace', aliases: ['Delete'], display: '⌘⌫', owner: 'native-menu' },
},
[APP_COMMAND_IDS.noteOpenInNewWindow]: {
route: { kind: 'handler', handler: 'onOpenInNewWindow' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl-shift', key: 'o', code: 'KeyO', display: '⌘⇧O', owner: 'native-menu' },
},
[APP_COMMAND_IDS.noteRestoreDeleted]: {
route: { kind: 'handler', handler: 'onRestoreDeletedNote' },
menuOwned: true,
},
[APP_COMMAND_IDS.vaultOpen]: {
route: { kind: 'handler', handler: 'onOpenVault' },
menuOwned: true,
},
[APP_COMMAND_IDS.vaultRemove]: {
route: { kind: 'handler', handler: 'onRemoveActiveVault' },
menuOwned: true,
},
[APP_COMMAND_IDS.vaultRestoreGettingStarted]: {
route: { kind: 'handler', handler: 'onRestoreGettingStarted' },
menuOwned: true,
},
[APP_COMMAND_IDS.vaultCommitPush]: {
route: { kind: 'handler', handler: 'onCommitPush' },
menuOwned: true,
},
[APP_COMMAND_IDS.vaultPull]: {
route: { kind: 'handler', handler: 'onPull' },
menuOwned: true,
},
[APP_COMMAND_IDS.vaultResolveConflicts]: {
route: { kind: 'handler', handler: 'onResolveConflicts' },
menuOwned: true,
},
[APP_COMMAND_IDS.vaultViewChanges]: {
route: { kind: 'handler', handler: 'onViewChanges' },
menuOwned: true,
},
[APP_COMMAND_IDS.vaultInstallMcp]: {
route: { kind: 'handler', handler: 'onInstallMcp' },
menuOwned: true,
},
[APP_COMMAND_IDS.vaultReload]: {
route: { kind: 'handler', handler: 'onReloadVault' },
menuOwned: true,
},
[APP_COMMAND_IDS.vaultRepair]: {
route: { kind: 'handler', handler: 'onRepairVault' },
menuOwned: true,
},
}
const APP_COMMAND_SET = new Set<string>(Object.values(APP_COMMAND_IDS))
const NATIVE_MENU_COMMAND_SET = new Set<string>(
(Object.entries(APP_COMMAND_DEFINITIONS) as Array<[AppCommandId, AppCommandDefinition]>)
.filter(([, definition]) => definition.menuOwned)
.map(([id]) => id),
)
const shortcutKeyMaps = {
'command-or-ctrl': new Map<string, AppCommandId>(),
'command-or-ctrl-shift': new Map<string, AppCommandId>(),
'command-shift': new Map<string, AppCommandId>(),
} satisfies Record<AppCommandShortcutCombo, Map<string, AppCommandId>>
const shortcutCodeMaps = {
'command-or-ctrl': new Map<string, AppCommandId>(),
'command-or-ctrl-shift': new Map<string, AppCommandId>(),
'command-shift': new Map<string, AppCommandId>(),
} satisfies Record<AppCommandShortcutCombo, Map<string, AppCommandId>>
const COMMAND_ONLY_COMBOS: readonly AppCommandShortcutCombo[] = ['command-or-ctrl']
const COMMAND_SHIFT_COMBOS: readonly AppCommandShortcutCombo[] = ['command-shift', 'command-or-ctrl-shift']
const COMMAND_OR_CTRL_SHIFT_COMBOS: readonly AppCommandShortcutCombo[] = ['command-or-ctrl-shift']
const NO_SHORTCUT_COMBOS: readonly AppCommandShortcutCombo[] = []
function normalizeShortcutKey(key: string): string {
return key.length === 1 ? key.toLowerCase() : key
}
for (const [id, definition] of Object.entries(APP_COMMAND_DEFINITIONS) as Array<[AppCommandId, AppCommandDefinition]>) {
const shortcut = definition.shortcut
if (!shortcut) continue
shortcutKeyMaps[shortcut.combo].set(normalizeShortcutKey(shortcut.key), id)
for (const alias of shortcut.aliases ?? []) {
shortcutKeyMaps[shortcut.combo].set(normalizeShortcutKey(alias), id)
}
if (shortcut.code) {
shortcutCodeMaps[shortcut.combo].set(shortcut.code, id)
}
}
export function isAppCommandId(value: string): value is AppCommandId {
return APP_COMMAND_SET.has(value)
}
export function isNativeMenuCommandId(value: string): value is AppCommandId {
return NATIVE_MENU_COMMAND_SET.has(value)
}
export function isNativeMenuShortcutCommand(id: AppCommandId): boolean {
return APP_COMMAND_DEFINITIONS[id].shortcut?.owner === 'native-menu'
}
export function shortcutCombosForEvent({
altKey,
ctrlKey,
metaKey,
shiftKey,
}: Pick<ShortcutEventLike, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>): readonly AppCommandShortcutCombo[] {
if (altKey || (!metaKey && !ctrlKey)) return NO_SHORTCUT_COMBOS
if (shiftKey) {
return metaKey && !ctrlKey ? COMMAND_SHIFT_COMBOS : COMMAND_OR_CTRL_SHIFT_COMBOS
}
return COMMAND_ONLY_COMBOS
}
export function findShortcutCommandId(
combo: AppCommandShortcutCombo,
key: string,
code?: string,
): AppCommandId | null {
if (code) {
const codeMatch = shortcutCodeMaps[combo].get(code)
if (codeMatch) return codeMatch
}
return shortcutKeyMaps[combo].get(normalizeShortcutKey(key)) ?? null
}
export function findShortcutCommandIdForEvent(event: ShortcutEventLike): AppCommandId | null {
for (const combo of shortcutCombosForEvent(event)) {
const commandId = findShortcutCommandId(combo, event.key, event.code)
if (commandId) return commandId
}
return null
}

View File

@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from 'vitest'
import {
APP_COMMAND_IDS,
dispatchAppCommand,
findShortcutCommandId,
findShortcutCommandIdForEvent,
isAppCommandId,
isNativeMenuCommandId,
isNativeMenuShortcutCommand,
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('matches shortcut ownership from the shared catalog', () => {
expect(isNativeMenuShortcutCommand(APP_COMMAND_IDS.fileNewNote)).toBe(true)
expect(isNativeMenuShortcutCommand(APP_COMMAND_IDS.noteToggleFavorite)).toBe(false)
})
it('finds raw editor and AI shortcuts from the shared catalog', () => {
expect(findShortcutCommandId('command-or-ctrl', '\\')).toBe(APP_COMMAND_IDS.editToggleRawEditor)
expect(findShortcutCommandId('command-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat)
})
it('resolves event modifiers through the shared shortcut catalog', () => {
expect(
findShortcutCommandIdForEvent({
key: '¬',
code: 'KeyL',
altKey: false,
ctrlKey: false,
metaKey: true,
shiftKey: true,
}),
).toBe(APP_COMMAND_IDS.viewToggleAiChat)
expect(
findShortcutCommandIdForEvent({
key: 'I',
code: 'KeyI',
altKey: false,
ctrlKey: false,
metaKey: true,
shiftKey: true,
}),
).toBe(APP_COMMAND_IDS.viewToggleProperties)
expect(
findShortcutCommandIdForEvent({
key: 'l',
code: 'KeyL',
altKey: false,
ctrlKey: true,
metaKey: false,
shiftKey: true,
}),
).toBeNull()
})
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,179 @@
import type { MutableRefObject } from 'react'
import type { SidebarFilter } from '../types'
import {
APP_COMMAND_DEFINITIONS,
type AppCommandId,
type AppCommandDefinition,
} from './appCommandCatalog'
import type { ViewMode } from './useViewMode'
export const APP_COMMAND_EVENT_NAME = 'laputa:dispatch-command'
export {
APP_COMMAND_IDS,
findShortcutCommandId,
findShortcutCommandIdForEvent,
isAppCommandId,
isNativeMenuCommandId,
isNativeMenuShortcutCommand,
} from './appCommandCatalog'
export type { AppCommandDefinition, AppCommandId, AppCommandShortcutCombo } from './appCommandCatalog'
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>
}
type SimpleHandlerKey = keyof Pick<
AppCommandHandlers,
| 'onOpenSettings'
| 'onCheckForUpdates'
| 'onCreateNote'
| 'onCreateType'
| 'onOpenDailyNote'
| 'onQuickOpen'
| 'onSave'
| 'onSearch'
| 'onToggleRawEditor'
| 'onToggleDiff'
| 'onToggleInspector'
| 'onToggleAIChat'
| 'onCommandPalette'
| 'onZoomIn'
| 'onZoomOut'
| 'onZoomReset'
| 'onGoBack'
| 'onGoForward'
| 'onOpenVault'
| 'onRemoveActiveVault'
| 'onRestoreGettingStarted'
| 'onCommitPush'
| 'onPull'
| 'onResolveConflicts'
| 'onViewChanges'
| 'onInstallMcp'
| 'onReloadVault'
| 'onRepairVault'
| 'onOpenInNewWindow'
| 'onRestoreDeletedNote'
>
type ActiveTabHandlerKey = keyof Pick<
AppCommandHandlers,
'onToggleOrganized' | 'onToggleFavorite' | 'onArchiveNote' | 'onDeleteNote'
>
const SIMPLE_HANDLER_EXECUTORS: Record<SimpleHandlerKey, (handlers: AppCommandHandlers) => void> = {
onOpenSettings: (handlers) => handlers.onOpenSettings(),
onCheckForUpdates: (handlers) => handlers.onCheckForUpdates?.(),
onCreateNote: (handlers) => handlers.onCreateNote(),
onCreateType: (handlers) => handlers.onCreateType?.(),
onOpenDailyNote: (handlers) => handlers.onOpenDailyNote(),
onQuickOpen: (handlers) => handlers.onQuickOpen(),
onSave: (handlers) => handlers.onSave(),
onSearch: (handlers) => handlers.onSearch(),
onToggleRawEditor: (handlers) => handlers.onToggleRawEditor?.(),
onToggleDiff: (handlers) => handlers.onToggleDiff?.(),
onToggleInspector: (handlers) => handlers.onToggleInspector(),
onToggleAIChat: (handlers) => handlers.onToggleAIChat?.(),
onCommandPalette: (handlers) => handlers.onCommandPalette(),
onZoomIn: (handlers) => handlers.onZoomIn(),
onZoomOut: (handlers) => handlers.onZoomOut(),
onZoomReset: (handlers) => handlers.onZoomReset(),
onGoBack: (handlers) => handlers.onGoBack?.(),
onGoForward: (handlers) => handlers.onGoForward?.(),
onOpenVault: (handlers) => handlers.onOpenVault?.(),
onRemoveActiveVault: (handlers) => handlers.onRemoveActiveVault?.(),
onRestoreGettingStarted: (handlers) => handlers.onRestoreGettingStarted?.(),
onCommitPush: (handlers) => handlers.onCommitPush?.(),
onPull: (handlers) => handlers.onPull?.(),
onResolveConflicts: (handlers) => handlers.onResolveConflicts?.(),
onViewChanges: (handlers) => handlers.onViewChanges?.(),
onInstallMcp: (handlers) => handlers.onInstallMcp?.(),
onReloadVault: (handlers) => handlers.onReloadVault?.(),
onRepairVault: (handlers) => handlers.onRepairVault?.(),
onOpenInNewWindow: (handlers) => handlers.onOpenInNewWindow?.(),
onRestoreDeletedNote: (handlers) => handlers.onRestoreDeletedNote?.(),
}
const ACTIVE_TAB_HANDLER_EXECUTORS: Record<ActiveTabHandlerKey, (handlers: AppCommandHandlers, path: string) => void> = {
onToggleOrganized: (handlers, path) => handlers.onToggleOrganized?.(path),
onToggleFavorite: (handlers, path) => handlers.onToggleFavorite?.(path),
onArchiveNote: (handlers, path) => handlers.onArchiveNote(path),
onDeleteNote: (handlers, path) => handlers.onDeleteNote(path),
}
function dispatchActiveTabCommand(
pathRef: MutableRefObject<string | null>,
handler: (path: string) => void,
): boolean {
const path = pathRef.current
if (!path) return false
handler(path)
return true
}
function dispatchDefinition(
definition: AppCommandDefinition,
handlers: AppCommandHandlers,
): boolean {
switch (definition.route.kind) {
case 'view-mode':
handlers.onSetViewMode(definition.route.value)
return true
case 'filter':
handlers.onSelectFilter?.(definition.route.value)
return true
case 'handler': {
const handler = definition.route.handler
SIMPLE_HANDLER_EXECUTORS[handler as SimpleHandlerKey](handlers)
return true
}
case 'active-tab-handler': {
const handler = definition.route.handler
return dispatchActiveTabCommand(
handlers.activeTabPathRef,
(path) => ACTIVE_TAB_HANDLER_EXECUTORS[handler as ActiveTabHandlerKey](handlers, path),
)
}
}
}
export function dispatchAppCommand(id: AppCommandId, handlers: AppCommandHandlers): boolean {
return dispatchDefinition(APP_COMMAND_DEFINITIONS[id], handlers)
}

View File

@@ -1,48 +1,40 @@
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,
findShortcutCommandIdForEvent,
isNativeMenuShortcutCommand,
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>
}
type ShortcutHandler = () => void
type ShortcutMap = Record<string, ShortcutHandler>
type NativeMenuCombo = 'command' | 'command-shift'
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'
>
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-shift': new Set(['f', 'i', 'o', 'l']),
}
const VIEW_MODE_KEYS: Record<string, ViewMode> = {
'1': 'editor-only',
'2': 'editor-list',
'3': 'all',
}
function isTextInputFocused(): boolean {
const active = document.activeElement
@@ -51,119 +43,15 @@ function isTextInputFocused(): boolean {
return active.isContentEditable || active.closest('[contenteditable="true"]') !== null
}
function isCommandOrCtrlOnly(e: KeyboardEvent): boolean {
return (e.metaKey || e.ctrlKey) && e.altKey === false
}
function isCommandOrCtrlShiftOnly(e: KeyboardEvent): boolean {
return isCommandOrCtrlOnly(e) && e.shiftKey
}
function isCommandShiftOnly(e: KeyboardEvent): boolean {
return e.metaKey && e.ctrlKey === false && e.altKey === false && e.shiftKey
}
function nativeMenuComboForEvent(e: KeyboardEvent): NativeMenuCombo | null {
if (isCommandShiftOnly(e)) return 'command-shift'
if (isCommandOrCtrlOnly(e) && e.shiftKey === false) return 'command'
return null
}
function shouldDeferToNativeMenu(e: KeyboardEvent): boolean {
if (!isTauri()) return false
const combo = nativeMenuComboForEvent(e)
if (combo === null) return false
const normalizedKey = combo === 'command-shift' ? e.key.toLowerCase() : e.key
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(actions: KeyboardActions): ShortcutMap {
const { activeTabPathRef } = actions
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?.(),
}
}
export function handleViewModeKey(e: KeyboardEvent, onSetViewMode: (mode: ViewMode) => void): boolean {
if (isCommandOrCtrlOnly(e) === false || e.shiftKey) return false
const mode = VIEW_MODE_KEYS[e.key]
if (mode === undefined) return false
e.preventDefault()
onSetViewMode(mode)
return true
}
export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean {
if (isCommandOrCtrlOnly(e) === false || e.shiftKey) return false
const handler = keyMap[e.key]
if (handler === undefined) return false
if (TEXT_EDITING_KEYS.has(e.key) && isTextInputFocused()) return false
e.preventDefault()
handler()
return true
}
export function handleAiPanelKey(e: KeyboardEvent, onToggleAIChat?: () => void): boolean {
const matchesAiPanelShortcut = e.code === 'KeyL' || e.key.toLowerCase() === 'l'
if (isCommandShiftOnly(e) === false || matchesAiPanelShortcut === false || onToggleAIChat === undefined) return false
e.preventDefault()
onToggleAIChat()
return true
}
export function handleShiftCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean {
if (isCommandOrCtrlShiftOnly(e) === false) return false
const handler = keyMap[e.key.toLowerCase()]
if (handler === undefined) return false
e.preventDefault()
handler()
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 (handleViewModeKey(event, actions.onSetViewMode)) return
const cmdKeyMap = createCommandKeyMap(actions)
handleCommandKey(event, cmdKeyMap)
const commandId = findShortcutCommandIdForEvent(event)
if (commandId === null) return
if (TEXT_EDITING_KEYS.has(event.key) && isTextInputFocused()) return
if (isTauri() && isNativeMenuShortcutCommand(commandId)) return
event.preventDefault()
if (commandId === APP_COMMAND_IDS.editFindInVault) {
trackEvent('search_used')
}
dispatchAppCommand(commandId, actions)
}

View File

@@ -103,6 +103,33 @@ describe('useAppKeyboard', () => {
expect(actions.onCreateNote).not.toHaveBeenCalled()
})
it('Cmd+\\ defers to native menu in Tauri', () => {
const actions = makeActions()
actions.onToggleRawEditor = vi.fn()
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
renderHook(() => useAppKeyboard(actions))
fireKey('\\', { metaKey: true })
expect(actions.onToggleRawEditor).not.toHaveBeenCalled()
})
it('Cmd+Shift+I defers to native menu in Tauri', () => {
const actions = makeActions()
const onToggleInspector = vi.fn()
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
renderHook(() => useAppKeyboard({ ...actions, onToggleInspector }))
fireKey('i', { metaKey: true, shiftKey: true })
expect(onToggleInspector).not.toHaveBeenCalled()
})
it('Cmd+Shift+L defers to native menu in Tauri', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('l', { metaKey: true, shiftKey: true })
expect(onToggleAIChat).not.toHaveBeenCalled()
})
it('Cmd+D triggers toggle favorite on active note', () => {
const actions = makeActions()
actions.onToggleFavorite = vi.fn()

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { SetStateAction } from 'react'
import { useAppSave } from './useAppSave'
import type { VaultEntry } from '../types'
import { isTauri } from '../mock-tauri'
@@ -22,6 +23,7 @@ describe('useAppSave', () => {
const deps = {
updateEntry: vi.fn(),
setTabs: vi.fn(),
handleSwitchTab: vi.fn(),
setToastMessage: vi.fn(),
loadModifiedFiles: vi.fn(),
clearUnsaved: vi.fn(),
@@ -147,6 +149,44 @@ describe('useAppSave', () => {
)
})
it('switches the active tab to the renamed path after untitled H1 auto-rename', async () => {
vi.useFakeTimers()
vi.mocked(isTauri).mockReturnValue(true)
const oldPath = '/vault/untitled-note-123.md'
const newPath = '/vault/fresh-title.md'
const entry = makeEntry(oldPath, 'Untitled Note 123', 'untitled-note-123.md')
let tabsState = [{ entry, content: '# Fresh Title\n\nBody' }]
const setTabs = vi.fn((updater: SetStateAction<typeof tabsState>) => {
tabsState = typeof updater === 'function' ? updater(tabsState) : updater
})
vi.mocked(invoke).mockImplementation(async (command: string, args?: Record<string, unknown>) => {
if (command === 'save_note_content') return undefined
if (command === 'auto_rename_untitled') return { new_path: newPath, updated_files: 0 }
if (command === 'reload_vault_entry') return makeEntry(newPath, 'Fresh Title', 'fresh-title.md')
if (command === 'get_note_content' && args?.path === newPath) return '# Fresh Title\n\nBody'
return undefined
})
const { result } = renderSave({
setTabs,
tabs: tabsState,
activeTabPath: oldPath,
unsavedPaths: new Set([oldPath]),
})
await act(async () => {
result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody')
await vi.advanceTimersByTimeAsync(3_000)
})
expect(deps.handleSwitchTab).toHaveBeenCalledWith(newPath)
expect(tabsState[0].entry.path).toBe(newPath)
expect(tabsState[0].entry.filename).toBe('fresh-title.md')
expect(tabsState[0].content).toBe('# Fresh Title\n\nBody')
})
it('cancels a pending untitled auto-rename when the user navigates away', async () => {
vi.useFakeTimers()
vi.mocked(isTauri).mockReturnValue(true)

View File

@@ -92,6 +92,10 @@ function pendingRenameOutsideActiveTab(
async function reloadAutoRenamedNote(
oldPath: string,
newPath: string,
tabs: TabState[],
activeTabPath: string | null,
setTabs: AppSaveDeps['setTabs'],
handleSwitchTab: AppSaveDeps['handleSwitchTab'],
replaceEntry: AppSaveDeps['replaceEntry'],
loadModifiedFiles: AppSaveDeps['loadModifiedFiles'],
): Promise<void> {
@@ -99,13 +103,31 @@ async function reloadAutoRenamedNote(
invoke<VaultEntry>('reload_vault_entry', { path: newPath }),
invoke<string>('get_note_content', { path: newPath }),
])
const otherTabPaths = tabs
.filter((tab) => tab.entry.path !== oldPath && tab.entry.path !== newPath)
.map((tab) => tab.entry.path)
setTabs((prev: TabState[]) => prev.map((tab) => (
tab.entry.path === oldPath
? { entry: { ...tab.entry, ...newEntry, path: newPath }, content: newContent }
: tab
)))
if (activeTabPath === oldPath) handleSwitchTab(newPath)
replaceEntry(oldPath, { ...newEntry, path: newPath }, newContent)
await Promise.all(otherTabPaths.map(async (path) => {
const content = await invoke<string>('get_note_content', { path })
setTabs((prev: TabState[]) => prev.map((tab) => (
tab.entry.path === path ? { ...tab, content } : tab
)))
}))
loadModifiedFiles()
}
interface AppSaveDeps {
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
setTabs: Parameters<typeof useEditorSaveWithLinks>[0]['setTabs']
handleSwitchTab: (path: string) => void
setToastMessage: (msg: string | null) => void
loadModifiedFiles: () => void
reloadViews?: () => Promise<void>
@@ -119,7 +141,7 @@ interface AppSaveDeps {
}
export function useAppSave({
updateEntry, setTabs, setToastMessage,
updateEntry, setTabs, handleSwitchTab, setToastMessage,
loadModifiedFiles, reloadViews, clearUnsaved, unsavedPaths,
tabs, activeTabPath,
handleRenameNote, replaceEntry, resolvedPath,
@@ -142,12 +164,21 @@ export function useAppSave({
notePath: path,
})
if (!result) return false
await reloadAutoRenamedNote(path, result.new_path, replaceEntry, loadModifiedFiles)
await reloadAutoRenamedNote(
path,
result.new_path,
tabs,
activeTabPath,
setTabs,
handleSwitchTab,
replaceEntry,
loadModifiedFiles,
)
return true
} catch {
return false
}
}, [resolvedPath, replaceEntry, loadModifiedFiles])
}, [resolvedPath, tabs, activeTabPath, setTabs, handleSwitchTab, replaceEntry, loadModifiedFiles])
const flushPendingUntitledRename = useCallback(async (path?: string) => {
const pending = takePendingRename(pendingUntitledRenameRef, path)

View File

@@ -48,6 +48,41 @@ describe('useEditorFocus', () => {
vi.useRealTimers()
})
it('waits for the matching tab swap event when a target path is provided', () => {
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
vi.spyOn(window, 'setTimeout')
const { editor } = setup(true)
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { path: '/vault/new-note.md' } }))
expect(editor.focus).not.toHaveBeenCalled()
expect(rAF).not.toHaveBeenCalled()
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', { detail: { path: '/vault/other.md' } }))
expect(editor.focus).not.toHaveBeenCalled()
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', { detail: { path: '/vault/new-note.md' } }))
expect(rAF).toHaveBeenCalled()
expect(editor.focus).toHaveBeenCalled()
})
it('falls back to focusing when the swap event never arrives', () => {
vi.useFakeTimers()
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const { editor } = setup(true)
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { path: '/vault/new-note.md' } }))
expect(editor.focus).not.toHaveBeenCalled()
vi.advanceTimersByTime(249)
expect(editor.focus).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(rAF).toHaveBeenCalled()
expect(editor.focus).toHaveBeenCalled()
vi.useRealTimers()
})
it('cleans up event listener on unmount', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
const editor = { focus: vi.fn() } as any

View File

@@ -1,5 +1,9 @@
import { useEffect } from 'react'
const TAB_SWAP_EVENT_NAME = 'laputa:editor-tab-swapped'
const FOCUS_EVENT_NAME = 'laputa:focus-editor'
const SWAP_WAIT_FALLBACK_MS = 250
interface TiptapChain {
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
run: () => void
@@ -42,10 +46,13 @@ export function useEditorFocus(
editorMountedRef: React.RefObject<boolean>,
) {
useEffect(() => {
const pendingCleanups = new Set<() => void>()
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean } | undefined
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean; path?: string | null } | undefined
const t0 = detail?.t0
const selectTitle = detail?.selectTitle ?? false
const targetPath = detail?.path ?? null
const doFocus = () => {
editor.focus()
if (!selectTitle) {
@@ -63,13 +70,47 @@ export function useEditorFocus(
if (t0) console.debug(`[perf] createNote → focus+select: ${(performance.now() - t0).toFixed(1)}ms`)
})
}
if (editorMountedRef.current) {
requestAnimationFrame(doFocus)
} else {
const scheduleFocus = () => {
if (editorMountedRef.current) {
requestAnimationFrame(doFocus)
return
}
setTimeout(doFocus, 80)
}
if (!targetPath) {
scheduleFocus()
return
}
const handleTabSwap = (event: Event) => {
const swapPath = (event as CustomEvent).detail?.path
if (swapPath !== targetPath) return
cleanupPending()
scheduleFocus()
}
const fallbackTimer = window.setTimeout(() => {
cleanupPending()
scheduleFocus()
}, SWAP_WAIT_FALLBACK_MS)
const cleanupPending = () => {
window.clearTimeout(fallbackTimer)
window.removeEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
pendingCleanups.delete(cleanupPending)
}
pendingCleanups.add(cleanupPending)
window.addEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
}
window.addEventListener(FOCUS_EVENT_NAME, handler)
return () => {
window.removeEventListener(FOCUS_EVENT_NAME, handler)
pendingCleanups.forEach((cleanup) => cleanup())
pendingCleanups.clear()
}
window.addEventListener('laputa:focus-editor', handler)
return () => window.removeEventListener('laputa:focus-editor', handler)
}, [editor, editorMountedRef])
}

View File

@@ -167,6 +167,13 @@ function makeTab(path: string, title: string) {
}
}
function makeUntitledTab(path: string, title = 'Untitled Note 1') {
return {
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
content: '---\ntype: Note\nstatus: Active\n---\n',
}
}
function makeMockEditor(docRef: { current: unknown[] }) {
return {
document: docRef.current,
@@ -220,6 +227,69 @@ describe('useEditorTabSwap raw mode sync', () => {
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('signals when the target tab content has been applied', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const swapListener = vi.fn()
window.addEventListener('laputa:editor-tab-swapped', swapListener)
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'March 2024')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
swapListener.mockClear()
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(swapListener).toHaveBeenCalledTimes(1)
const event = swapListener.mock.calls[0][0] as CustomEvent
expect(event.detail.path).toBe('b.md')
window.removeEventListener('laputa:editor-tab-swapped', swapListener)
})
it('hard-resets the editor when the target note body is blank', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const untitledTab = makeUntitledTab('untitled.md')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
mockEditor._tiptapEditor.commands.setContent.mockClear()
mockEditor.replaceBlocks.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<p></p>')
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
})
it('re-parses from tab.content when rawMode transitions from true to false', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -22,6 +22,12 @@ interface UseEditorTabSwapOptions {
rawMode?: boolean
}
function signalEditorTabSwapped(path: string): void {
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', {
detail: { path },
}))
}
/** Strip the YAML frontmatter from raw file content, returning the body
* (including any H1 heading) that should appear in the editor. */
export function extractEditorBody(rawFileContent: string): string {
@@ -83,6 +89,14 @@ function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
]
}
function isBlankBodyContent(content: string): boolean {
return extractEditorBody(content).trim() === ''
}
function blankParagraphBlocks(): EditorBlocks {
return [{ type: 'paragraph', content: [], children: [] }]
}
async function parseMarkdownBlocks(
editor: ReturnType<typeof useCreateBlockNote>,
preprocessed: string,
@@ -153,6 +167,26 @@ function applyBlocksToEditor(
})
}
function applyBlankStateToEditor(
editor: ReturnType<typeof useCreateBlockNote>,
suppressChangeRef: MutableRefObject<boolean>,
) {
suppressChangeRef.current = true
try {
editor._tiptapEditor.commands.setContent('<p></p>')
} catch (err) {
console.error('applyBlankStateToEditor failed, falling back to replaceBlocks:', err)
applyBlocksToEditor(editor, blankParagraphBlocks(), 0, suppressChangeRef)
return
}
queueMicrotask(() => { suppressChangeRef.current = false })
requestAnimationFrame(() => {
const scrollEl = document.querySelector('.editor__blocknote-container')
if (scrollEl) scrollEl.scrollTop = 0
})
}
function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined {
return activeTabPath
? tabs.find(tab => tab.entry.path === activeTabPath)
@@ -319,10 +353,19 @@ function scheduleTabSwap(options: {
const doSwap = () => {
if (prevActivePathRef.current !== targetPath) return
rawSwapPendingRef.current = false
if (isBlankBodyContent(activeTab.content)) {
cache.set(targetPath, { blocks: blankParagraphBlocks(), scrollTop: 0 })
applyBlankStateToEditor(editor, suppressChangeRef)
requestAnimationFrame(() => signalEditorTabSwapped(targetPath))
return
}
void resolveBlocksForTarget(editor, cache, targetPath, activeTab.content)
.then(({ blocks, scrollTop }) => {
if (prevActivePathRef.current !== targetPath) return
applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef)
requestAnimationFrame(() => signalEditorTabSwapped(targetPath))
})
.catch((err: unknown) => {
console.error('Failed to parse/swap editor content:', err)

View File

@@ -1,5 +1,22 @@
import { describe, it, expect, vi } from 'vitest'
import { dispatchMenuEvent, type MenuEventHandlers } from './useMenuEvents'
import { renderHook } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useMenuEvents, dispatchMenuEvent, type MenuEventHandlers } from './useMenuEvents'
const isTauriMock = vi.fn(() => false)
const listenMock = vi.fn()
const invokeMock = vi.fn().mockResolvedValue(undefined)
vi.mock('../mock-tauri', () => ({
isTauri: () => isTauriMock(),
}))
vi.mock('@tauri-apps/api/event', () => ({
listen: (...args: unknown[]) => listenMock(...args),
}))
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
}))
function makeHandlers(): MenuEventHandlers {
return {
@@ -43,6 +60,36 @@ function makeHandlers(): MenuEventHandlers {
}
}
describe('useMenuEvents', () => {
beforeEach(() => {
vi.clearAllMocks()
isTauriMock.mockReturnValue(false)
})
it('cleans up a native menu listener even if unmounted before listen resolves', async () => {
isTauriMock.mockReturnValue(true)
let resolveListen: ((teardown: () => void) => void) | null = null
const teardown = vi.fn()
listenMock.mockImplementationOnce(() => new Promise((resolve) => {
resolveListen = resolve
}))
const { unmount } = renderHook(() => useMenuEvents(makeHandlers()))
await vi.dynamicImportSettled()
expect(listenMock).toHaveBeenCalledTimes(1)
unmount()
resolveListen?.(teardown)
await vi.dynamicImportSettled()
expect(teardown).toHaveBeenCalledTimes(1)
})
})
describe('dispatchMenuEvent', () => {
// View mode events
it('view-editor-only sets editor-only mode', () => {

View File

@@ -1,174 +1,156 @@
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,
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>
declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand?: (id: string) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
}
}
}
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',
import('@tauri-apps/api/core')
.then(({ invoke }) => invoke('update_menu_state', { state }))
.catch(() => {})
}
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 useNativeMenuEventListener(handlersRef: { current: MenuEventHandlers }) {
useEffect(() => {
if (!isTauri()) return
let disposed = false
let unlisten: (() => void) | null = null
import('@tauri-apps/api/event')
.then(async ({ listen }) => {
const teardown = await listen<string>('menu-event', (event) => {
dispatchMenuEvent(event.payload, handlersRef.current)
})
if (disposed) {
teardown()
return
}
unlisten = teardown
})
.catch(() => {
/* not in Tauri */
})
return () => {
disposed = true
unlisten?.()
}
}, [handlersRef])
}
function dispatchOptionalEvent(id: string, h: MenuEventHandlers): boolean {
const handler = OPTIONAL_EVENT_MAP[id]
if (handler) { h[handler]?.(); return true }
return false
function useWindowAppCommandListener(handlersRef: { current: MenuEventHandlers }) {
useEffect(() => {
const handleCommandEvent = createWindowCommandListener((detail) => {
if (isAppCommandId(detail)) {
dispatchAppCommand(detail, handlersRef.current)
}
})
window.addEventListener(APP_COMMAND_EVENT_NAME, handleCommandEvent)
return () => window.removeEventListener(APP_COMMAND_EVENT_NAME, handleCommandEvent)
}, [handlersRef])
}
function dispatchFilterEvent(id: string, h: MenuEventHandlers): boolean {
const filter = FILTER_MAP[id]
if (filter) { h.onSelectFilter?.(filter); return true }
return false
function useTestMenuCommandBridge(handlersRef: { current: MenuEventHandlers }) {
useEffect(() => {
const bridge = (id: string) => {
dispatchMenuEvent(id, handlersRef.current)
}
window.__laputaTest = {
...window.__laputaTest,
dispatchBrowserMenuCommand: bridge,
}
return () => {
if (window.__laputaTest?.dispatchBrowserMenuCommand === bridge) {
delete window.__laputaTest.dispatchBrowserMenuCommand
}
}
}, [handlersRef])
}
function useNativeMenuStateSync(state: MenuStatePayload) {
useEffect(() => {
syncNativeMenuState(state)
}, [state])
}
/** 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(() => {
if (!isTauri()) return
ref.current = handlers
}, [handlers])
let cleanup: (() => void) | undefined
import('@tauri-apps/api/event').then(({ listen }) => {
const unlisten = listen<string>('menu-event', (event) => {
dispatchMenuEvent(event.payload, ref.current)
})
cleanup = () => { unlisten.then(fn => fn()) }
}).catch(() => { /* not in Tauri */ })
return () => cleanup?.()
}, [])
// 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])
useNativeMenuEventListener(ref)
useWindowAppCommandListener(ref)
useTestMenuCommandBridge(ref)
useNativeMenuStateSync({
hasActiveNote,
hasModifiedFiles,
hasConflicts,
hasRestorableDeletedNote,
})
}

View File

@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { todayDateString } from './useNoteCreation'
import { RAPID_CREATE_NOTE_SETTLE_MS, todayDateString } from './useNoteCreation'
import { useNoteActions } from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
@@ -67,6 +67,7 @@ describe('useNoteActions hook', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(isTauri).mockReturnValue(false)
vi.useRealTimers()
})
it('handleCreateNote calls addEntry and creates correct entry', () => {
@@ -193,6 +194,7 @@ describe('useNoteActions hook', () => {
})
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
vi.useFakeTimers()
let ts = 1700000000000
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
const { result } = renderHook(() => useNoteActions(makeConfig()))
@@ -202,6 +204,7 @@ describe('useNoteActions hook', () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
expect(addEntry).toHaveBeenCalledTimes(3)
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
@@ -477,14 +480,15 @@ describe('useNoteActions hook', () => {
})
it('handleCreateNoteImmediate does not call invoke (no disk write)', async () => {
vi.useFakeTimers()
const { result } = renderHook(() => useNoteActions(makeConfig()))
await act(async () => {
act(() => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await new Promise((r) => setTimeout(r, 0))
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
expect(addEntry).toHaveBeenCalledTimes(3)
// No disk writes for immediate creation — notes are unsaved/in-memory

View File

@@ -17,6 +17,7 @@ import {
buildDailyNoteContent,
resolveDailyNote,
findDailyNote,
RAPID_CREATE_NOTE_SETTLE_MS,
useNoteCreation,
} from './useNoteCreation'
import type { NoteCreationConfig } from './useNoteCreation'
@@ -226,6 +227,7 @@ describe('useNoteCreation hook', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(isTauri).mockReturnValue(false)
vi.useRealTimers()
})
it('handleCreateNote creates entry and opens tab', () => {
@@ -249,6 +251,7 @@ describe('useNoteCreation hook', () => {
})
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
vi.useFakeTimers()
let ts = 1700000000000
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
@@ -257,6 +260,7 @@ describe('useNoteCreation hook', () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
// Each call consumes Date.now() multiple times (filename + buildNewEntry), so just verify uniqueness
expect(new Set(filenames).size).toBe(3)
@@ -267,6 +271,7 @@ describe('useNoteCreation hook', () => {
})
it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', () => {
vi.useFakeTimers()
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
@@ -274,6 +279,7 @@ describe('useNoteCreation hook', () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
const filenames = addEntry.mock.calls.map(([entry]: [VaultEntry]) => entry.filename)
expect(filenames).toEqual([
@@ -284,6 +290,28 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('serializes rapid immediate-create bursts after the first note', () => {
vi.useFakeTimers()
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
})
expect(addEntry).toHaveBeenCalledTimes(1)
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
expect(addEntry).toHaveBeenCalledTimes(2)
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
expect(addEntry).toHaveBeenCalledTimes(3)
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate('Project') })
@@ -314,6 +342,20 @@ describe('useNoteCreation hook', () => {
expect(markContentPending).toHaveBeenCalled()
})
it('handleCreateNoteImmediate requests editor focus for the new path', () => {
const focusListener = vi.fn()
window.addEventListener('laputa:focus-editor', focusListener)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate() })
expect(focusListener).toHaveBeenCalledTimes(1)
const event = focusListener.mock.calls[0][0] as CustomEvent
expect(event.detail.path).toMatch(/\/test\/vault\/untitled-note-\d+\.md$/)
window.removeEventListener('laputa:focus-editor', focusListener)
})
it('handleOpenDailyNote creates new daily note when none exists', () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleOpenDailyNote() })
@@ -330,6 +372,21 @@ describe('useNoteCreation hook', () => {
expect(handleSelectNote).toHaveBeenCalledWith(existing)
})
it('handleOpenDailyNote requests focus for the daily note path', () => {
const focusListener = vi.fn()
window.addEventListener('laputa:focus-editor', focusListener)
const today = todayDateString()
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleOpenDailyNote() })
expect(focusListener).toHaveBeenCalledTimes(1)
const event = focusListener.mock.calls[0][0] as CustomEvent
expect(event.detail.path).toBe(`/test/vault/${today}.md`)
window.removeEventListener('laputa:focus-editor', focusListener)
})
it('handleCreateType creates type entry', () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateType('Recipe') })

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, addMockEntry } from '../mock-tauri'
import type { VaultEntry } from '../types'
@@ -150,15 +150,20 @@ export function persistNewNote(path: string, content: string): Promise<void> {
return invoke<void>('save_note_content', { path, content }).then(() => {})
}
// Rapid Cmd+N bursts can outpace the note-list render path on desktop. Keep
// the first create immediate, then serialize the rest so each new note settles
// before the next one is opened.
export const RAPID_CREATE_NOTE_SETTLE_MS = 200
function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry) => void) {
if (!isTauri()) addMockEntry(entry, content)
addEntry(entry)
}
/** Dispatch focus-editor event with perf timing marker. */
function signalFocusEditor(opts?: { selectTitle?: boolean }): void {
function signalFocusEditor(opts?: { selectTitle?: boolean; path?: string }): void {
window.dispatchEvent(new CustomEvent('laputa:focus-editor', {
detail: { t0: performance.now(), selectTitle: opts?.selectTitle ?? false },
detail: { t0: performance.now(), selectTitle: opts?.selectTitle ?? false, path: opts?.path ?? null },
}))
}
@@ -195,9 +200,10 @@ function createAndPersist(
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn, vaultPath: string): void {
const date = todayDateString()
const existing = findDailyNote(entries, date)
const targetPath = existing?.path ?? `${vaultPath}/${date}.md`
if (existing) selectNote(existing)
else persist(resolveDailyNote(date, vaultPath))
signalFocusEditor()
signalFocusEditor({ path: targetPath })
}
interface ImmediateCreateDeps {
@@ -210,6 +216,10 @@ interface ImmediateCreateDeps {
markContentPending?: (path: string, content: string) => void
}
interface ImmediateCreateRequest {
type?: string
}
/** Generate a unique untitled filename using a timestamp. */
function generateUntitledFilename(entries: VaultEntry[], type: string, pendingSlugs?: Set<string>): string {
const ts = Math.floor(Date.now() / 1000)
@@ -241,7 +251,7 @@ function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
addEntryWithMock(entry, content, deps.addEntry)
deps.trackUnsaved?.(entry.path)
deps.markContentPending?.(entry.path, content)
signalFocusEditor()
signalFocusEditor({ path: entry.path })
}
interface RelationshipCreateDeps {
@@ -298,6 +308,26 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
}, [removeEntry, setToastMessage])
const pendingSlugsRef = useRef<Set<string>>(new Set())
const queuedImmediateCreatesRef = useRef<ImmediateCreateRequest[]>([])
const immediateCreateLockedRef = useRef(false)
const immediateCreateTimerRef = useRef<number | null>(null)
const latestImmediateCreateDepsRef = useRef<ImmediateCreateDeps | null>(null)
const syncImmediateCreateDeps = useCallback(() => {
latestImmediateCreateDepsRef.current = {
entries,
vaultPath: config.vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent,
addEntry,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
}
}, [entries, config.vaultPath, openTabWithContent, addEntry, config.trackUnsaved, config.markContentPending])
useEffect(() => {
syncImmediateCreateDeps()
}, [syncImmediateCreateDeps])
const persistNew: PersistFn = useCallback(
(resolved) => createAndPersist(resolved, addEntry, openTabWithContent, {
@@ -315,13 +345,47 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
trackEvent('note_created', { has_type: type !== 'Note' ? 1 : 0, creation_path: 'plus_button' })
}, [entries, persistNew, config.vaultPath])
const executeImmediateCreateRequest = useCallback((request: ImmediateCreateRequest) => {
const deps = latestImmediateCreateDepsRef.current
if (!deps) return
createNoteImmediate(deps, request.type)
trackEvent('note_created', { has_type: request.type ? 1 : 0, creation_path: request.type ? 'type_section' : 'cmd_n' })
}, [])
const continueImmediateCreateBurst = useCallback(function scheduleImmediateCreateBurst() {
if (immediateCreateTimerRef.current !== null) return
immediateCreateTimerRef.current = window.setTimeout(() => {
immediateCreateTimerRef.current = null
const next = queuedImmediateCreatesRef.current.shift()
if (!next) {
immediateCreateLockedRef.current = false
return
}
executeImmediateCreateRequest(next)
scheduleImmediateCreateBurst()
}, RAPID_CREATE_NOTE_SETTLE_MS)
}, [executeImmediateCreateRequest])
const handleCreateNoteImmediate = useCallback((type?: string) => {
createNoteImmediate({
entries, vaultPath: config.vaultPath, pendingSlugs: pendingSlugsRef.current,
openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending,
}, type)
trackEvent('note_created', { has_type: type ? 1 : 0, creation_path: type ? 'type_section' : 'cmd_n' })
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending])
syncImmediateCreateDeps()
const request = { type }
if (immediateCreateLockedRef.current) {
queuedImmediateCreatesRef.current.push(request)
return
}
immediateCreateLockedRef.current = true
executeImmediateCreateRequest(request)
continueImmediateCreateBurst()
}, [syncImmediateCreateDeps, executeImmediateCreateRequest, continueImmediateCreateBurst])
useEffect(() => () => {
if (immediateCreateTimerRef.current !== null) {
window.clearTimeout(immediateCreateTimerRef.current)
}
}, [])
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
createNoteForRelationship({

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, startTransition } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult, ViewFile } from '../types'
@@ -125,16 +125,12 @@ export function useVaultLoader(vaultPath: string) {
useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load
// PERF: startTransition defers the expensive entries update (filter/sort on
// 9000+ entries) so the high-priority tab render completes in <50ms first.
const addEntry = useCallback((entry: VaultEntry) => {
startTransition(() => {
setEntries((prev) => {
if (prev.some(e => e.path === entry.path)) return prev
return [entry, ...prev]
})
tracker.trackNew(entry.path)
setEntries((prev) => {
if (prev.some(e => e.path === entry.path)) return prev
return [entry, ...prev]
})
tracker.trackNew(entry.path)
}, [tracker])
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {

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,
isAppCommandId,
isNativeMenuCommandId,
} from './hooks/appCommandDispatcher'
declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand?: (id: string) => void
dispatchBrowserMenuCommand?: (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,32 @@ 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 })
}
if (!window.__laputaTest?.dispatchBrowserMenuCommand) {
throw new Error('Laputa test bridge is missing dispatchBrowserMenuCommand')
}
window.__laputaTest.dispatchBrowserMenuCommand(id)
return undefined
},
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TooltipProvider>

View File

@@ -4,6 +4,7 @@ import os from 'os'
import path from 'path'
const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault')
const FIXTURE_VAULT_READY_TIMEOUT = 30_000
function copyDirSync(src: string, dest: string): void {
fs.mkdirSync(dest, { recursive: true })
@@ -54,29 +55,209 @@ export async function openFixtureVault(
return nativeFetch(input, init)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ref: any = null
const applyFixtureVaultOverrides = (
handlers: Record<string, ((args?: unknown) => unknown)> | null | undefined,
) => {
if (!handlers) return handlers
handlers.load_vault_list = () => ({
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
hidden_defaults: [],
})
handlers.check_vault_exists = () => true
handlers.get_last_vault_path = () => resolvedVaultPath
handlers.get_default_vault_path = () => resolvedVaultPath
handlers.save_vault_list = () => null
return handlers
}
let ref = applyFixtureVaultOverrides(
(window.__mockHandlers as Record<string, ((args?: unknown) => unknown)> | undefined),
) ?? null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value
ref.load_vault_list = () => ({
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
hidden_defaults: [],
})
ref.check_vault_exists = () => true
ref.get_last_vault_path = () => resolvedVaultPath
ref.get_default_vault_path = () => resolvedVaultPath
ref.save_vault_list = () => null
ref = applyFixtureVaultOverrides(
value as Record<string, ((args?: unknown) => unknown)> | undefined,
) ?? null
},
get() {
return ref
return applyFixtureVaultOverrides(ref) ?? ref
},
})
}, vaultPath)
await page.goto('/')
await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: 15_000 })
await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({ timeout: 15_000 })
await page.goto('/', { waitUntil: 'domcontentloaded' })
await page.waitForFunction(() => Boolean(window.__mockHandlers))
await page.evaluate((resolvedVaultPath: string) => {
const handlers = window.__mockHandlers
if (!handlers) {
throw new Error('Mock handlers unavailable for fixture vault override')
}
handlers.load_vault_list = () => ({
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
hidden_defaults: [],
})
handlers.check_vault_exists = () => true
handlers.get_last_vault_path = () => resolvedVaultPath
handlers.get_default_vault_path = () => resolvedVaultPath
handlers.save_vault_list = () => null
}, vaultPath)
await page.reload({ waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: FIXTURE_VAULT_READY_TIMEOUT })
await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({
timeout: FIXTURE_VAULT_READY_TIMEOUT,
})
}
export async function openFixtureVaultTauri(
page: Page,
vaultPath: string,
): Promise<void> {
await openFixtureVault(page, vaultPath)
await page.evaluate((resolvedVaultPath: string) => {
const jsonHeaders = { 'Content-Type': 'application/json' }
const nativeFetch = window.fetch.bind(window)
const readJson = async (url: string, init?: RequestInit) => {
const response = await nativeFetch(url, init)
if (!response.ok) {
let message = `HTTP ${response.status}`
try {
const body = await response.json() as { error?: string }
message = body.error ?? message
} catch {
// Keep the HTTP status fallback when the body is not JSON.
}
throw new Error(message)
}
return response.json()
}
const invoke = async (command: string, args?: Record<string, unknown>) => {
switch (command) {
case 'trigger_menu_command': {
const commandId = String(args?.id ?? '')
const bridge = window.__laputaTest?.dispatchBrowserMenuCommand
if (!bridge) throw new Error('Laputa test bridge is missing dispatchBrowserMenuCommand')
bridge(commandId)
return null
}
case 'load_vault_list':
return {
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
hidden_defaults: [],
}
case 'check_vault_exists':
case 'is_git_repo':
return true
case 'get_last_vault_path':
case 'get_default_vault_path':
return resolvedVaultPath
case 'save_vault_list':
case 'save_settings':
case 'register_mcp_tools':
case 'reinit_telemetry':
case 'update_menu_state':
return null
case 'get_settings':
return {
github_token: null,
github_username: null,
auto_pull_interval_minutes: 5,
telemetry_consent: false,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
case 'list_vault':
case 'reload_vault': {
const path = String(args?.path ?? resolvedVaultPath)
return readJson(`/api/vault/list?path=${encodeURIComponent(path)}&reload=${command === 'reload_vault' ? '1' : '0'}`)
}
case 'list_vault_folders':
case 'list_views':
case 'get_modified_files':
case 'detect_renames':
return []
case 'reload_vault_entry':
return readJson(`/api/vault/entry?path=${encodeURIComponent(String(args?.path ?? ''))}`)
case 'get_note_content': {
const data = await readJson(`/api/vault/content?path=${encodeURIComponent(String(args?.path ?? ''))}`) as { content: string }
return data.content
}
case 'get_all_content':
return readJson(`/api/vault/all-content?path=${encodeURIComponent(String(args?.path ?? resolvedVaultPath))}`)
case 'save_note_content':
return readJson('/api/vault/save', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ path: args?.path, content: args?.content }),
})
case 'rename_note':
return readJson('/api/vault/rename', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({
vault_path: args?.vaultPath ?? resolvedVaultPath,
old_path: args?.oldPath,
new_title: args?.newTitle,
old_title: args?.oldTitle ?? null,
}),
})
case 'rename_note_filename':
return readJson('/api/vault/rename-filename', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({
vault_path: args?.vaultPath ?? resolvedVaultPath,
old_path: args?.oldPath,
new_filename_stem: args?.newFilenameStem,
}),
})
case 'search_vault': {
const path = String(args?.path ?? args?.vaultPath ?? resolvedVaultPath)
const query = encodeURIComponent(String(args?.query ?? ''))
const mode = encodeURIComponent(String(args?.mode ?? 'all'))
return readJson(`/api/vault/search?vault_path=${encodeURIComponent(path)}&query=${query}&mode=${mode}`)
}
case 'auto_rename_untitled': {
const notePath = String(args?.notePath ?? '')
const contentData = await readJson(`/api/vault/content?path=${encodeURIComponent(notePath)}`) as { content: string }
const match = contentData.content.match(/^#\s+(.+)$/m)
if (!match) return null
return readJson('/api/vault/rename', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({
vault_path: args?.vaultPath ?? resolvedVaultPath,
old_path: notePath,
new_title: match[1].trim(),
}),
})
}
default: {
const handler = window.__mockHandlers?.[command]
if (!handler) throw new Error(`Unhandled invoke: ${command}`)
return handler(args)
}
}
}
Object.defineProperty(window, '__TAURI__', {
configurable: true,
value: {},
})
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: { invoke },
})
}, vaultPath)
await page.waitForFunction(() => Boolean(window.__TAURI_INTERNALS__))
}

View File

@@ -0,0 +1,47 @@
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
let tempVaultDir: string
async function expectIconSize(pageTitle: string, page: Page) {
const icon = page.locator(`button[title="${pageTitle}"] svg`)
await expect(icon).toBeVisible({ timeout: 5_000 })
const box = await icon.boundingBox()
expect(box?.width).toBeGreaterThanOrEqual(15)
expect(box?.width).toBeLessThanOrEqual(17)
expect(box?.height).toBeGreaterThanOrEqual(15)
expect(box?.height).toBeLessThanOrEqual(17)
}
async function selectAlphaProject(page: Page) {
await expect(async () => {
const note = page
.locator('[data-testid="note-list-container"]')
.getByText('Alpha Project', { exact: true })
.first()
await expect(note).toBeVisible({ timeout: 5_000 })
await note.click({ timeout: 5_000 })
}).toPass({ timeout: 10_000 })
}
test.describe('Breadcrumb action icon size regression', () => {
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('breadcrumb action icons render at the pre-regression 16px size', async ({ page }) => {
await openFixtureVault(page, tempVaultDir)
await selectAlphaProject(page)
await expect(page.locator('.breadcrumb-bar')).toBeVisible({ timeout: 5_000 })
await expectIconSize('Search in file', page)
await expectIconSize('Raw editor', page)
await expectIconSize('Open AI Chat', page)
await expectIconSize('Archive', page)
})
})

View File

@@ -0,0 +1,110 @@
import fs from 'fs'
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { triggerMenuCommand } from './testBridge'
const KNOWN_EDITOR_ERRORS = ['isConnected']
function isKnownEditorError(message: string): boolean {
return KNOWN_EDITOR_ERRORS.some((known) => message.includes(known))
}
function markdownFiles(vaultPath: string): string[] {
return fs.readdirSync(vaultPath).filter((name) => name.endsWith('.md')).sort()
}
function slugifyTitle(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
async function createUntitledNote(page: Page): Promise<void> {
await triggerMenuCommand(page, 'file-new-note')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+(?:-\d+)?/i, {
timeout: 5_000,
})
await expect.poll(async () => page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
}), {
timeout: 5_000,
}).toBe(true)
}
async function writeNewHeading(page: Page, title: string): Promise<void> {
await page.keyboard.type(`# ${title}`)
await page.keyboard.press('Enter')
}
async function expectRenamedFile(vaultPath: string, filename: string): Promise<void> {
await expect(async () => {
expect(markdownFiles(vaultPath)).toContain(filename)
}).toPass({ timeout: 10_000 })
}
async function expectFileContentContains(vaultPath: string, filename: string, text: string): Promise<void> {
await expect(async () => {
const content = fs.readFileSync(`${vaultPath}/${filename}`, 'utf-8')
expect(content).toContain(text)
}).toPass({ timeout: 10_000 })
}
async function expectActiveFilename(page: Page, filenameStem: string): Promise<void> {
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(filenameStem, { timeout: 10_000 })
}
async function expectEditorFocused(page: Page): Promise<void> {
await expect.poll(async () => page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
}), {
timeout: 5_000,
}).toBe(true)
}
let tempVaultDir: string
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultTauri(page, tempVaultDir)
})
test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('@smoke new-note H1 auto-rename keeps the editor usable and leaves no untitled duplicates', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => {
if (!isKnownEditorError(err.message)) errors.push(err.message)
})
const titles = [
'Fresh Focus Title',
'Rapid Rename 2',
'Rapid Rename 3',
'Rapid Rename 4',
'Rapid Rename 5',
]
for (const [index, title] of titles.entries()) {
await createUntitledNote(page)
await expectEditorFocused(page)
await writeNewHeading(page, title)
await expectActiveFilename(page, slugifyTitle(title))
await expectRenamedFile(tempVaultDir, `${slugifyTitle(title)}.md`)
await expectEditorFocused(page)
if (index === 0) {
await page.keyboard.type(' focus-probe')
await expectFileContentContains(tempVaultDir, 'fresh-focus-title.md', 'focus-probe')
}
}
const files = markdownFiles(tempVaultDir)
expect(files).toContain('fresh-focus-title.md')
expect(files.filter((name) => name.startsWith('untitled-note-'))).toEqual([])
expect(files.filter((name) => /^rapid-rename-\d+\.md$/.test(name))).toHaveLength(4)
expect(errors).toEqual([])
})

View File

@@ -0,0 +1,80 @@
import { test, expect } from '@playwright/test'
import { triggerMenuCommand } from './testBridge'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
let tempVaultDir: string
function untitledNoteListMatcher(typeLabel: string) {
return new RegExp(`Untitled ${typeLabel}(?: \\d+)?`, 'i')
}
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(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i, { timeout: 5_000 })
await expect(
page.locator('[data-testid="note-list-container"]').getByText(untitledNoteListMatcher('note')).first(),
).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 raw editor through the shared command path', async ({ page }) => {
await openFixtureVault(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 })
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
})
test('Meta+Backslash toggles the raw editor through the keyboard path', async ({ page }) => {
await openFixtureVault(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await page.keyboard.press('Meta+Backslash')
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await page.keyboard.press('Meta+Backslash')
await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 })
await expect(page.locator('.bn-editor')).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 })
})
})

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

@@ -0,0 +1,34 @@
import { type Page } from '@playwright/test'
export async function triggerMenuCommand(page: Page, id: string): Promise<void> {
await page.evaluate(async (commandId) => {
const deadline = Date.now() + 5_000
while (Date.now() < deadline) {
const bridge = window.__laputaTest
const dispatchBrowserMenuCommand = bridge?.dispatchBrowserMenuCommand
const triggerMenuCommand = bridge?.triggerMenuCommand
if (typeof dispatchBrowserMenuCommand === 'function') {
if (typeof triggerMenuCommand === 'function') {
try {
await triggerMenuCommand(commandId)
return
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes('dispatchBrowserMenuCommand')) {
throw error
}
}
}
dispatchBrowserMenuCommand(commandId)
return
}
await new Promise((resolve) => window.setTimeout(resolve, 50))
}
throw new Error('Laputa test bridge is missing dispatchBrowserMenuCommand')
}, id)
}

View File

@@ -0,0 +1,102 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
type MockHandlers = Record<string, ((args?: unknown) => unknown)> | null | undefined
async function installPersistentViewMocks(page: Page) {
await page.addInitScript(() => {
type ViewRecord = {
filename: string
definition: unknown
}
const clone = <T,>(value: T): T => structuredClone(value)
const views: ViewRecord[] = []
const apply = (handlers: MockHandlers) => {
if (!handlers) return handlers
handlers.list_views = () => views.map((view) => ({
filename: view.filename,
definition: clone(view.definition),
}))
handlers.save_view_cmd = (args?: unknown) => {
const next = clone(args as { filename: string; definition: unknown })
const index = views.findIndex((view) => view.filename === next.filename)
if (index >= 0) {
views[index] = next
} else {
views.push(next)
}
return null
}
handlers.delete_view_cmd = (args?: unknown) => {
const { filename } = args as { filename: string }
const index = views.findIndex((view) => view.filename === filename)
if (index >= 0) views.splice(index, 1)
return null
}
return handlers
}
let ref = apply(window.__mockHandlers as MockHandlers) ?? null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
get() {
return apply(ref) ?? ref
},
set(value) {
ref = apply(value as MockHandlers) ?? null
},
})
})
}
async function openCreateViewDialog(page: Page) {
const viewsHeader = page.locator('button:has(span:text("VIEWS"))')
await viewsHeader.waitFor({ timeout: 10_000 })
await viewsHeader.locator('svg').last().click({ force: true })
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 })
}
async function opacityOf(locator: Locator): Promise<number> {
return locator.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity))
}
test('view row hover swaps the note count with edit/delete actions', async ({ page }) => {
await installPersistentViewMocks(page)
await page.goto('/')
await page.waitForLoadState('networkidle')
await openCreateViewDialog(page)
await page.getByPlaceholder('e.g. Active Projects, Reading List...').fill('Project View')
await page.getByTestId('filter-value-input').fill('Project')
await page.getByRole('button', { name: 'Create' }).click()
const viewRow = page.locator('div.group').filter({
has: page.getByText('Project View', { exact: true }),
}).first()
await expect(viewRow).toBeVisible({ timeout: 10_000 })
const countChip = viewRow.locator('span').last()
const actionStrip = viewRow.locator('div.absolute.right-2.top-1\\/2').first()
await expect(countChip).toHaveText(/\d+/)
await expect.poll(() => opacityOf(countChip)).toBeGreaterThan(0.9)
await expect.poll(() => opacityOf(actionStrip)).toBeLessThan(0.1)
await viewRow.hover()
await expect.poll(() => opacityOf(countChip)).toBeLessThan(0.1)
await expect.poll(() => opacityOf(actionStrip)).toBeGreaterThan(0.9)
await expect(viewRow.getByTitle('Edit view')).toBeVisible()
await expect(viewRow.getByTitle('Delete view')).toBeVisible()
await page.getByTestId('sidebar-top-nav').hover()
await expect.poll(() => opacityOf(countChip)).toBeGreaterThan(0.9)
await expect.poll(() => opacityOf(actionStrip)).toBeLessThan(0.1)
})