Compare commits
26 Commits
v0.2026041
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbf54657f0 | ||
|
|
0c365eb7dd | ||
|
|
20b789271d | ||
|
|
36d3c8731b | ||
|
|
e412fa8fd7 | ||
|
|
76c37cf783 | ||
|
|
4b60b9539d | ||
|
|
69e520b5aa | ||
|
|
272f2c0b3c | ||
|
|
d7bdab5b2e | ||
|
|
5a5ea8d6f0 | ||
|
|
86306dc9de | ||
|
|
974ca6148b | ||
|
|
b1bc056afb | ||
|
|
c24f60c594 | ||
|
|
71a3be577d | ||
|
|
717fa9d1a6 | ||
|
|
78e76e0d54 | ||
|
|
b01c6b4a4b | ||
|
|
db4359981f | ||
|
|
98d19e4c41 | ||
|
|
5ebffc447b | ||
|
|
a44dd41f95 | ||
|
|
eb67b98d96 | ||
|
|
968c4d05a9 | ||
|
|
c8db92c92d |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.68
|
||||
AVERAGE_THRESHOLD=9.33
|
||||
HOTSPOT_THRESHOLD=9.7
|
||||
AVERAGE_THRESHOLD=9.36
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
29
docs/adr/0050-deterministic-shortcut-command-routing.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0050"
|
||||
title: "Deterministic shortcut command routing"
|
||||
status: active
|
||||
date: 2026-04-11
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Laputa is keyboard-first, but shortcut execution had split ownership: `useAppKeyboard` handled some shortcuts in the renderer while `menu.rs` owned others as native Tauri menu accelerators. That split made QA unreliable. Browser tests could prove the renderer path, but not the native menu path, and flaky macOS key synthesis made `Cmd+Shift+L`, `Cmd+Shift+I`, and `Cmd+N` regressions easy to miss.
|
||||
|
||||
## Decision
|
||||
|
||||
**Keyboard shortcuts and native menu accelerators now dispatch through the same canonical app command IDs. Renderer-owned shortcuts call the shared dispatcher directly; native menu items emit the same IDs into the frontend, and tests get a deterministic menu-command trigger that exercises that route without relying on synthesized native keystrokes.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Shared command IDs plus deterministic menu-command trigger — keeps native desktop UX while making menu-owned commands testable in unit tests, Playwright, and native QA. Downside: one more command layer to maintain.
|
||||
- **Option B**: Move every shortcut to the renderer — simpler automated testing, but worse macOS menu-bar parity and weaker native UX.
|
||||
- **Option C**: Keep renderer and native shortcuts separate — lowest code churn, but continues to produce false confidence and shortcut regressions.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `appCommandDispatcher.ts` owns the canonical shortcut command IDs and the shared execution path used by `useAppKeyboard` and `useMenuEvents`.
|
||||
- Native menu routing remains explicit in `menu.rs`; adding or changing a native shortcut now requires wiring the accelerator and the matching command ID in one place.
|
||||
- Automated QA can trigger menu-owned commands deterministically through the shared `window.__laputaTest.triggerMenuCommand()` bridge in browser runs and through the native `trigger_menu_command` Tauri command in desktop runs.
|
||||
- Keyboard QA should prefer real menu selection or the deterministic menu-command trigger for native-owned shortcuts, and reserve synthesized keystrokes for renderer-owned shortcuts or true end-to-end spot checks.
|
||||
- This decision supersedes the blanket assumption in ADR 0020 that all shortcut verification can be treated as plain keyboard-event testing.
|
||||
@@ -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.”
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 115 KiB |
@@ -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]
|
||||
|
||||
@@ -45,6 +45,17 @@ pub fn rename_note(
|
||||
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_note_filename(
|
||||
vault_path: String,
|
||||
old_path: String,
|
||||
new_filename_stem: String,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let old_path = expand_tilde(&old_path);
|
||||
vault::rename_note_filename(&vault_path, &old_path, &new_filename_stem)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn auto_rename_untitled(
|
||||
vault_path: String,
|
||||
|
||||
@@ -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,74 +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::auto_rename_untitled,
|
||||
commands::detect_renames,
|
||||
commands::update_wikilinks_for_renames,
|
||||
commands::get_file_history,
|
||||
commands::get_modified_files,
|
||||
commands::get_file_diff,
|
||||
commands::get_file_diff_at_commit,
|
||||
commands::get_vault_pulse,
|
||||
commands::git_commit,
|
||||
commands::get_build_number,
|
||||
commands::get_last_commit_info,
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::git_remote_status,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
commands::git_commit_conflict_resolution,
|
||||
commands::git_discard_file,
|
||||
commands::is_git_repo,
|
||||
commands::init_git_repo,
|
||||
commands::check_claude_cli,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::sync_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::delete_note,
|
||||
commands::batch_delete_notes,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::create_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::get_settings,
|
||||
commands::update_menu_state,
|
||||
commands::save_settings,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
commands::github_list_repos,
|
||||
commands::github_create_repo,
|
||||
commands::clone_repo,
|
||||
commands::github_device_flow_start,
|
||||
commands::github_device_flow_poll,
|
||||
commands::github_get_user,
|
||||
commands::search_vault,
|
||||
commands::create_empty_vault,
|
||||
commands::create_getting_started_vault,
|
||||
commands::check_vault_exists,
|
||||
commands::get_default_vault_path,
|
||||
commands::register_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
|
||||
@@ -78,6 +78,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
GO_ALL_NOTES,
|
||||
GO_ARCHIVED,
|
||||
GO_CHANGES,
|
||||
GO_INBOX,
|
||||
NOTE_TOGGLE_ORGANIZED,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_DELETE,
|
||||
@@ -213,6 +214,7 @@ fn build_view_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let toggle_properties = MenuItemBuilder::new("Toggle Properties Panel")
|
||||
.id(VIEW_TOGGLE_PROPERTIES)
|
||||
.accelerator("CmdOrCtrl+Shift+I")
|
||||
.build(app)?;
|
||||
let command_palette = MenuItemBuilder::new("Command Palette")
|
||||
.id(VIEW_COMMAND_PALETTE)
|
||||
@@ -404,14 +406,21 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
app.on_menu_event(|app_handle, event| {
|
||||
let id = event.id().0.as_str();
|
||||
if CUSTOM_IDS.contains(&id) {
|
||||
let _ = app_handle.emit("menu-event", id);
|
||||
}
|
||||
let _ = emit_custom_menu_event(app_handle, id);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn emit_custom_menu_event(app_handle: &AppHandle, id: &str) -> Result<(), String> {
|
||||
if !CUSTOM_IDS.contains(&id) {
|
||||
return Err(format!("Unknown custom menu event: {id}"));
|
||||
}
|
||||
app_handle
|
||||
.emit("menu-event", id)
|
||||
.map_err(|err| format!("Failed to emit menu-event {id}: {err}"))
|
||||
}
|
||||
|
||||
fn set_items_enabled(app_handle: &AppHandle, ids: &[&str], enabled: bool) {
|
||||
let Some(menu) = app_handle.menu() else {
|
||||
return;
|
||||
|
||||
@@ -20,8 +20,8 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{
|
||||
auto_rename_untitled, detect_renames, rename_note, update_wikilinks_for_renames,
|
||||
DetectedRename, RenameResult,
|
||||
auto_rename_untitled, detect_renames, rename_note, rename_note_filename,
|
||||
update_wikilinks_for_renames, DetectedRename, RenameResult,
|
||||
};
|
||||
pub use title_sync::{sync_title_on_open, SyncAction};
|
||||
pub use trash::{batch_delete_notes, delete_note};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
@@ -28,13 +29,17 @@ pub(super) fn title_to_slug(title: &str) -> String {
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Build a regex that matches wiki links referencing old title or path stem.
|
||||
fn build_wikilink_pattern(old_title: &str, old_path_stem: &str) -> Option<Regex> {
|
||||
let pattern_str = format!(
|
||||
r"\[\[(?:{}|{})(\|[^\]]*?)?\]\]",
|
||||
regex::escape(old_title),
|
||||
regex::escape(old_path_stem),
|
||||
);
|
||||
/// Build a regex that matches wiki links referencing any of the provided targets.
|
||||
fn build_wikilink_pattern(targets: &[&str]) -> Option<Regex> {
|
||||
let escaped_targets: Vec<String> = targets
|
||||
.iter()
|
||||
.filter(|target| !target.is_empty())
|
||||
.map(|target| regex::escape(target))
|
||||
.collect();
|
||||
if escaped_targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let pattern_str = format!(r"\[\[(?:{})(\|[^\]]*?)?\]\]", escaped_targets.join("|"));
|
||||
Regex::new(&pattern_str).ok()
|
||||
}
|
||||
|
||||
@@ -44,13 +49,13 @@ fn is_replaceable_md_file(path: &Path, exclude: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Replace wikilink references in a single file's content. Returns updated content if changed.
|
||||
fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> Option<String> {
|
||||
fn replace_wikilinks_in_content(content: &str, re: &Regex, new_target: &str) -> Option<String> {
|
||||
if !re.is_match(content) {
|
||||
return None;
|
||||
}
|
||||
let replaced = re.replace_all(content, |caps: ®ex::Captures| match caps.get(1) {
|
||||
Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()),
|
||||
None => format!("[[{}]]", new_title),
|
||||
Some(pipe) => format!("[[{}{}]]", new_target, pipe.as_str()),
|
||||
None => format!("[[{}]]", new_target),
|
||||
});
|
||||
if replaced != content {
|
||||
Some(replaced.into_owned())
|
||||
@@ -59,15 +64,6 @@ fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> O
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for a vault-wide wikilink replacement.
|
||||
struct WikilinkReplacement<'a> {
|
||||
vault_path: &'a Path,
|
||||
old_title: &'a str,
|
||||
new_title: &'a str,
|
||||
old_path_stem: &'a str,
|
||||
exclude_path: &'a Path,
|
||||
}
|
||||
|
||||
/// Collect all .md file paths in vault eligible for wikilink replacement.
|
||||
fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec<std::path::PathBuf> {
|
||||
WalkDir::new(vault_path)
|
||||
@@ -79,47 +75,76 @@ fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec<std::path::PathBuf
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unique_wikilink_targets(targets: Vec<&str>) -> Vec<&str> {
|
||||
let mut seen = HashSet::new();
|
||||
targets
|
||||
.into_iter()
|
||||
.filter(|target| !target.is_empty())
|
||||
.filter(|target| seen.insert(*target))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_legacy_wikilink_targets<'a>(old_title: &'a str, old_path_stem: &'a str) -> Vec<&'a str> {
|
||||
let old_filename_stem = old_path_stem.rsplit('/').next().unwrap_or(old_path_stem);
|
||||
unique_wikilink_targets(vec![old_title, old_path_stem, old_filename_stem])
|
||||
}
|
||||
|
||||
/// Replace wiki link references across all vault markdown files.
|
||||
fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize {
|
||||
let re = match build_wikilink_pattern(params.old_title, params.old_path_stem) {
|
||||
fn update_wikilinks_in_vault(
|
||||
vault_path: &Path,
|
||||
old_targets: &[&str],
|
||||
new_target: &str,
|
||||
exclude_path: &Path,
|
||||
) -> usize {
|
||||
let re = match build_wikilink_pattern(old_targets) {
|
||||
Some(r) => r,
|
||||
None => return 0,
|
||||
};
|
||||
replace_wikilinks_in_files(collect_md_files(vault_path, exclude_path), &re, new_target)
|
||||
}
|
||||
|
||||
let files = collect_md_files(params.vault_path, params.exclude_path);
|
||||
fn replace_wikilinks_in_files(
|
||||
files: Vec<std::path::PathBuf>,
|
||||
re: &Regex,
|
||||
replacement: &str,
|
||||
) -> usize {
|
||||
files
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
match replace_wikilinks_in_content(&content, &re, params.new_title) {
|
||||
Some(new_content) => fs::write(path, &new_content).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
.filter(|path| rewrite_wikilinks_in_file(path, re, replacement))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> bool {
|
||||
let Ok(content) = fs::read_to_string(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(new_content) = replace_wikilinks_in_content(&content, re, replacement) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
fs::write(path, &new_content).is_ok()
|
||||
}
|
||||
|
||||
/// Extract the value of the `title:` frontmatter field from raw content.
|
||||
fn extract_fm_title_value(content: &str) -> Option<String> {
|
||||
if !content.starts_with("---\n") {
|
||||
return None;
|
||||
}
|
||||
let fm = content[4..].split("\n---").next()?;
|
||||
for line in fm.lines() {
|
||||
let t = line.trim_start();
|
||||
for prefix in &["title:", "\"title\":"] {
|
||||
if let Some(rest) = t.strip_prefix(prefix) {
|
||||
let val = rest.trim().trim_matches('"').trim_matches('\'');
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
fm.lines()
|
||||
.map(str::trim_start)
|
||||
.find_map(extract_title_value_from_frontmatter_line)
|
||||
}
|
||||
|
||||
fn extract_title_value_from_frontmatter_line(line: &str) -> Option<String> {
|
||||
["title:", "\"title\":"]
|
||||
.iter()
|
||||
.find_map(|prefix| line.strip_prefix(prefix))
|
||||
.map(str::trim)
|
||||
.map(|value| value.trim_matches('"').trim_matches('\''))
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_string())
|
||||
}
|
||||
|
||||
/// Update the `title:` frontmatter field in content.
|
||||
@@ -168,6 +193,22 @@ fn unique_dest_path(dest_dir: &Path, filename: &str, exclude: &Path) -> std::pat
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_filename_stem(new_filename_stem: &str) -> Result<String, String> {
|
||||
let trimmed = new_filename_stem.trim();
|
||||
let stem = trimmed.strip_suffix(".md").unwrap_or(trimmed).trim();
|
||||
if stem.is_empty() {
|
||||
return Err("New filename cannot be empty".to_string());
|
||||
}
|
||||
if is_invalid_filename_stem(stem) {
|
||||
return Err("Invalid filename".to_string());
|
||||
}
|
||||
Ok(stem.to_string())
|
||||
}
|
||||
|
||||
fn is_invalid_filename_stem(stem: &str) -> bool {
|
||||
stem == "." || stem == ".." || stem.contains('/') || stem.contains('\\')
|
||||
}
|
||||
|
||||
/// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault.
|
||||
///
|
||||
/// When `old_title_hint` is provided it is used instead of extracting the title from
|
||||
@@ -237,13 +278,9 @@ pub fn rename_note(
|
||||
// Update wikilinks across the vault
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let updated_files = update_wikilinks_in_vault(&WikilinkReplacement {
|
||||
vault_path: vault,
|
||||
old_title,
|
||||
new_title,
|
||||
old_path_stem,
|
||||
exclude_path: &new_file,
|
||||
});
|
||||
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix).to_string();
|
||||
let old_targets = collect_legacy_wikilink_targets(old_title, old_path_stem);
|
||||
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
|
||||
|
||||
Ok(RenameResult {
|
||||
new_path: new_path_str,
|
||||
@@ -251,6 +288,67 @@ pub fn rename_note(
|
||||
})
|
||||
}
|
||||
|
||||
/// Rename only the file path stem while preserving title/frontmatter content.
|
||||
pub fn rename_note_filename(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
new_filename_stem: &str,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
|
||||
if !old_file.exists() {
|
||||
return Err(format!("File does not exist: {}", old_path));
|
||||
}
|
||||
|
||||
let normalized_stem = normalize_filename_stem(new_filename_stem)?;
|
||||
let old_filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let fm_title = extract_fm_title_value(&content);
|
||||
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
|
||||
let new_filename = format!("{}.md", normalized_stem);
|
||||
|
||||
if old_filename == new_filename {
|
||||
return Ok(RenameResult {
|
||||
new_path: old_path.to_string(),
|
||||
updated_files: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let new_file = parent_dir.join(&new_filename);
|
||||
if new_file.exists() && new_file != old_file {
|
||||
return Err("A note with that name already exists".to_string());
|
||||
}
|
||||
|
||||
fs::rename(old_file, &new_file).map_err(|e| {
|
||||
format!(
|
||||
"Failed to rename {} to {}: {}",
|
||||
old_path,
|
||||
new_file.to_string_lossy(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let new_path = new_file.to_string_lossy().to_string();
|
||||
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
|
||||
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
|
||||
|
||||
Ok(RenameResult {
|
||||
new_path,
|
||||
updated_files,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md").
|
||||
fn is_untitled_filename(filename: &str) -> bool {
|
||||
let stem = filename.strip_suffix(".md").unwrap_or(filename);
|
||||
@@ -352,22 +450,12 @@ pub fn update_wikilinks_for_renames(
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&rename.new_path);
|
||||
let old_filename_stem = old_stem.split('/').next_back().unwrap_or(old_stem);
|
||||
let new_filename_stem = new_stem.split('/').next_back().unwrap_or(new_stem);
|
||||
|
||||
// Build title from filename stem (kebab-case → Title Case)
|
||||
let old_title = super::parsing::slug_to_title(old_filename_stem);
|
||||
let new_title = super::parsing::slug_to_title(new_filename_stem);
|
||||
|
||||
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
|
||||
let new_file = vault.join(&rename.new_path);
|
||||
|
||||
let updated = update_wikilinks_in_vault(&WikilinkReplacement {
|
||||
vault_path: vault,
|
||||
old_title: &old_title,
|
||||
new_title: &new_title,
|
||||
old_path_stem: old_filename_stem,
|
||||
exclude_path: &new_file,
|
||||
});
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem);
|
||||
let updated = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
|
||||
total_updated += updated;
|
||||
}
|
||||
|
||||
@@ -457,11 +545,11 @@ mod tests {
|
||||
assert_eq!(result.updated_files, 2);
|
||||
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[Sprint Retrospective]]"));
|
||||
assert!(other_content.contains("[[note/sprint-retrospective]]"));
|
||||
assert!(!other_content.contains("[[Weekly Review]]"));
|
||||
|
||||
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
|
||||
assert!(project_content.contains("[[Sprint Retrospective]]"));
|
||||
assert!(project_content.contains("[[note/sprint-retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -521,7 +609,33 @@ mod tests {
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains("[[Sprint Retro|my review]]"));
|
||||
assert!(ref_content.contains("[[note/sprint-retro|my review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_updates_filename_only_wikilinks_to_canonical_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/ref.md",
|
||||
"# Ref\n\nSee [[weekly-review]] for info.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retro",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains("[[note/sprint-retro]]"));
|
||||
assert!(!ref_content.contains("[[weekly-review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -698,6 +812,58 @@ mod tests {
|
||||
assert!(vault.join("note/my-note.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_filename_preserves_title_and_updates_path_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/project-kickoff.md",
|
||||
"---\ntitle: Project Kickoff\ntype: Note\n---\n\n# Project Kickoff\n\nBody.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/ref.md",
|
||||
"# Ref\n\nSee [[note/project-kickoff]] and [[Project Kickoff]].\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/project-kickoff.md");
|
||||
let result = rename_note_filename(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"manual-name",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.new_path.ends_with("manual-name.md"));
|
||||
assert!(!old_path.exists());
|
||||
|
||||
let renamed = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert!(renamed.contains("title: Project Kickoff"));
|
||||
assert!(renamed.contains("# Project Kickoff"));
|
||||
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains("[[note/manual-name]]"));
|
||||
assert!(!ref_content.contains("[[Project Kickoff]]"));
|
||||
assert!(!ref_content.contains("[[note/project-kickoff]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_filename_rejects_existing_destination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/current.md", "# Current\n");
|
||||
create_test_file(vault, "note/manual-name.md", "# Existing\n");
|
||||
|
||||
let result = rename_note_filename(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("note/current.md").to_str().unwrap(),
|
||||
"manual-name",
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap_err(), "A note with that name already exists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_with_old_title_hint_updates_wikilinks() {
|
||||
// Simulates H1 sync: content already saved with new H1, but wikilinks still use old title.
|
||||
@@ -736,11 +902,11 @@ mod tests {
|
||||
assert!(!vault.join("note/weekly-review.md").exists());
|
||||
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[Sprint Retrospective]]"));
|
||||
assert!(other_content.contains("[[note/sprint-retrospective]]"));
|
||||
assert!(!other_content.contains("[[Weekly Review]]"));
|
||||
|
||||
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
|
||||
assert!(project_content.contains("[[Sprint Retrospective]]"));
|
||||
assert!(project_content.contains("[[note/sprint-retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -770,7 +936,7 @@ mod tests {
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[Sprint Retrospective]]"));
|
||||
assert!(other_content.contains("[[note/sprint-retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
10
src/App.tsx
@@ -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,
|
||||
@@ -288,6 +288,13 @@ function App() {
|
||||
replaceEntry: vault.replaceEntry, resolvedPath,
|
||||
})
|
||||
|
||||
const handleFilenameRename = useCallback((path: string, newFilenameStem: string) => {
|
||||
appSave.savePendingForPath(path)
|
||||
.then(() => notes.handleRenameFilename(path, newFilenameStem, resolvedPath, vault.replaceEntry))
|
||||
.then(vault.loadModifiedFiles)
|
||||
.catch((err) => console.error('Filename rename failed:', err))
|
||||
}, [appSave, notes, resolvedPath, vault])
|
||||
|
||||
const aiActivity = useAiActivity({
|
||||
onOpenNote: vaultBridge.openNoteByPath,
|
||||
onOpenTab: vaultBridge.openNoteByPath,
|
||||
@@ -701,6 +708,7 @@ function App() {
|
||||
onContentChange={appSave.handleContentChange}
|
||||
onSave={appSave.handleSave}
|
||||
onTitleSync={activeDeletedFile ? undefined : appSave.handleTitleSync}
|
||||
onRenameFilename={activeDeletedFile ? undefined : handleFilenameRename}
|
||||
rawToggleRef={rawToggleRef}
|
||||
diffToggleRef={diffToggleRef}
|
||||
canGoBack={canGoBack}
|
||||
|
||||
@@ -153,6 +153,79 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
|
||||
|
||||
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
|
||||
})
|
||||
|
||||
it('keeps the breadcrumb title visible when the separate title section is absent', () => {
|
||||
const { container } = render(
|
||||
<BreadcrumbBar entry={baseEntry} {...defaultProps} showTitleSection={false} />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — filename controls', () => {
|
||||
it('shows the sync button when the filename diverges from the title slug', () => {
|
||||
const entry = { ...baseEntry, title: 'Fresh Title', filename: 'untitled-note-123.md' }
|
||||
render(<BreadcrumbBar entry={entry} {...defaultProps} onRenameFilename={vi.fn()} />)
|
||||
expect(screen.getByTestId('breadcrumb-sync-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the sync button when the filename already matches the title slug', () => {
|
||||
const entry = { ...baseEntry, title: 'Test Note', filename: 'test-note.md' }
|
||||
render(<BreadcrumbBar entry={entry} {...defaultProps} onRenameFilename={vi.fn()} />)
|
||||
expect(screen.queryByTestId('breadcrumb-sync-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking the sync button renames the file to the title slug', () => {
|
||||
const onRenameFilename = vi.fn()
|
||||
const entry = { ...baseEntry, title: 'Fresh Title', filename: 'untitled-note-123.md' }
|
||||
render(<BreadcrumbBar entry={entry} {...defaultProps} onRenameFilename={onRenameFilename} />)
|
||||
fireEvent.click(screen.getByTestId('breadcrumb-sync-button'))
|
||||
expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'fresh-title')
|
||||
})
|
||||
|
||||
it('lets keyboard users press Enter on the filename to start editing', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={vi.fn()} />)
|
||||
fireEvent.keyDown(screen.getByTestId('breadcrumb-filename-trigger'), { key: 'Enter' })
|
||||
expect(screen.getByTestId('breadcrumb-filename-input')).toHaveValue('test')
|
||||
})
|
||||
|
||||
it('double-clicking the filename enters edit mode and Enter confirms the rename', () => {
|
||||
const onRenameFilename = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={onRenameFilename} />)
|
||||
|
||||
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
|
||||
const input = screen.getByTestId('breadcrumb-filename-input')
|
||||
fireEvent.change(input, { target: { value: 'renamed-file' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onRenameFilename).toHaveBeenCalledWith(baseEntry.path, 'renamed-file')
|
||||
})
|
||||
|
||||
it('pressing Escape while editing cancels the inline rename', () => {
|
||||
const onRenameFilename = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={onRenameFilename} />)
|
||||
|
||||
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
|
||||
const input = screen.getByTestId('breadcrumb-filename-input')
|
||||
fireEvent.change(input, { target: { value: 'renamed-file' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
|
||||
expect(onRenameFilename).not.toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('breadcrumb-filename-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('blur confirms the inline rename when the value changed', () => {
|
||||
const onRenameFilename = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={onRenameFilename} />)
|
||||
|
||||
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
|
||||
const input = screen.getByTestId('breadcrumb-filename-input')
|
||||
fireEvent.change(input, { target: { value: 'renamed-on-blur' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onRenameFilename).toHaveBeenCalledWith(baseEntry.path, 'renamed-on-blur')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — action buttons always right-aligned', () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { memo } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
GitBranch,
|
||||
@@ -14,8 +17,10 @@ import {
|
||||
ArrowUUpLeft,
|
||||
Star,
|
||||
CheckCircle,
|
||||
ArrowsClockwise,
|
||||
} from '@phosphor-icons/react'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { slugify } from '../hooks/useNoteCreation'
|
||||
|
||||
interface BreadcrumbBarProps {
|
||||
entry: VaultEntry
|
||||
@@ -37,170 +42,485 @@ interface BreadcrumbBarProps {
|
||||
onDelete?: () => void
|
||||
onArchive?: () => void
|
||||
onUnarchive?: () => void
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
showTitleSection?: boolean
|
||||
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
|
||||
barRef?: React.Ref<HTMLDivElement>
|
||||
}
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
const BREADCRUMB_ICON_CLASS = 'size-[16px]'
|
||||
|
||||
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
|
||||
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,
|
||||
onClick,
|
||||
className,
|
||||
style,
|
||||
disabled,
|
||||
tabIndex,
|
||||
children,
|
||||
testId,
|
||||
}: {
|
||||
title: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
disabled?: boolean
|
||||
tabIndex?: number
|
||||
children: ReactNode
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors',
|
||||
rawMode ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={onToggleRaw}
|
||||
title={rawMode ? 'Back to editor' : 'Raw editor'}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn('text-muted-foreground [&_svg:not([class*=size-])]:size-4', className)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
title={title}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Code size={16} />
|
||||
</button>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
|
||||
rawMode, onToggleRaw, forceRawMode,
|
||||
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onToggleFavorite, onToggleOrganized, onDelete, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount'>) {
|
||||
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
|
||||
return (
|
||||
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
entry.favorite ? "text-yellow-500" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onToggleFavorite}
|
||||
title={entry.favorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Star size={16} weight={entry.favorite ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
{onToggleOrganized && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
entry.organized ? "text-green-600" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onToggleOrganized}
|
||||
title={entry.organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
|
||||
>
|
||||
<CheckCircle size={16} weight={entry.organized ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
title="Search in file"
|
||||
>
|
||||
<MagnifyingGlass size={16} />
|
||||
</button>
|
||||
{showDiffToggle ? (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
diffMode ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onToggleDiff}
|
||||
disabled={diffLoading}
|
||||
title={diffLoading ? 'Loading diff...' : diffMode ? 'Back to editor' : 'Show diff'}
|
||||
>
|
||||
<GitBranch size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
title="No changes"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<GitBranch size={16} />
|
||||
</button>
|
||||
)}
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
title="Coming soon"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<CursorText size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
showAIChat ? "" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
style={showAIChat ? { color: 'var(--primary)' } : undefined}
|
||||
onClick={onToggleAIChat}
|
||||
title={showAIChat ? 'Close AI Chat' : 'Open AI Chat'}
|
||||
>
|
||||
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
{entry.archived ? (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onUnarchive}
|
||||
title="Unarchive"
|
||||
>
|
||||
<ArrowUUpLeft size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onArchive}
|
||||
title="Archive"
|
||||
>
|
||||
<Archive size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
title="Delete (Cmd+Delete)"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
{inspectorCollapsed && (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onToggleInspector}
|
||||
title="Properties (⌘⇧I)"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
title="Coming soon"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<DotsThree size={16} />
|
||||
</button>
|
||||
<IconActionButton
|
||||
title={rawMode ? 'Back to editor' : 'Raw editor'}
|
||||
onClick={onToggleRaw}
|
||||
className={cn(rawMode ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
<Code size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
|
||||
return (
|
||||
<IconActionButton
|
||||
title={favorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
onClick={onToggleFavorite}
|
||||
className={cn(favorite ? 'text-yellow-500' : 'hover:text-foreground')}
|
||||
>
|
||||
<Star size={16} weight={favorite ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function OrganizedAction({
|
||||
organized,
|
||||
onToggleOrganized,
|
||||
}: {
|
||||
organized: boolean
|
||||
onToggleOrganized?: () => void
|
||||
}) {
|
||||
if (!onToggleOrganized) return null
|
||||
return (
|
||||
<IconActionButton
|
||||
title={organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
|
||||
onClick={onToggleOrganized}
|
||||
className={cn(organized ? 'text-green-600' : 'hover:text-foreground')}
|
||||
>
|
||||
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchAction() {
|
||||
return (
|
||||
<IconActionButton title="Search in file" className="hover:text-foreground">
|
||||
<MagnifyingGlass size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffAction({
|
||||
showDiffToggle,
|
||||
diffMode,
|
||||
diffLoading,
|
||||
onToggleDiff,
|
||||
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'onToggleDiff'>) {
|
||||
if (!showDiffToggle) {
|
||||
return (
|
||||
<IconActionButton title="No changes" style={DISABLED_ICON_STYLE} tabIndex={-1}>
|
||||
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IconActionButton
|
||||
title={diffLoading ? 'Loading diff...' : diffMode ? 'Back to editor' : 'Show diff'}
|
||||
onClick={onToggleDiff}
|
||||
disabled={diffLoading}
|
||||
className={cn(diffMode ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaceholderAction({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<IconActionButton title={title} style={DISABLED_ICON_STYLE} tabIndex={-1}>
|
||||
{children}
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
|
||||
return (
|
||||
<IconActionButton
|
||||
title={showAIChat ? 'Close AI Chat' : 'Open AI Chat'}
|
||||
onClick={onToggleAIChat}
|
||||
className={cn(showAIChat ? 'text-primary' : 'hover:text-foreground')}
|
||||
>
|
||||
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function ArchiveAction({
|
||||
archived,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'onArchive' | 'onUnarchive'>) {
|
||||
if (archived) {
|
||||
return (
|
||||
<IconActionButton title="Unarchive" onClick={onUnarchive} className="hover:text-foreground">
|
||||
<ArrowUUpLeft size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IconActionButton title="Archive" onClick={onArchive} className="hover:text-foreground">
|
||||
<Archive size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
|
||||
return (
|
||||
<IconActionButton title="Delete (Cmd+Delete)" onClick={onDelete} className="hover:text-destructive">
|
||||
<Trash size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function InspectorAction({
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'onToggleInspector'>) {
|
||||
if (!inspectorCollapsed) return null
|
||||
return (
|
||||
<IconActionButton title="Properties (⌘⇧I)" onClick={onToggleInspector} className="hover:text-foreground">
|
||||
<SlidersHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeFilenameStemInput(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.replace(/\.md$/i, '').trim()
|
||||
}
|
||||
|
||||
function deriveSyncStem(entry: VaultEntry): string | null {
|
||||
const expectedStem = slugify(entry.title.trim())
|
||||
const filenameStem = entry.filename.replace(/\.md$/, '')
|
||||
if (!expectedStem || expectedStem === filenameStem) return null
|
||||
return expectedStem
|
||||
}
|
||||
|
||||
function FilenameInput({
|
||||
inputRef,
|
||||
draftStem,
|
||||
onDraftStemChange,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
draftStem: string
|
||||
onDraftStemChange: (nextValue: string) => void
|
||||
onBlur: () => void
|
||||
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={draftStem}
|
||||
onChange={(event) => onDraftStemChange(event.target.value)}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
className="h-7 w-[180px] text-sm"
|
||||
data-testid="breadcrumb-filename-input"
|
||||
aria-label="Rename filename"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FilenameTrigger({
|
||||
entry,
|
||||
filenameStem,
|
||||
onStartEditing,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
filenameStem: string
|
||||
onStartEditing: () => void
|
||||
}) {
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
onStartEditing()
|
||||
}, [onStartEditing])
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-auto min-w-0 gap-1 px-0 py-0 text-sm font-medium text-foreground hover:bg-transparent hover:text-foreground"
|
||||
onDoubleClick={onStartEditing}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="breadcrumb-filename-trigger"
|
||||
aria-label={`Filename ${filenameStem}. Press Enter to rename`}
|
||||
>
|
||||
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
|
||||
<span className="truncate">{filenameStem}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SyncFilenameButton({
|
||||
entryPath,
|
||||
syncStem,
|
||||
onRenameFilename,
|
||||
}: {
|
||||
entryPath: string
|
||||
syncStem: string | null
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
}) {
|
||||
if (!syncStem || !onRenameFilename) return null
|
||||
return (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onRenameFilename(entryPath, syncStem)}
|
||||
data-testid="breadcrumb-sync-button"
|
||||
aria-label="Rename file to match title"
|
||||
>
|
||||
<ArrowsClockwise size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Rename file to match title
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function FilenameDisplay({
|
||||
entry,
|
||||
filenameStem,
|
||||
syncStem,
|
||||
onRenameFilename,
|
||||
onStartEditing,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
filenameStem: string
|
||||
syncStem: string | null
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
onStartEditing: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<FilenameTrigger entry={entry} filenameStem={filenameStem} onStartEditing={onStartEditing} />
|
||||
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
|
||||
function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
|
||||
const filenameStem = useMemo(() => entry.filename.replace(/\.md$/, ''), [entry.filename])
|
||||
const syncStem = useMemo(() => deriveSyncStem(entry), [entry])
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [draftStem, setDraftStem] = useState(filenameStem)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
focusFilenameInput(isEditing, inputRef)
|
||||
}, [isEditing])
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
beginFilenameEditing(onRenameFilename, filenameStem, setDraftStem, setIsEditing)
|
||||
}, [onRenameFilename, filenameStem])
|
||||
|
||||
const cancelEditing = useCallback(() => {
|
||||
setDraftStem(filenameStem)
|
||||
setIsEditing(false)
|
||||
}, [filenameStem])
|
||||
|
||||
const submitRename = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
const nextStem = resolveFilenameRenameTarget(draftStem, filenameStem)
|
||||
if (!nextStem) return
|
||||
onRenameFilename?.(entry.path, nextStem)
|
||||
}, [draftStem, filenameStem, onRenameFilename, entry.path])
|
||||
|
||||
const handleInputKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
||||
handleFilenameInputKeyDown(event, submitRename, cancelEditing)
|
||||
}, [submitRename, cancelEditing])
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<FilenameInput
|
||||
inputRef={inputRef}
|
||||
draftStem={draftStem}
|
||||
onDraftStemChange={setDraftStem}
|
||||
onBlur={submitRename}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FilenameDisplay
|
||||
entry={entry}
|
||||
filenameStem={filenameStem}
|
||||
syncStem={syncStem}
|
||||
onRenameFilename={onRenameFilename}
|
||||
onStartEditing={startEditing}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbActions({
|
||||
entry,
|
||||
showDiffToggle,
|
||||
diffMode,
|
||||
diffLoading,
|
||||
onToggleDiff,
|
||||
rawMode,
|
||||
onToggleRaw,
|
||||
forceRawMode,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount' | 'barRef' | 'onRenameFilename'>) {
|
||||
return (
|
||||
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
|
||||
<FavoriteAction favorite={entry.favorite} onToggleFavorite={onToggleFavorite} />
|
||||
<OrganizedAction organized={entry.organized} onToggleOrganized={onToggleOrganized} />
|
||||
<SearchAction />
|
||||
<DiffAction
|
||||
showDiffToggle={showDiffToggle}
|
||||
diffMode={diffMode}
|
||||
diffLoading={diffLoading}
|
||||
onToggleDiff={onToggleDiff}
|
||||
/>
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
|
||||
<PlaceholderAction title="Coming soon">
|
||||
<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} className={BREADCRUMB_ICON_CLASS} />
|
||||
</PlaceholderAction>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbTitle({
|
||||
entry,
|
||||
onRenameFilename,
|
||||
}: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
|
||||
const typeLabel = entry.isA ?? 'Note'
|
||||
const filenameStem = entry.filename.replace(/\.md$/, '')
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
|
||||
<span className="shrink-0">{typeLabel}</span>
|
||||
<span className="shrink-0 text-border">›</span>
|
||||
<span className="flex min-w-0 items-center gap-1 truncate font-medium text-foreground">
|
||||
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
|
||||
<span className="truncate">{filenameStem}</span>
|
||||
</span>
|
||||
<div className="flex min-w-0 items-center gap-1 truncate">
|
||||
<FilenameCrumb entry={entry} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry, barRef, ...actionProps
|
||||
entry,
|
||||
barRef,
|
||||
onRenameFilename,
|
||||
showTitleSection = true,
|
||||
...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
// In raw/diff mode the title section is not rendered — always show title in breadcrumb.
|
||||
// Using a prop-driven attribute avoids the timing issues of DOM mutation in useEffect.
|
||||
const titleAlwaysVisible = actionProps.rawMode || actionProps.diffMode
|
||||
const titleAlwaysVisible = !showTitleSection || actionProps.rawMode || actionProps.diffMode
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
@@ -215,7 +535,7 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
}}
|
||||
>
|
||||
<div className="breadcrumb-bar__title flex-1 min-w-0">
|
||||
<BreadcrumbTitle entry={entry} />
|
||||
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
<BreadcrumbActions entry={entry} {...actionProps} />
|
||||
</div>
|
||||
|
||||
70
src/components/BreadcrumbBar.visibility.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import './Editor.css'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const baseEntry: VaultEntry = {
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
template: null,
|
||||
sort: null,
|
||||
sidebarLabel: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
properties: {},
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
hasH1: false,
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
wordCount: 100,
|
||||
showDiffToggle: false,
|
||||
diffMode: false,
|
||||
diffLoading: false,
|
||||
onToggleDiff: vi.fn(),
|
||||
}
|
||||
|
||||
describe('BreadcrumbBar filename visibility', () => {
|
||||
it('keeps the filename visible in the breadcrumb by default', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
expect(screen.getByText('test')).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps the filename visible even when the bar is marked as title-hidden', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
container.querySelector('.breadcrumb-bar')?.setAttribute('data-title-hidden', '')
|
||||
expect(screen.getByText('test')).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps breadcrumb action buttons on the zero-padding footprint', () => {
|
||||
const editorCss = readFileSync(`${process.cwd()}/src/components/Editor.css`, 'utf8')
|
||||
|
||||
expect(editorCss).toContain(".breadcrumb-bar__actions [data-slot='button']")
|
||||
expect(editorCss).toContain('width: auto;')
|
||||
expect(editorCss).toContain('height: auto;')
|
||||
expect(editorCss).toContain('padding: 0;')
|
||||
expect(editorCss).toContain('border-radius: 0;')
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,8 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Breadcrumb bar: title + border toggled via data attribute (no React re-render) */
|
||||
/* Breadcrumb bar: border can still react to the data attribute, but the
|
||||
breadcrumb filename/title stays visible at all times. */
|
||||
.breadcrumb-bar {
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
@@ -32,11 +33,19 @@
|
||||
}
|
||||
|
||||
.breadcrumb-bar__title {
|
||||
display: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.breadcrumb-bar[data-title-hidden] .breadcrumb-bar__title {
|
||||
display: flex;
|
||||
.breadcrumb-bar__actions [data-slot='button'] {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-bar__actions [data-slot='button']:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Scroll area wrapping title + editor — single scroll context for alignment */
|
||||
|
||||
@@ -413,7 +413,7 @@ describe('wikilink autocomplete', () => {
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
items[0].onItemClick()
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'vault/project/test|Alpha Project' } },
|
||||
{ type: 'wikilink', props: { target: 'vault/project/test' } },
|
||||
' ',
|
||||
])
|
||||
})
|
||||
@@ -566,7 +566,7 @@ describe('person @mention autocomplete', () => {
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
items[0].onItemClick()
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini|Matteo Cellini' } },
|
||||
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini' } },
|
||||
' ',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -56,6 +56,8 @@ interface EditorProps {
|
||||
onSave?: () => void
|
||||
/** Called when the user edits the title in TitleField. */
|
||||
onTitleSync?: (path: string, newTitle: string) => void
|
||||
/** Called when the user explicitly renames the filename from the breadcrumb. */
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
canGoBack?: boolean
|
||||
canGoForward?: boolean
|
||||
onGoBack?: () => void
|
||||
@@ -203,7 +205,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onTitleSync,
|
||||
onContentChange, onSave, onTitleSync, onRenameFilename,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
} = props
|
||||
@@ -255,6 +257,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
vaultPath={vaultPath}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onTitleChange={onTitleSync}
|
||||
onRenameFilename={onRenameFilename}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
|
||||
@@ -75,6 +75,7 @@ export function EditorRightPanel({
|
||||
content={inspectorContent}
|
||||
entries={entries}
|
||||
gitHistory={gitHistory}
|
||||
vaultPath={vaultPath}
|
||||
onNavigate={onNavigateWikilink}
|
||||
onViewCommitDiff={onViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
|
||||
@@ -24,6 +24,7 @@ interface InspectorProps {
|
||||
content: string | null
|
||||
entries: VaultEntry[]
|
||||
gitHistory: GitCommit[]
|
||||
vaultPath?: string
|
||||
onNavigate: (target: string) => void
|
||||
onViewCommitDiff?: (commitHash: string) => void
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
@@ -41,6 +42,7 @@ export function Inspector({
|
||||
content,
|
||||
entries,
|
||||
gitHistory,
|
||||
vaultPath,
|
||||
onNavigate,
|
||||
onViewCommitDiff,
|
||||
onUpdateFrontmatter,
|
||||
@@ -96,6 +98,7 @@ export function Inspector({
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
vaultPath={vaultPath}
|
||||
onNavigate={onNavigate}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Related to' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
|
||||
fireEvent.click(screen.getByTestId('submit-add-relationship'))
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[AI]]')
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]')
|
||||
})
|
||||
|
||||
it('cancels add relationship form', () => {
|
||||
@@ -427,14 +427,14 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'AI' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[AI]]'])
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
|
||||
})
|
||||
|
||||
it('does not add duplicate refs', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[AI]]'] }}
|
||||
frontmatter={{ 'Belongs to': ['[[topic/ai]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
@@ -533,7 +533,7 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note')
|
||||
await vi.waitFor(() => {
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[Brand New Note]]'])
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[brand-new-note]]'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -647,7 +647,7 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('New Person')
|
||||
await vi.waitFor(() => {
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[New Person]]')
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[new-person]]')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -224,7 +224,7 @@ function NoteDateRow({ entry }: { entry: VaultEntry }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 text-[10px] text-muted-foreground" data-testid="note-date-row">
|
||||
<span>{modifiedLabel}</span>
|
||||
{createdLabel && <span className="shrink-0">{createdLabel}</span>}
|
||||
{createdLabel && <span className="ml-auto">{createdLabel}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,23 @@ import {
|
||||
renderNoteList,
|
||||
} from '../test-utils/noteListTestUtils'
|
||||
|
||||
function makeBookTypeEntries(
|
||||
displayProps: string[] = [],
|
||||
entryOverrides: Parameters<typeof makeEntry>[0] = {},
|
||||
) {
|
||||
return [
|
||||
makeTypeDefinition('Book', displayProps),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
createdAt: 1700000000,
|
||||
...entryOverrides,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
describe('NoteList rendering', () => {
|
||||
it('shows an empty state when there are no entries', () => {
|
||||
renderNoteList({ entries: [] })
|
||||
@@ -137,20 +154,8 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
it('shows the inbox customize-columns action and falls back to type-defined chips', () => {
|
||||
const entries = [
|
||||
makeTypeDefinition('Book', ['Priority']),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
properties: { Priority: 'High', Owner: 'Luca' },
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
|
||||
selection: { kind: 'filter', filter: 'inbox' },
|
||||
inboxNoteListProperties: null,
|
||||
onUpdateInboxNoteListProperties: () => undefined,
|
||||
@@ -163,20 +168,9 @@ describe('NoteList rendering', () => {
|
||||
|
||||
it('opens the inbox column picker from the global event and saves new columns', () => {
|
||||
const onUpdateInboxNoteListProperties = vi.fn()
|
||||
const entries = [
|
||||
makeTypeDefinition('Book', ['Priority']),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
properties: { Priority: 'High', Owner: 'Luca' },
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
|
||||
selection: { kind: 'filter', filter: 'inbox' },
|
||||
inboxNoteListProperties: null,
|
||||
onUpdateInboxNoteListProperties,
|
||||
@@ -194,20 +188,8 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
it('shows status in the type column picker when at least one note has it set', () => {
|
||||
const entries = [
|
||||
makeTypeDefinition('Book'),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
status: 'Active',
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
entries: makeBookTypeEntries([], { status: 'Active' }),
|
||||
selection: { kind: 'sectionGroup', type: 'Book' },
|
||||
onUpdateTypeSort: () => undefined,
|
||||
})
|
||||
@@ -221,21 +203,8 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
it('keeps blank statuses out of the type column picker', () => {
|
||||
const entries = [
|
||||
makeTypeDefinition('Book'),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
status: '',
|
||||
properties: { Owner: 'Luca' },
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
entries: makeBookTypeEntries([], { status: '', properties: { Owner: 'Luca' } }),
|
||||
selection: { kind: 'sectionGroup', type: 'Book' },
|
||||
onUpdateTypeSort: () => undefined,
|
||||
})
|
||||
@@ -250,41 +219,41 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
it('renders status as a note-list chip when a type displays it', () => {
|
||||
const entries = [
|
||||
makeTypeDefinition('Book', ['status']),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
status: 'Active',
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
entries: makeBookTypeEntries(['status'], { status: 'Active' }),
|
||||
selection: { kind: 'sectionGroup', type: 'Book' },
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('property-chips')).toHaveTextContent('Active')
|
||||
const chip = screen.getByTestId('property-chip-status-0')
|
||||
expect(chip).toHaveTextContent('• Active')
|
||||
expect(chip).toHaveStyle({ backgroundColor: 'var(--accent-green-light)', color: 'var(--accent-green)' })
|
||||
})
|
||||
|
||||
it('auto-detects status-like property values in note-list chips', () => {
|
||||
renderNoteList({
|
||||
entries: makeBookTypeEntries(['Phase'], { properties: { Phase: 'Draft' } }),
|
||||
selection: { kind: 'sectionGroup', type: 'Book' },
|
||||
})
|
||||
|
||||
const chip = screen.getByTestId('property-chip-phase-0')
|
||||
expect(chip).toHaveTextContent('• Draft')
|
||||
expect(chip).toHaveStyle({ backgroundColor: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' })
|
||||
})
|
||||
|
||||
it('keeps unknown status values on neutral note-list chip styling', () => {
|
||||
renderNoteList({
|
||||
entries: makeBookTypeEntries(['status'], { status: 'Needs Review' }),
|
||||
selection: { kind: 'sectionGroup', type: 'Book' },
|
||||
})
|
||||
|
||||
const chip = screen.getByTestId('property-chip-status-0')
|
||||
expect(chip).toHaveTextContent('• Needs Review')
|
||||
expect(chip.getAttribute('style')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses inbox overrides when configured', () => {
|
||||
const entries = [
|
||||
makeTypeDefinition('Book', ['Priority']),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
properties: { Priority: 'High', Owner: 'Luca' },
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
|
||||
selection: { kind: 'filter', filter: 'inbox' },
|
||||
inboxNoteListProperties: ['Owner'],
|
||||
onUpdateInboxNoteListProperties: () => undefined,
|
||||
|
||||
@@ -48,6 +48,7 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="quick-open-palette"
|
||||
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
|
||||
onClick={onClose}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, act } from '@testing-library/react'
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
||||
|
||||
function entry(title: string, path = `/vault/note/${title}.md`) {
|
||||
return {
|
||||
@@ -23,61 +22,6 @@ const defaultProps = {
|
||||
onSave: vi.fn(),
|
||||
}
|
||||
|
||||
describe('extractWikilinkQuery', () => {
|
||||
it('returns null when no [[ trigger', () => {
|
||||
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns empty string immediately after [[', () => {
|
||||
const text = 'see [['
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('')
|
||||
})
|
||||
|
||||
it('returns query after [[', () => {
|
||||
const text = 'see [[Proj'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
|
||||
})
|
||||
|
||||
it('returns null when ]] closes the link', () => {
|
||||
const text = '[[Proj]]'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when newline is in query', () => {
|
||||
const text = '[[Proj\ncontinued'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles cursor before end of text', () => {
|
||||
const text = '[[Proj after'
|
||||
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectYamlError', () => {
|
||||
it('returns null for content without frontmatter', () => {
|
||||
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid frontmatter', () => {
|
||||
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns error for unclosed frontmatter', () => {
|
||||
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
|
||||
expect(error).toContain('Unclosed frontmatter')
|
||||
})
|
||||
|
||||
it('returns error for tab indentation in frontmatter', () => {
|
||||
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
|
||||
expect(error).toContain('tab indentation')
|
||||
})
|
||||
|
||||
it('returns null for content not starting with ---', () => {
|
||||
expect(detectYamlError('Not frontmatter')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RawEditorView', () => {
|
||||
it('renders CodeMirror container', () => {
|
||||
render(<RawEditorView {...defaultProps} />)
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
|
||||
import { MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { NoteSearchList } from './NoteSearchList'
|
||||
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
||||
import {
|
||||
buildRawEditorAutocompleteState,
|
||||
buildRawEditorBaseItems,
|
||||
detectYamlError,
|
||||
extractWikilinkQuery,
|
||||
getRawEditorDropdownPosition,
|
||||
replaceActiveWikilinkQuery,
|
||||
type RawEditorAutocompleteState,
|
||||
} from '../utils/rawEditorUtils'
|
||||
import { useCodeMirror } from '../hooks/useCodeMirror'
|
||||
import type { WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
interface AutocompleteState {
|
||||
caretTop: number
|
||||
caretLeft: number
|
||||
selectedIndex: number
|
||||
items: WikilinkSuggestionItem[]
|
||||
}
|
||||
|
||||
export interface RawEditorViewProps {
|
||||
content: string
|
||||
path: string
|
||||
@@ -32,13 +31,6 @@ export interface RawEditorViewProps {
|
||||
const DEBOUNCE_MS = 500
|
||||
const DROPDOWN_MAX_HEIGHT = 200
|
||||
|
||||
function getCursorCoords(view: EditorView): { top: number; left: number } | null {
|
||||
const pos = view.state.selection.main.head
|
||||
const coords = view.coordsAtPos(pos)
|
||||
if (!coords) return null
|
||||
return { top: coords.bottom, left: coords.left }
|
||||
}
|
||||
|
||||
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -52,23 +44,14 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
|
||||
useEffect(() => { onSaveRef.current = onSave }, [onSave])
|
||||
|
||||
const [autocomplete, setAutocomplete] = useState<AutocompleteState | null>(null)
|
||||
const [autocomplete, setAutocomplete] = useState<RawEditorAutocompleteState | null>(null)
|
||||
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))),
|
||||
[entries],
|
||||
)
|
||||
const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries])
|
||||
|
||||
const insertWikilinkRef = useRef<(entryTitle: string) => void>(() => {})
|
||||
const insertWikilinkRef = useRef<(target: string) => void>(() => {})
|
||||
|
||||
const latestContentRefStable = useRef(latestContentRef)
|
||||
useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef])
|
||||
@@ -91,12 +74,15 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
setAutocomplete(null)
|
||||
return
|
||||
}
|
||||
const coords = getCursorCoords(view)
|
||||
if (!coords) { setAutocomplete(null); return }
|
||||
const candidates = preFilterWikilinks(baseItems, query)
|
||||
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title), vaultPath ?? '')
|
||||
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
|
||||
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
|
||||
const nextAutocomplete = buildRawEditorAutocompleteState({
|
||||
view,
|
||||
baseItems,
|
||||
query,
|
||||
typeEntryMap,
|
||||
onInsertTarget: (target: string) => insertWikilinkRef.current(target),
|
||||
vaultPath: vaultPath ?? '',
|
||||
})
|
||||
setAutocomplete(nextAutocomplete)
|
||||
}, [baseItems, typeEntryMap, vaultPath])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -120,30 +106,25 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
onEscape: handleEscape,
|
||||
})
|
||||
|
||||
const insertWikilink = useCallback((entryTitle: string) => {
|
||||
const insertWikilink = useCallback((target: string) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
const cursor = view.state.selection.main.head
|
||||
const doc = view.state.doc.toString()
|
||||
const before = doc.slice(0, cursor)
|
||||
const triggerIdx = before.lastIndexOf('[[')
|
||||
if (triggerIdx === -1) return
|
||||
|
||||
const after = doc.slice(cursor)
|
||||
const newText = `${doc.slice(0, triggerIdx)}[[${entryTitle}]]${after}`
|
||||
const newCursor = triggerIdx + entryTitle.length + 4
|
||||
const replacement = replaceActiveWikilinkQuery(doc, cursor, target)
|
||||
if (!replacement) return
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: doc.length, insert: newText },
|
||||
selection: { anchor: newCursor },
|
||||
changes: { from: 0, to: doc.length, insert: replacement.text },
|
||||
selection: { anchor: replacement.cursor },
|
||||
})
|
||||
trackEvent('wikilink_inserted')
|
||||
setAutocomplete(null)
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
latestDocRef.current = newText
|
||||
onContentChangeRef.current(pathRef.current, newText)
|
||||
latestDocRef.current = replacement.text
|
||||
onContentChangeRef.current(pathRef.current, replacement.text)
|
||||
|
||||
view.focus()
|
||||
}, [viewRef])
|
||||
@@ -165,9 +146,9 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const item = autocomplete.items[autocomplete.selectedIndex]
|
||||
if (item) insertWikilink(item.entryTitle ?? item.title)
|
||||
if (item) item.onItemClick()
|
||||
}
|
||||
}, [autocomplete, insertWikilink])
|
||||
}, [autocomplete])
|
||||
|
||||
// Flush pending debounce on unmount
|
||||
useEffect(() => {
|
||||
@@ -179,15 +160,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dropdownBelow = autocomplete
|
||||
? autocomplete.caretTop + 20 + DROPDOWN_MAX_HEIGHT <= window.innerHeight
|
||||
: true
|
||||
const dropdownTop = autocomplete
|
||||
? (dropdownBelow ? autocomplete.caretTop + 4 : autocomplete.caretTop - DROPDOWN_MAX_HEIGHT - 24)
|
||||
: 0
|
||||
const dropdownLeft = autocomplete
|
||||
? Math.min(autocomplete.caretLeft, window.innerWidth - 260)
|
||||
: 0
|
||||
const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window)
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={handleAutocompleteKey} role="presentation">
|
||||
@@ -212,8 +185,8 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
<div
|
||||
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
|
||||
style={{
|
||||
top: dropdownTop,
|
||||
left: dropdownLeft,
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
maxHeight: DROPDOWN_MAX_HEIGHT,
|
||||
background: 'var(--popover)',
|
||||
borderColor: 'var(--border)',
|
||||
@@ -224,7 +197,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
items={autocomplete.items}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
|
||||
onItemClick={(item) => insertWikilink(item.entryTitle ?? item.title)}
|
||||
onItemClick={(item) => item.onItemClick()}
|
||||
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -900,6 +900,46 @@ describe('Sidebar', () => {
|
||||
favorite: true, favoriteIndex: 0,
|
||||
}
|
||||
|
||||
function getFavoriteAndTypeRows(favoriteTitle = 'My Favorite Note') {
|
||||
const favoriteLabel = screen.getByText(favoriteTitle)
|
||||
const favoriteRow = favoriteLabel.closest('.cursor-pointer')
|
||||
const typeLabel = screen.getByText('Projects')
|
||||
const typeRow = typeLabel.closest('.cursor-pointer')
|
||||
expect(favoriteRow).not.toBeNull()
|
||||
expect(typeRow).not.toBeNull()
|
||||
|
||||
return {
|
||||
favoriteLabel,
|
||||
favoriteRow: favoriteRow as HTMLElement,
|
||||
typeLabel,
|
||||
typeRow: typeRow as HTMLElement,
|
||||
}
|
||||
}
|
||||
|
||||
function expectFavoriteIconToMatchTypeSizing(favoriteRow: HTMLElement) {
|
||||
const favoriteIcon = favoriteRow.querySelector('svg')
|
||||
expect(favoriteIcon).not.toBeNull()
|
||||
expect(favoriteIcon!.getAttribute('width')).toBe('16')
|
||||
expect(favoriteIcon!.getAttribute('height')).toBe('16')
|
||||
expect(favoriteIcon!.getAttribute('style')).toContain('var(--accent-red)')
|
||||
return favoriteIcon as SVGElement
|
||||
}
|
||||
|
||||
function expectFavoriteRowToMatchTypeRow(favoriteTitle = 'My Favorite Note') {
|
||||
const { favoriteLabel, favoriteRow, typeLabel, typeRow } = getFavoriteAndTypeRows(favoriteTitle)
|
||||
|
||||
expect(favoriteRow.className).toBe(typeRow.className)
|
||||
expect(favoriteRow.style.padding).toBe(typeRow.style.padding)
|
||||
expect(favoriteRow.style.gap).toBe(typeRow.style.gap)
|
||||
expect(favoriteLabel.className).toContain(typeLabel.className)
|
||||
expect(favoriteLabel.className).toContain('truncate')
|
||||
return {
|
||||
favoriteRow,
|
||||
typeRow,
|
||||
favoriteIcon: expectFavoriteIconToMatchTypeSizing(favoriteRow),
|
||||
}
|
||||
}
|
||||
|
||||
it('shows FAVORITES section when there are favorites', () => {
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('FAVORITES')).toBeInTheDocument()
|
||||
@@ -920,21 +960,28 @@ describe('Sidebar', () => {
|
||||
|
||||
it('matches the Types row styling and type color for favorites', () => {
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expectFavoriteRowToMatchTypeRow()
|
||||
})
|
||||
|
||||
const favoriteLabel = screen.getByText('My Favorite Note')
|
||||
const favoriteRow = favoriteLabel.closest('.cursor-pointer')
|
||||
const typeLabel = screen.getByText('Projects')
|
||||
const typeRow = typeLabel.closest('.cursor-pointer')
|
||||
const favoriteIcon = favoriteRow?.querySelector('svg')
|
||||
it('prefers a favorite note emoji icon over the type icon fallback', () => {
|
||||
const emojiFavorite = { ...favEntry, title: 'Emoji Favorite', icon: '🚀' }
|
||||
|
||||
expect(favoriteRow?.className).toBe(typeRow?.className)
|
||||
expect(favoriteRow?.style.padding).toBe(typeRow?.style.padding)
|
||||
expect(favoriteRow?.style.gap).toBe(typeRow?.style.gap)
|
||||
expect(favoriteLabel.className).toContain(typeLabel.className)
|
||||
expect(favoriteLabel.className).toContain('truncate')
|
||||
expect(favoriteIcon?.getAttribute('width')).toBe('16')
|
||||
expect(favoriteIcon?.getAttribute('height')).toBe('16')
|
||||
expect(favoriteIcon?.getAttribute('style')).toContain('var(--accent-red)')
|
||||
render(<Sidebar entries={[...mockEntries, emojiFavorite]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const emojiRow = screen.getByText('Emoji Favorite').closest('.cursor-pointer')
|
||||
expect(within(emojiRow as HTMLElement).getByText('🚀')).toBeInTheDocument()
|
||||
expect(emojiRow?.querySelector('svg')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses a favorite note phosphor icon and keeps the type color', () => {
|
||||
const iconFavorite = { ...favEntry, title: 'Icon Favorite', icon: 'rocket' }
|
||||
|
||||
render(<Sidebar entries={[...mockEntries, iconFavorite]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const { favoriteIcon, typeRow } = expectFavoriteRowToMatchTypeRow('Icon Favorite')
|
||||
const typeIcon = typeRow.querySelector('svg')
|
||||
expect(typeIcon).not.toBeNull()
|
||||
expect(favoriteIcon.innerHTML).not.toBe(typeIcon!.innerHTML)
|
||||
})
|
||||
|
||||
it('falls back to a neutral icon color when the favorite type has no defined color', () => {
|
||||
@@ -1121,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')
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ type BreadcrumbActions = Pick<
|
||||
| 'onDeleteNote'
|
||||
| 'onArchiveNote'
|
||||
| 'onUnarchiveNote'
|
||||
| 'onRenameFilename'
|
||||
>
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -98,12 +99,14 @@ function ActiveTabBreadcrumb({
|
||||
barRef,
|
||||
wordCount,
|
||||
path,
|
||||
showTitleSection,
|
||||
actions,
|
||||
}: {
|
||||
activeTab: NonNullable<EditorContentModel['activeTab']>
|
||||
barRef: React.RefObject<HTMLDivElement | null>
|
||||
wordCount: number
|
||||
path: string
|
||||
showTitleSection: boolean
|
||||
actions: BreadcrumbActions
|
||||
}) {
|
||||
return (
|
||||
@@ -111,6 +114,7 @@ function ActiveTabBreadcrumb({
|
||||
entry={activeTab.entry}
|
||||
wordCount={wordCount}
|
||||
barRef={barRef}
|
||||
showTitleSection={showTitleSection}
|
||||
showDiffToggle={actions.showDiffToggle}
|
||||
diffMode={actions.diffMode}
|
||||
diffLoading={actions.diffLoading}
|
||||
@@ -127,6 +131,7 @@ function ActiveTabBreadcrumb({
|
||||
onDelete={bindPath(actions.onDeleteNote, path)}
|
||||
onArchive={bindPath(actions.onArchiveNote, path)}
|
||||
onUnarchive={bindPath(actions.onUnarchiveNote, path)}
|
||||
onRenameFilename={actions.onRenameFilename}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -316,6 +321,7 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
barRef={breadcrumbBarRef}
|
||||
wordCount={wordCount}
|
||||
path={path}
|
||||
showTitleSection={showTitleSection}
|
||||
actions={{
|
||||
diffMode: model.diffMode,
|
||||
diffLoading: model.diffLoading,
|
||||
@@ -333,6 +339,7 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
onDeleteNote: model.onDeleteNote,
|
||||
onArchiveNote: model.onArchiveNote,
|
||||
onUnarchiveNote: model.onUnarchiveNote,
|
||||
onRenameFilename: model.onRenameFilename,
|
||||
}}
|
||||
/>
|
||||
<EditorChrome
|
||||
|
||||
@@ -40,11 +40,54 @@ export interface EditorContentProps {
|
||||
vaultPath?: string
|
||||
rawLatestContentRef?: React.MutableRefObject<string | null>
|
||||
onTitleChange?: (path: string, newTitle: string) => void
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
isConflicted?: boolean
|
||||
onKeepMine?: (path: string) => void
|
||||
onKeepTheirs?: (path: string) => void
|
||||
}
|
||||
|
||||
function useBreadcrumbTitleVisibility({
|
||||
showEditor,
|
||||
showTitleSection,
|
||||
path,
|
||||
breadcrumbBarRef,
|
||||
titleSectionRef,
|
||||
}: {
|
||||
showEditor: boolean
|
||||
showTitleSection: boolean
|
||||
path: string
|
||||
breadcrumbBarRef: React.RefObject<HTMLDivElement | null>
|
||||
titleSectionRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!showEditor) return
|
||||
|
||||
const bar = breadcrumbBarRef.current
|
||||
const titleSection = titleSectionRef.current
|
||||
if (!bar || !titleSection) return
|
||||
|
||||
if (!showTitleSection) {
|
||||
bar.setAttribute('data-title-hidden', '')
|
||||
return () => {
|
||||
bar.removeAttribute('data-title-hidden')
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) bar.removeAttribute('data-title-hidden')
|
||||
else bar.setAttribute('data-title-hidden', '')
|
||||
},
|
||||
{ threshold: 0 },
|
||||
)
|
||||
observer.observe(titleSection)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
bar.removeAttribute('data-title-hidden')
|
||||
}
|
||||
}, [path, showEditor, showTitleSection, breadcrumbBarRef, titleSectionRef])
|
||||
}
|
||||
|
||||
export function useEditorContentModel(props: EditorContentProps) {
|
||||
const {
|
||||
activeTab,
|
||||
@@ -77,26 +120,13 @@ export function useEditorContentModel(props: EditorContentProps) {
|
||||
const titleSectionRef = useRef<HTMLDivElement | null>(null)
|
||||
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEditor) return
|
||||
|
||||
const bar = breadcrumbBarRef.current
|
||||
const titleSection = titleSectionRef.current
|
||||
if (!bar || !titleSection) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) bar.removeAttribute('data-title-hidden')
|
||||
else bar.setAttribute('data-title-hidden', '')
|
||||
},
|
||||
{ threshold: 0 },
|
||||
)
|
||||
observer.observe(titleSection)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
bar.removeAttribute('data-title-hidden')
|
||||
}
|
||||
}, [path, showEditor])
|
||||
useBreadcrumbTitleVisibility({
|
||||
showEditor,
|
||||
showTitleSection,
|
||||
path,
|
||||
breadcrumbBarRef,
|
||||
titleSectionRef,
|
||||
})
|
||||
|
||||
return {
|
||||
...props,
|
||||
|
||||
@@ -6,7 +6,12 @@ import { containsWikilinks } from '../DynamicPropertiesPanel'
|
||||
import type { FrontmatterValue } from '../Inspector'
|
||||
import { NoteSearchList } from '../NoteSearchList'
|
||||
import { useNoteSearch } from '../../hooks/useNoteSearch'
|
||||
import { resolveEntry } from '../../utils/wikilink'
|
||||
import {
|
||||
resolveEntry,
|
||||
canonicalWikilinkTargetForEntry,
|
||||
canonicalWikilinkTargetForTitle,
|
||||
formatWikilinkRef,
|
||||
} from '../../utils/wikilink'
|
||||
import { isWikilink, resolveRefProps } from './shared'
|
||||
import { LinkButton } from './LinkButton'
|
||||
|
||||
@@ -15,6 +20,61 @@ function hasExactTitleMatch(entries: VaultEntry[], title: string): boolean {
|
||||
return resolveEntry(entries, title) !== undefined
|
||||
}
|
||||
|
||||
function inferVaultPath(entries: VaultEntry[]): string {
|
||||
if (entries.length === 0) return ''
|
||||
const segments = entries.map((entry) => entry.path.split('/').slice(0, -1))
|
||||
const prefix: string[] = []
|
||||
const maxDepth = Math.min(...segments.map((parts) => parts.length))
|
||||
for (let i = 0; i < maxDepth; i += 1) {
|
||||
const segment = segments[0][i]
|
||||
if (segments.every((parts) => parts[i] === segment)) prefix.push(segment)
|
||||
else break
|
||||
}
|
||||
return prefix.join('/')
|
||||
}
|
||||
|
||||
function canonicalRefForEntry(entry: VaultEntry, vaultPath: string): string {
|
||||
return formatWikilinkRef(canonicalWikilinkTargetForEntry(entry, vaultPath))
|
||||
}
|
||||
|
||||
function canonicalRefForTitle(title: string, entries: VaultEntry[], vaultPath: string): string {
|
||||
return formatWikilinkRef(canonicalWikilinkTargetForTitle(title, entries, vaultPath))
|
||||
}
|
||||
|
||||
function shouldShowSearchDropdown(focused: boolean, trimmed: string, resultCount: number, showCreate: boolean): boolean {
|
||||
return focused && trimmed.length > 0 && (resultCount > 0 || showCreate)
|
||||
}
|
||||
|
||||
function confirmRelationshipSelection({
|
||||
showCreate,
|
||||
selectedIndex,
|
||||
createIndex,
|
||||
trimmed,
|
||||
selectedEntry,
|
||||
onCreate,
|
||||
onSelectEntry,
|
||||
onFallback,
|
||||
}: {
|
||||
showCreate: boolean
|
||||
selectedIndex: number
|
||||
createIndex: number
|
||||
trimmed: string
|
||||
selectedEntry?: VaultEntry
|
||||
onCreate?: (title: string) => void
|
||||
onSelectEntry?: (entry: VaultEntry) => void
|
||||
onFallback?: () => void
|
||||
}): void {
|
||||
if (showCreate && selectedIndex === createIndex && trimmed) {
|
||||
onCreate?.(trimmed)
|
||||
return
|
||||
}
|
||||
if (selectedEntry) {
|
||||
onSelectEntry?.(selectedEntry)
|
||||
return
|
||||
}
|
||||
onFallback?.()
|
||||
}
|
||||
|
||||
/** Shared keyboard navigation for search dropdowns with an optional "create" item. */
|
||||
function useSearchKeyboard(
|
||||
search: ReturnType<typeof useNoteSearch>,
|
||||
@@ -90,7 +150,7 @@ function CreateAndOpenOption({ title, selected, onClick, onHover }: {
|
||||
|
||||
function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAndOpen }: {
|
||||
search: ReturnType<typeof useNoteSearch>
|
||||
onSelect: (title: string) => void
|
||||
onSelect: (entry: VaultEntry) => void
|
||||
query: string
|
||||
entries: VaultEntry[]
|
||||
onCreateAndOpen?: (title: string) => void
|
||||
@@ -108,7 +168,7 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn
|
||||
items={search.results}
|
||||
selectedIndex={search.selectedIndex}
|
||||
getItemKey={(item) => item.entry.path}
|
||||
onItemClick={(item) => onSelect(item.entry.title)}
|
||||
onItemClick={(item) => onSelect(item.entry)}
|
||||
onItemHover={(i) => search.setSelectedIndex(i)}
|
||||
className="max-h-[160px] overflow-y-auto"
|
||||
/>
|
||||
@@ -125,11 +185,12 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn
|
||||
)
|
||||
}
|
||||
|
||||
function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
|
||||
entries: VaultEntry[]
|
||||
onAdd: (noteTitle: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
function useInlineAddNoteState(
|
||||
entries: VaultEntry[],
|
||||
vaultPath: string,
|
||||
onAdd: (ref: string) => void,
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>,
|
||||
) {
|
||||
const [active, setActive] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -138,25 +199,82 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
|
||||
const trimmed = query.trim()
|
||||
const { showCreate, createIndex, totalItems } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpenNote)
|
||||
|
||||
const dismiss = useCallback(() => { setQuery(''); setActive(false) }, [])
|
||||
const dismiss = useCallback(() => {
|
||||
setQuery('')
|
||||
setActive(false)
|
||||
}, [])
|
||||
|
||||
const selectAndClose = useCallback((title: string) => {
|
||||
onAdd(title)
|
||||
const selectAndClose = useCallback((ref: string) => {
|
||||
onAdd(ref)
|
||||
dismiss()
|
||||
}, [onAdd, dismiss])
|
||||
|
||||
const handleCreateAndOpen = useCreateAndOpen(onCreateAndOpenNote, onAdd, dismiss)
|
||||
const selectEntryAndClose = useCallback((entry: VaultEntry) => {
|
||||
selectAndClose(canonicalRefForEntry(entry, vaultPath))
|
||||
}, [selectAndClose, vaultPath])
|
||||
|
||||
const handleCreateAndOpen = useCreateAndOpen(
|
||||
onCreateAndOpenNote,
|
||||
(title) => onAdd(canonicalRefForTitle(title, entries, vaultPath)),
|
||||
dismiss,
|
||||
)
|
||||
|
||||
const handleFallback = useCallback(() => {
|
||||
if (!trimmed) return
|
||||
selectAndClose(canonicalRefForTitle(trimmed, entries, vaultPath))
|
||||
}, [trimmed, selectAndClose, entries, vaultPath])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (showCreate && search.selectedIndex === createIndex) {
|
||||
handleCreateAndOpen(trimmed)
|
||||
return
|
||||
}
|
||||
const title = search.selectedEntry?.title ?? trimmed
|
||||
if (title) selectAndClose(title)
|
||||
}, [search.selectedEntry, search.selectedIndex, trimmed, selectAndClose, showCreate, createIndex, handleCreateAndOpen])
|
||||
confirmRelationshipSelection({
|
||||
showCreate,
|
||||
selectedIndex: search.selectedIndex,
|
||||
createIndex,
|
||||
trimmed,
|
||||
selectedEntry: search.selectedEntry,
|
||||
onCreate: handleCreateAndOpen,
|
||||
onSelectEntry: selectEntryAndClose,
|
||||
onFallback: handleFallback,
|
||||
})
|
||||
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, handleCreateAndOpen, selectEntryAndClose, handleFallback])
|
||||
|
||||
const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, dismiss)
|
||||
const showDropdown = shouldShowSearchDropdown(active, trimmed, search.results.length, showCreate)
|
||||
|
||||
return {
|
||||
active,
|
||||
setActive,
|
||||
query,
|
||||
setQuery,
|
||||
inputRef,
|
||||
search,
|
||||
dismiss,
|
||||
handleKeyDown,
|
||||
showDropdown,
|
||||
selectEntryAndClose,
|
||||
showCreate,
|
||||
handleCreateAndOpen,
|
||||
}
|
||||
}
|
||||
|
||||
function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: {
|
||||
entries: VaultEntry[]
|
||||
vaultPath: string
|
||||
onAdd: (ref: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
const {
|
||||
active,
|
||||
setActive,
|
||||
query,
|
||||
setQuery,
|
||||
inputRef,
|
||||
search,
|
||||
dismiss,
|
||||
handleKeyDown,
|
||||
showDropdown,
|
||||
selectEntryAndClose,
|
||||
handleCreateAndOpen,
|
||||
} = useInlineAddNoteState(entries, vaultPath, onAdd, onCreateAndOpenNote)
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
@@ -171,8 +289,6 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
|
||||
)
|
||||
}
|
||||
|
||||
const showDropdown = trimmed.length > 0 && (search.results.length > 0 || showCreate)
|
||||
|
||||
return (
|
||||
<div className="relative mt-1">
|
||||
<div className="group/add relative flex items-center">
|
||||
@@ -197,7 +313,7 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
|
||||
{showDropdown && (
|
||||
<SearchDropdownWithCreate
|
||||
search={search}
|
||||
onSelect={selectAndClose}
|
||||
onSelect={selectEntryAndClose}
|
||||
query={query}
|
||||
entries={entries}
|
||||
onCreateAndOpen={onCreateAndOpenNote ? (title) => { handleCreateAndOpen(title) } : undefined}
|
||||
@@ -207,11 +323,11 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
|
||||
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
|
||||
function RelationshipGroup({ label, refs, entries, typeEntryMap, vaultPath, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
|
||||
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath: string
|
||||
onNavigate: (target: string) => void
|
||||
onRemoveRef?: (ref: string) => void
|
||||
onAddRef?: (noteTitle: string) => void
|
||||
onAddRef?: (ref: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
if (refs.length === 0) return null
|
||||
@@ -234,6 +350,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
|
||||
{onAddRef && (
|
||||
<InlineAddNote
|
||||
entries={entries}
|
||||
vaultPath={vaultPath}
|
||||
onAdd={onAddRef}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
@@ -269,22 +386,30 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
|
||||
const trimmed = value.trim()
|
||||
const { showCreate, createIndex, totalItems } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpenNote)
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (showCreate && search.selectedIndex === createIndex) {
|
||||
onSubmitWithCreate?.(trimmed)
|
||||
} else if (search.selectedEntry) {
|
||||
onChange(search.selectedEntry.title)
|
||||
setFocused(false)
|
||||
} else {
|
||||
onSubmit?.()
|
||||
}
|
||||
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, onChange, onSubmit, onSubmitWithCreate])
|
||||
const selectEntry = useCallback((entry: VaultEntry) => {
|
||||
onChange(entry.title)
|
||||
setFocused(false)
|
||||
}, [onChange])
|
||||
|
||||
const handleEscape = useCallback(() => { onCancel?.() }, [onCancel])
|
||||
const handleConfirm = useCallback(() => {
|
||||
confirmRelationshipSelection({
|
||||
showCreate,
|
||||
selectedIndex: search.selectedIndex,
|
||||
createIndex,
|
||||
trimmed,
|
||||
selectedEntry: search.selectedEntry,
|
||||
onCreate: onSubmitWithCreate,
|
||||
onSelectEntry: selectEntry,
|
||||
onFallback: onSubmit,
|
||||
})
|
||||
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, onSubmitWithCreate, selectEntry, onSubmit])
|
||||
|
||||
const handleEscape = useCallback(() => {
|
||||
onCancel?.()
|
||||
}, [onCancel])
|
||||
|
||||
const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, handleEscape)
|
||||
|
||||
const showDropdown = focused && trimmed.length > 0 && (search.results.length > 0 || showCreate)
|
||||
const showDropdown = shouldShowSearchDropdown(focused, trimmed, search.results.length, showCreate)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -301,7 +426,7 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
|
||||
{showDropdown && (
|
||||
<SearchDropdownWithCreate
|
||||
search={search}
|
||||
onSelect={(title) => { onChange(title); setFocused(false) }}
|
||||
onSelect={selectEntry}
|
||||
query={value}
|
||||
entries={entries}
|
||||
onCreateAndOpen={onCreateAndOpenNote ? (title) => onSubmitWithCreate?.(title) : undefined}
|
||||
@@ -311,8 +436,56 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
|
||||
)
|
||||
}
|
||||
|
||||
function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
|
||||
function useRelationshipPanelState(
|
||||
frontmatter: ParsedFrontmatter,
|
||||
entries: VaultEntry[],
|
||||
vaultPath: string | undefined,
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void,
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void,
|
||||
onDeleteProperty?: (key: string) => void,
|
||||
) {
|
||||
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
|
||||
const resolvedVaultPath = useMemo(() => vaultPath ?? inferVaultPath(entries), [vaultPath, entries])
|
||||
|
||||
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
|
||||
if (!onUpdateProperty || !onDeleteProperty) return
|
||||
const group = relationshipEntries.find(g => g.key === key)
|
||||
if (!group) return
|
||||
const result = updateRefsForRemoval(group.refs, refToRemove)
|
||||
if (result === null) onDeleteProperty(key)
|
||||
else onUpdateProperty(key, result)
|
||||
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
|
||||
|
||||
const handleAddRef = useCallback((key: string, ref: string) => {
|
||||
if (!onUpdateProperty) return
|
||||
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
|
||||
const result = updateRefsForAddition(existing, ref)
|
||||
if (result !== false) onUpdateProperty(key, result)
|
||||
}, [relationshipEntries, onUpdateProperty])
|
||||
|
||||
const canEdit = !!onUpdateProperty && !!onDeleteProperty
|
||||
const existingRelKeys = useMemo(
|
||||
() => new Set(relationshipEntries.map(g => g.key.toLowerCase())),
|
||||
[relationshipEntries],
|
||||
)
|
||||
const missingSuggestedRels = useMemo(
|
||||
() => (onAddProperty ? SUGGESTED_RELATIONSHIPS.filter(r => !existingRelKeys.has(r.toLowerCase())) : []),
|
||||
[onAddProperty, existingRelKeys],
|
||||
)
|
||||
|
||||
return {
|
||||
relationshipEntries,
|
||||
resolvedVaultPath,
|
||||
handleRemoveRef,
|
||||
handleAddRef,
|
||||
canEdit,
|
||||
missingSuggestedRels,
|
||||
}
|
||||
}
|
||||
|
||||
function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpenNote }: {
|
||||
entries: VaultEntry[]
|
||||
vaultPath: string
|
||||
onAddProperty: (key: string, value: FrontmatterValue) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
@@ -327,16 +500,16 @@ function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
|
||||
|
||||
const submitForm = useCallback((targetOverride?: string) => {
|
||||
const key = relKey.trim()
|
||||
const target = (targetOverride ?? relTarget).trim()
|
||||
if (!key || !target) return
|
||||
onAddProperty(key, `[[${target}]]`)
|
||||
const rawTarget = (targetOverride ?? relTarget).trim()
|
||||
if (!key || !rawTarget) return
|
||||
onAddProperty(key, canonicalRefForTitle(rawTarget, entries, vaultPath))
|
||||
resetForm()
|
||||
}, [relKey, relTarget, onAddProperty, resetForm])
|
||||
}, [relKey, relTarget, entries, vaultPath, onAddProperty, resetForm])
|
||||
|
||||
const addPropertyForKey = useCallback((title: string) => {
|
||||
const key = relKey.trim()
|
||||
if (key) onAddProperty(key, `[[${title}]]`)
|
||||
}, [relKey, onAddProperty])
|
||||
if (key) onAddProperty(key, canonicalRefForTitle(title, entries, vaultPath))
|
||||
}, [relKey, entries, vaultPath, onAddProperty])
|
||||
|
||||
const handleCreateAndSubmit = useCreateAndOpen(onCreateAndOpenNote, addPropertyForKey, resetForm)
|
||||
|
||||
@@ -381,10 +554,9 @@ function updateRefsForRemoval(refs: string[], refToRemove: string): FrontmatterV
|
||||
return remaining.length === 1 ? remaining[0] : remaining
|
||||
}
|
||||
|
||||
function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterValue | false {
|
||||
const newRef = `[[${noteTitle}]]`
|
||||
if (refs.includes(newRef)) return false
|
||||
const updated = [...refs, newRef]
|
||||
function updateRefsForAddition(refs: string[], refToAdd: string): FrontmatterValue | false {
|
||||
if (refs.includes(refToAdd)) return false
|
||||
const updated = [...refs, refToAdd]
|
||||
return updated.length === 1 ? updated[0] : updated
|
||||
}
|
||||
|
||||
@@ -396,10 +568,11 @@ function DisabledLinkButton() {
|
||||
|
||||
const SUGGESTED_RELATIONSHIPS = ['Belongs to', 'Related to', 'Has'] as const
|
||||
|
||||
function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote }: {
|
||||
function SuggestedRelationshipSlot({ label, entries, vaultPath, onAdd, onCreateAndOpenNote }: {
|
||||
label: string
|
||||
entries: VaultEntry[]
|
||||
onAdd: (noteTitle: string) => void
|
||||
vaultPath: string
|
||||
onAdd: (ref: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
return (
|
||||
@@ -407,6 +580,7 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote
|
||||
<span className="mb-1 block text-[12px] text-muted-foreground/50">{label}</span>
|
||||
<InlineAddNote
|
||||
entries={entries}
|
||||
vaultPath={vaultPath}
|
||||
onAdd={onAdd}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
@@ -414,50 +588,30 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote
|
||||
)
|
||||
}
|
||||
|
||||
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
|
||||
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
|
||||
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
|
||||
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath?: string
|
||||
onNavigate: (target: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
|
||||
|
||||
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
|
||||
if (!onUpdateProperty || !onDeleteProperty) return
|
||||
const group = relationshipEntries.find(g => g.key === key)
|
||||
if (!group) return
|
||||
const result = updateRefsForRemoval(group.refs, refToRemove)
|
||||
if (result === null) onDeleteProperty(key)
|
||||
else onUpdateProperty(key, result)
|
||||
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
|
||||
|
||||
const handleAddRef = useCallback((key: string, noteTitle: string) => {
|
||||
if (!onUpdateProperty) return
|
||||
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
|
||||
const result = updateRefsForAddition(existing, noteTitle)
|
||||
if (result !== false) onUpdateProperty(key, result)
|
||||
}, [relationshipEntries, onUpdateProperty])
|
||||
|
||||
const canEdit = !!onUpdateProperty && !!onDeleteProperty
|
||||
|
||||
const existingRelKeys = useMemo(
|
||||
() => new Set(relationshipEntries.map(g => g.key.toLowerCase())),
|
||||
[relationshipEntries],
|
||||
)
|
||||
|
||||
const missingSuggestedRels = onAddProperty
|
||||
? SUGGESTED_RELATIONSHIPS.filter(r => !existingRelKeys.has(r.toLowerCase()))
|
||||
: []
|
||||
const {
|
||||
relationshipEntries,
|
||||
resolvedVaultPath,
|
||||
handleRemoveRef,
|
||||
handleAddRef,
|
||||
canEdit,
|
||||
missingSuggestedRels,
|
||||
} = useRelationshipPanelState(frontmatter, entries, vaultPath, onAddProperty, onUpdateProperty, onDeleteProperty)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{relationshipEntries.map(({ key, refs }) => (
|
||||
<RelationshipGroup
|
||||
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
|
||||
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} vaultPath={resolvedVaultPath} onNavigate={onNavigate}
|
||||
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
|
||||
onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined}
|
||||
onAddRef={canEdit ? (ref) => handleAddRef(key, ref) : undefined}
|
||||
onCreateAndOpenNote={canEdit ? onCreateAndOpenNote : undefined}
|
||||
/>
|
||||
))}
|
||||
@@ -466,12 +620,13 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
|
||||
key={label}
|
||||
label={label}
|
||||
entries={entries}
|
||||
onAdd={(noteTitle) => onAddProperty!(label, `[[${noteTitle}]]`)}
|
||||
vaultPath={resolvedVaultPath}
|
||||
onAdd={(ref) => onAddProperty!(label, ref)}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
))}
|
||||
{onAddProperty
|
||||
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
|
||||
? <AddRelationshipForm entries={entries} vaultPath={resolvedVaultPath} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
|
||||
: <DisabledLinkButton />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,10 @@ function targetMatchesEntry(target: string, entryPath: string, matchTargets: Set
|
||||
return false
|
||||
}
|
||||
|
||||
function refsMatchTargets(refs: string[], targets: Set<string>): boolean {
|
||||
function refsMatchTargets(refs: string[], entryPath: string, targets: Set<string>): boolean {
|
||||
return refs.some((ref) => {
|
||||
const target = wikilinkTarget(ref)
|
||||
return targets.has(target) || targets.has(target.split('/').pop() ?? '')
|
||||
return targetMatchesEntry(target, entryPath, targets)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[])
|
||||
for (const other of entries) {
|
||||
if (other.path === entry.path) continue
|
||||
for (const [key, refs] of Object.entries(other.relationships)) {
|
||||
if (key !== 'Type' && refsMatchTargets(refs, matchTargets)) {
|
||||
if (key !== 'Type' && refsMatchTargets(refs, entry.path, matchTargets)) {
|
||||
results.push({ entry: other, viaKey: key })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Link } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { resolveNoteIcon } from '../../utils/noteIcon'
|
||||
import { detectPropertyType } from '../../utils/propertyTypes'
|
||||
import { getMappedStatusStyle } from '../../utils/statusStyles'
|
||||
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
|
||||
import { isUrlValue, normalizeUrl, openExternalUrl } from '../../utils/url'
|
||||
import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../../utils/wikilink'
|
||||
@@ -14,7 +16,7 @@ interface PropertyChipValue {
|
||||
typeIcon: ComponentType<SVGAttributes<SVGSVGElement>> | null
|
||||
style?: CSSProperties
|
||||
action?: { kind: 'note'; entry: VaultEntry } | { kind: 'url'; url: string }
|
||||
tone: 'neutral' | 'relationship' | 'url'
|
||||
tone: 'neutral' | 'relationship' | 'status' | 'url'
|
||||
}
|
||||
|
||||
const URL_CHIP_STYLE: CSSProperties = {
|
||||
@@ -22,6 +24,8 @@ const URL_CHIP_STYLE: CSSProperties = {
|
||||
color: 'var(--accent-blue)',
|
||||
}
|
||||
|
||||
type ChipScalarValue = string | number | boolean | null
|
||||
|
||||
function toChipTestId(propName: string, index: number): string {
|
||||
const slug = propName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
return `property-chip-${slug || 'value'}-${index}`
|
||||
@@ -115,6 +119,29 @@ function resolveScalarChip(value: unknown): PropertyChipValue | null {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusChip(value: ChipScalarValue): PropertyChipValue | null {
|
||||
const label = formatChipLabel(value)
|
||||
if (!label) return null
|
||||
|
||||
const status = String(value)
|
||||
const style = getMappedStatusStyle(status)
|
||||
return {
|
||||
label: `• ${label}`,
|
||||
noteIcon: null,
|
||||
typeIcon: null,
|
||||
style: style ? { backgroundColor: style.bg, color: style.color } : undefined,
|
||||
tone: 'status',
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePropertyValueChip(propName: string, value: ChipScalarValue | undefined): PropertyChipValue | null {
|
||||
if (value === undefined) return null
|
||||
if (detectPropertyType(propName, value) !== 'status') {
|
||||
return resolveScalarChip(value)
|
||||
}
|
||||
return resolveStatusChip(value)
|
||||
}
|
||||
|
||||
function resolveRelationshipChipValues(
|
||||
entry: VaultEntry,
|
||||
propName: string,
|
||||
@@ -135,7 +162,7 @@ function resolveScalarChipValues(entry: VaultEntry, propName: string): PropertyC
|
||||
const rawValue = entry.properties[propertyKey]
|
||||
const values = Array.isArray(rawValue) ? rawValue : [rawValue]
|
||||
return values
|
||||
.map((value) => resolveScalarChip(value))
|
||||
.map((value) => resolvePropertyValueChip(propertyKey, value))
|
||||
.filter((chip): chip is PropertyChipValue => chip !== null)
|
||||
}
|
||||
|
||||
@@ -146,7 +173,7 @@ function resolvePropertyChipValues(
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): PropertyChipValue[] {
|
||||
if (propName.toLowerCase() === 'status') {
|
||||
const statusChip = resolveScalarChip(entry.status)
|
||||
const statusChip = resolvePropertyValueChip(propName, entry.status)
|
||||
return statusChip ? [statusChip] : []
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -360,7 +361,7 @@ const FAVORITE_TYPE_ICON_MAP: Record<string, string> = {
|
||||
|
||||
function getFavoriteIcon(entry: VaultEntry, typeEntryMap: Record<string, VaultEntry>) {
|
||||
const typeEntry = entry.isA ? typeEntryMap[entry.isA] : undefined
|
||||
return typeEntry?.icon ?? FAVORITE_TYPE_ICON_MAP[entry.isA ?? ''] ?? 'file-text'
|
||||
return entry.icon ?? typeEntry?.icon ?? FAVORITE_TYPE_ICON_MAP[entry.isA ?? ''] ?? 'file-text'
|
||||
}
|
||||
|
||||
function SortableFavoriteItem({
|
||||
|
||||
393
src/hooks/appCommandCatalog.ts
Normal 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
|
||||
}
|
||||
153
src/hooks/appCommandDispatcher.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
179
src/hooks/appCommandDispatcher.ts
Normal 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)
|
||||
}
|
||||
@@ -1,43 +1,41 @@
|
||||
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>
|
||||
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 VIEW_MODE_KEYS: Record<string, ViewMode> = {
|
||||
'1': 'editor-only',
|
||||
'2': 'editor-list',
|
||||
'3': 'all',
|
||||
}
|
||||
|
||||
function isTextInputFocused(): boolean {
|
||||
const active = document.activeElement
|
||||
if (!(active instanceof HTMLElement)) return false
|
||||
@@ -45,104 +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 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 (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)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,10 @@ function makeActions() {
|
||||
}
|
||||
|
||||
describe('useAppKeyboard', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
afterEach(() => {
|
||||
delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('Cmd+1 sets view mode to editor-only', () => {
|
||||
const actions = makeActions()
|
||||
@@ -92,6 +95,41 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onCreateNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+N defers to native menu in Tauri', () => {
|
||||
const actions = makeActions()
|
||||
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('n', { metaKey: true })
|
||||
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()
|
||||
@@ -100,6 +138,15 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
|
||||
})
|
||||
|
||||
it('Cmd+D still uses JS shortcut in Tauri when no native menu owns it', () => {
|
||||
const actions = makeActions()
|
||||
actions.onToggleFavorite = vi.fn()
|
||||
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('d', { metaKey: true })
|
||||
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
|
||||
})
|
||||
|
||||
it('Cmd+E triggers toggle organized on active note, not archive', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
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'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
isTauri: vi.fn(() => false),
|
||||
mockInvoke: vi.fn().mockResolvedValue(undefined),
|
||||
updateMockContent: vi.fn(),
|
||||
}))
|
||||
|
||||
function makeEntry(path: string, title = 'Test', filename = 'test.md'): VaultEntry {
|
||||
@@ -19,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(),
|
||||
@@ -32,12 +37,18 @@ describe('useAppSave', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
deps.unsavedPaths = new Set()
|
||||
deps.tabs = []
|
||||
deps.activeTabPath = null
|
||||
deps.handleRenameNote.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function renderSave(overrides = {}) {
|
||||
return renderHook(() => useAppSave({ ...deps, ...overrides }))
|
||||
}
|
||||
@@ -91,4 +102,123 @@ describe('useAppSave', () => {
|
||||
const { result } = renderSave()
|
||||
expect(typeof result.current.handleContentChange).toBe('function')
|
||||
})
|
||||
|
||||
it('debounces untitled H1 auto-rename until the user pauses typing', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
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: '/vault/fresh-title.md', updated_files: 0 }
|
||||
if (command === 'reload_vault_entry') return makeEntry('/vault/fresh-title.md', 'Fresh Title', 'fresh-title.md')
|
||||
if (command === 'get_note_content' && args?.path === '/vault/fresh-title.md') return '# Fresh Title\n\nBody'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const entry = makeEntry('/vault/untitled-note-123.md', 'Untitled Note 123', 'untitled-note-123.md')
|
||||
const tabs = [{ entry, content: '# Fresh Title\n\nBody' }]
|
||||
const { result } = renderSave({
|
||||
tabs,
|
||||
activeTabPath: entry.path,
|
||||
unsavedPaths: new Set([entry.path]),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
})
|
||||
|
||||
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything())
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2_499)
|
||||
})
|
||||
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything())
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
})
|
||||
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('auto_rename_untitled', {
|
||||
vaultPath: '/vault',
|
||||
notePath: entry.path,
|
||||
})
|
||||
expect(deps.replaceEntry).toHaveBeenCalledWith(
|
||||
entry.path,
|
||||
expect.objectContaining({ path: '/vault/fresh-title.md', filename: 'fresh-title.md' }),
|
||||
'# Fresh Title\n\nBody',
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
vi.mocked(invoke).mockImplementation(async (command: string) => {
|
||||
if (command === 'save_note_content') return undefined
|
||||
if (command === 'auto_rename_untitled') return { new_path: '/vault/fresh-title.md', updated_files: 0 }
|
||||
return undefined
|
||||
})
|
||||
|
||||
const entry = makeEntry('/vault/untitled-note-123.md', 'Untitled Note 123', 'untitled-note-123.md')
|
||||
const tabs = [{ entry, content: '# Fresh Title\n\nBody' }]
|
||||
const { result, rerender } = renderHook(
|
||||
({ currentActiveTabPath }: { currentActiveTabPath: string | null }) => useAppSave({
|
||||
...deps,
|
||||
tabs,
|
||||
activeTabPath: currentActiveTabPath,
|
||||
unsavedPaths: new Set([entry.path]),
|
||||
}),
|
||||
{ initialProps: { currentActiveTabPath: entry.path } },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
})
|
||||
|
||||
rerender({ currentActiveTabPath: '/vault/other.md' })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2_500)
|
||||
})
|
||||
|
||||
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
|
||||
import { needsRenameOnSave } from './useNoteRename'
|
||||
import { flushEditorContent } from '../utils/autoSave'
|
||||
import { extractH1TitleFromContent } from '../utils/noteTitle'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
@@ -11,6 +12,13 @@ interface TabState {
|
||||
content: string
|
||||
}
|
||||
|
||||
const UNTITLED_RENAME_DEBOUNCE_MS = 2500
|
||||
|
||||
interface PendingUntitledRename {
|
||||
path: string
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
function findUnsavedFallback(
|
||||
tabs: TabState[], activeTabPath: string | null, unsavedPaths: Set<string>,
|
||||
): { path: string; content: string } | undefined {
|
||||
@@ -27,9 +35,99 @@ function activeTabNeedsRename(tabs: TabState[], activeTabPath: string | null): {
|
||||
: null
|
||||
}
|
||||
|
||||
function isUntitledRenameCandidate(path: string): boolean {
|
||||
const filename = path.split('/').pop() ?? ''
|
||||
const stem = filename.replace(/\.md$/, '')
|
||||
return stem.startsWith('untitled-') && /\d+$/.test(stem)
|
||||
}
|
||||
|
||||
function shouldScheduleUntitledRename(path: string, content: string): boolean {
|
||||
return isTauri()
|
||||
&& isUntitledRenameCandidate(path)
|
||||
&& extractH1TitleFromContent(content) !== null
|
||||
}
|
||||
|
||||
function matchingPendingRename(
|
||||
pending: PendingUntitledRename | null,
|
||||
path?: string,
|
||||
): PendingUntitledRename | null {
|
||||
if (!pending) return null
|
||||
if (path && pending.path !== path) return null
|
||||
return pending
|
||||
}
|
||||
|
||||
function takePendingRename(
|
||||
pendingRenameRef: MutableRefObject<PendingUntitledRename | null>,
|
||||
path?: string,
|
||||
): PendingUntitledRename | null {
|
||||
const pending = matchingPendingRename(pendingRenameRef.current, path)
|
||||
if (!pending) return null
|
||||
clearTimeout(pending.timer)
|
||||
pendingRenameRef.current = null
|
||||
return pending
|
||||
}
|
||||
|
||||
function schedulePendingRename(
|
||||
pendingRenameRef: MutableRefObject<PendingUntitledRename | null>,
|
||||
path: string,
|
||||
onFire: (path: string) => void,
|
||||
): void {
|
||||
takePendingRename(pendingRenameRef)
|
||||
const timer = setTimeout(() => {
|
||||
const pending = takePendingRename(pendingRenameRef, path)
|
||||
if (pending) onFire(pending.path)
|
||||
}, UNTITLED_RENAME_DEBOUNCE_MS)
|
||||
pendingRenameRef.current = { path, timer }
|
||||
}
|
||||
|
||||
function pendingRenameOutsideActiveTab(
|
||||
pendingRenameRef: MutableRefObject<PendingUntitledRename | null>,
|
||||
activeTabPath: string | null,
|
||||
): string | null {
|
||||
const pending = pendingRenameRef.current
|
||||
if (!pending || pending.path === activeTabPath) return null
|
||||
return pending.path
|
||||
}
|
||||
|
||||
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> {
|
||||
const [newEntry, newContent] = await Promise.all([
|
||||
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>
|
||||
@@ -43,47 +141,78 @@ interface AppSaveDeps {
|
||||
}
|
||||
|
||||
export function useAppSave({
|
||||
updateEntry, setTabs, setToastMessage,
|
||||
updateEntry, setTabs, handleSwitchTab, setToastMessage,
|
||||
loadModifiedFiles, reloadViews, clearUnsaved, unsavedPaths,
|
||||
tabs, activeTabPath,
|
||||
handleRenameNote, replaceEntry, resolvedPath,
|
||||
}: AppSaveDeps) {
|
||||
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
|
||||
const pendingUntitledRenameRef = useRef<PendingUntitledRename | null>(null)
|
||||
|
||||
const onAfterSave = useCallback(() => {
|
||||
loadModifiedFiles()
|
||||
}, [loadModifiedFiles])
|
||||
|
||||
const onNotePersisted = useCallback((path: string) => {
|
||||
clearUnsaved(path)
|
||||
if (path.endsWith('.yml')) reloadViews?.()
|
||||
// Auto-rename untitled notes when they have an H1 heading
|
||||
const filename = path.split('/').pop() ?? ''
|
||||
const stem = filename.replace(/\.md$/, '')
|
||||
if (isTauri() && stem.startsWith('untitled-') && /\d+$/.test(stem)) {
|
||||
invoke<{ new_path: string; updated_files: number } | null>('auto_rename_untitled', {
|
||||
const cancelPendingUntitledRename = useCallback((path?: string) => (
|
||||
takePendingRename(pendingUntitledRenameRef, path) !== null
|
||||
), [])
|
||||
|
||||
const executeUntitledRename = useCallback(async (path: string) => {
|
||||
try {
|
||||
const result = await invoke<{ new_path: string; updated_files: number } | null>('auto_rename_untitled', {
|
||||
vaultPath: resolvedPath,
|
||||
notePath: path,
|
||||
}).then((result) => {
|
||||
if (result) {
|
||||
// Re-read the renamed file content and entry, then replace
|
||||
Promise.all([
|
||||
invoke<VaultEntry>('reload_vault_entry', { path: result.new_path }),
|
||||
invoke<string>('get_note_content', { path: result.new_path }),
|
||||
]).then(([newEntry, newContent]) => {
|
||||
replaceEntry(path, { ...newEntry, path: result.new_path }, newContent)
|
||||
loadModifiedFiles()
|
||||
}).catch(() => { /* ignore reload failure */ })
|
||||
}
|
||||
}).catch(() => { /* auto-rename is best-effort */ })
|
||||
})
|
||||
if (!result) return false
|
||||
await reloadAutoRenamedNote(
|
||||
path,
|
||||
result.new_path,
|
||||
tabs,
|
||||
activeTabPath,
|
||||
setTabs,
|
||||
handleSwitchTab,
|
||||
replaceEntry,
|
||||
loadModifiedFiles,
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [clearUnsaved, reloadViews, resolvedPath, replaceEntry, loadModifiedFiles])
|
||||
}, [resolvedPath, tabs, activeTabPath, setTabs, handleSwitchTab, replaceEntry, loadModifiedFiles])
|
||||
|
||||
const flushPendingUntitledRename = useCallback(async (path?: string) => {
|
||||
const pending = takePendingRename(pendingUntitledRenameRef, path)
|
||||
if (!pending) return false
|
||||
return executeUntitledRename(pending.path)
|
||||
}, [executeUntitledRename])
|
||||
|
||||
const scheduleUntitledRename = useCallback((path: string, content: string) => {
|
||||
if (!shouldScheduleUntitledRename(path, content)) {
|
||||
cancelPendingUntitledRename(path)
|
||||
return
|
||||
}
|
||||
|
||||
schedulePendingRename(pendingUntitledRenameRef, path, (pendingPath) => {
|
||||
void executeUntitledRename(pendingPath)
|
||||
})
|
||||
}, [cancelPendingUntitledRename, executeUntitledRename])
|
||||
|
||||
const onNotePersisted = useCallback((path: string, content: string) => {
|
||||
clearUnsaved(path)
|
||||
if (path.endsWith('.yml')) reloadViews?.()
|
||||
scheduleUntitledRename(path, content)
|
||||
}, [clearUnsaved, reloadViews, scheduleUntitledRename])
|
||||
|
||||
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
|
||||
updateEntry, setTabs, setToastMessage, onAfterSave, onNotePersisted,
|
||||
})
|
||||
|
||||
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
|
||||
useEffect(() => () => { cancelPendingUntitledRename() }, [cancelPendingUntitledRename])
|
||||
useEffect(() => {
|
||||
const pendingPath = pendingRenameOutsideActiveTab(pendingUntitledRenameRef, activeTabPath)
|
||||
if (pendingPath) cancelPendingUntitledRename(pendingPath)
|
||||
}, [activeTabPath, cancelPendingUntitledRename])
|
||||
|
||||
// Refs for stable closure in flushBeforeAction
|
||||
const tabsRef = useRef(tabs)
|
||||
@@ -99,29 +228,33 @@ export function useAppSave({
|
||||
isUnsaved: (p) => unsavedPathsRef.current.has(p),
|
||||
onSaved: (p) => { clearUnsaved(p) },
|
||||
})
|
||||
await flushPendingUntitledRename(path)
|
||||
} catch (err) {
|
||||
setToastMessage(`Auto-save failed: ${err}`)
|
||||
throw err
|
||||
}
|
||||
}, [savePendingForPath, clearUnsaved, setToastMessage])
|
||||
}, [savePendingForPath, clearUnsaved, setToastMessage, flushPendingUntitledRename])
|
||||
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
cancelPendingUntitledRename(path)
|
||||
await handleRenameNote(path, newTitle, resolvedPath, replaceEntry).then(loadModifiedFiles)
|
||||
}, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles])
|
||||
}, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles, cancelPendingUntitledRename])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
await handleSaveRaw(findUnsavedFallback(tabs, activeTabPath, unsavedPaths))
|
||||
const flushedUntitledRename = await flushPendingUntitledRename(activeTabPath ?? undefined)
|
||||
const rename = activeTabNeedsRename(tabs, activeTabPath)
|
||||
if (rename) await handleRenameTab(rename.path, rename.title)
|
||||
}, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths])
|
||||
if (!flushedUntitledRename && rename) await handleRenameTab(rename.path, rename.title)
|
||||
}, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename])
|
||||
|
||||
const handleTitleSync = useCallback((path: string, newTitle: string) => {
|
||||
cancelPendingUntitledRename(path)
|
||||
savePendingForPath(path)
|
||||
.then(() => handleRenameNote(path, newTitle, resolvedPath, replaceEntry))
|
||||
.then(loadModifiedFiles)
|
||||
.catch((err) => console.error('Title rename failed:', err))
|
||||
}, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles])
|
||||
}, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles, cancelPendingUntitledRename])
|
||||
|
||||
return {
|
||||
contentChangeRef,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -566,7 +570,7 @@ describe('useNoteActions hook', () => {
|
||||
new_title: 'Sprint Retro',
|
||||
old_title: 'Weekly Review',
|
||||
}))
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 2 wiki links')
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Updated 2 notes')
|
||||
})
|
||||
|
||||
it('handleRenameNote passes null old_title when entry not found', async () => {
|
||||
|
||||
@@ -148,5 +148,6 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleRenameNote: rename.handleRenameNote,
|
||||
handleRenameFilename: rename.handleRenameFilename,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') })
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -66,11 +66,11 @@ describe('renameToastMessage', () => {
|
||||
})
|
||||
|
||||
it('returns singular when 1 file updated', () => {
|
||||
expect(renameToastMessage(1)).toBe('Renamed — updated 1 wiki link')
|
||||
expect(renameToastMessage(1)).toBe('Updated 1 note')
|
||||
})
|
||||
|
||||
it('returns plural when multiple files updated', () => {
|
||||
expect(renameToastMessage(3)).toBe('Renamed — updated 3 wiki links')
|
||||
expect(renameToastMessage(3)).toBe('Updated 3 notes')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('useNoteRename hook', () => {
|
||||
new_title: 'New',
|
||||
old_title: 'Old',
|
||||
}))
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 2 wiki links')
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Updated 2 notes')
|
||||
expect(onEntryRenamed).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -167,4 +167,53 @@ describe('useNoteRename hook', () => {
|
||||
|
||||
expect(handleSwitchTab).toHaveBeenCalledWith('/vault/new.md')
|
||||
})
|
||||
|
||||
it('handleRenameFilename renames the file while preserving the existing title', async () => {
|
||||
const entry = makeEntry({ path: '/vault/old-name.md', filename: 'old-name.md', title: 'Project Kickoff' })
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'rename_note_filename') return { new_path: '/vault/manual-name.md', updated_files: 1 }
|
||||
if (cmd === 'get_note_content') return '# Project Kickoff\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteRename(
|
||||
{ entries: [entry], setToastMessage },
|
||||
{ tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
|
||||
))
|
||||
|
||||
const onEntryRenamed = vi.fn()
|
||||
await act(async () => {
|
||||
await result.current.handleRenameFilename('/vault/old-name.md', 'manual-name', '/vault', onEntryRenamed)
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('rename_note_filename', expect.objectContaining({
|
||||
old_path: '/vault/old-name.md',
|
||||
new_filename_stem: 'manual-name',
|
||||
}))
|
||||
expect(onEntryRenamed).toHaveBeenCalledWith(
|
||||
'/vault/old-name.md',
|
||||
expect.objectContaining({
|
||||
path: '/vault/manual-name.md',
|
||||
filename: 'manual-name.md',
|
||||
title: 'Project Kickoff',
|
||||
}),
|
||||
'# Project Kickoff\n',
|
||||
)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Updated 1 note')
|
||||
})
|
||||
|
||||
it('handleRenameFilename surfaces backend conflict errors', async () => {
|
||||
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('A note with that name already exists'))
|
||||
|
||||
const { result } = renderHook(() => useNoteRename(
|
||||
{ entries: [makeEntry({ path: '/vault/old-name.md', filename: 'old-name.md' })], setToastMessage },
|
||||
{ tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
|
||||
))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameFilename('/vault/old-name.md', 'manual-name', '/vault', vi.fn())
|
||||
})
|
||||
|
||||
expect(setToastMessage).toHaveBeenCalledWith('A note with that name already exists')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,11 +28,35 @@ export async function performRename(
|
||||
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle, old_title: oldTitle ?? null })
|
||||
}
|
||||
|
||||
export async function performFilenameRename(
|
||||
path: string,
|
||||
newFilenameStem: string,
|
||||
vaultPath: string,
|
||||
): Promise<RenameResult> {
|
||||
if (isTauri()) {
|
||||
return invoke<RenameResult>('rename_note_filename', {
|
||||
vaultPath,
|
||||
oldPath: path,
|
||||
newFilenameStem,
|
||||
})
|
||||
}
|
||||
return mockInvoke<RenameResult>('rename_note_filename', {
|
||||
vault_path: vaultPath,
|
||||
old_path: path,
|
||||
new_filename_stem: newFilenameStem,
|
||||
})
|
||||
}
|
||||
|
||||
export function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry {
|
||||
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
return { ...entry, path: newPath, filename: `${slug}.md`, title: newTitle }
|
||||
}
|
||||
|
||||
export function buildFilenameRenamedEntry(entry: VaultEntry, newPath: string): VaultEntry {
|
||||
const filename = newPath.split('/').pop() ?? entry.filename
|
||||
return { ...entry, path: newPath, filename }
|
||||
}
|
||||
|
||||
export async function loadNoteContent(path: string): Promise<string> {
|
||||
return isTauri()
|
||||
? invoke<string>('get_note_content', { path })
|
||||
@@ -41,7 +65,7 @@ export async function loadNoteContent(path: string): Promise<string> {
|
||||
|
||||
export function renameToastMessage(updatedFiles: number): string {
|
||||
if (updatedFiles === 0) return 'Renamed'
|
||||
return `Renamed — updated ${updatedFiles} wiki link${updatedFiles > 1 ? 's' : ''}`
|
||||
return `Updated ${updatedFiles} note${updatedFiles > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
/** Reload content for open tabs whose wikilinks may have changed after a rename. */
|
||||
@@ -61,6 +85,18 @@ interface Tab {
|
||||
content: string
|
||||
}
|
||||
|
||||
function renameErrorMessage(err: unknown): string {
|
||||
const message = typeof err === 'string'
|
||||
? err.trim()
|
||||
: err instanceof Error
|
||||
? err.message.trim()
|
||||
: ''
|
||||
if (message === 'A note with that name already exists' || message === 'Invalid filename') {
|
||||
return message
|
||||
}
|
||||
return 'Failed to rename note'
|
||||
}
|
||||
|
||||
export interface NoteRenameConfig {
|
||||
entries: VaultEntry[]
|
||||
setToastMessage: (msg: string | null) => void
|
||||
@@ -82,6 +118,23 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
tabsRef.current = tabDeps.tabs
|
||||
|
||||
const applyRenameResult = useCallback(async (
|
||||
oldPath: string,
|
||||
result: RenameResult,
|
||||
buildEntry: (entry: VaultEntry | undefined, newPath: string) => VaultEntry,
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
) => {
|
||||
const entry = entries.find((item) => item.path === oldPath)
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
const newEntry = buildEntry(entry, result.new_path)
|
||||
const otherTabPaths = tabsRef.current.filter((tab) => tab.entry.path !== oldPath).map((tab) => tab.entry.path)
|
||||
setTabs((prev) => prev.map((tab) => tab.entry.path === oldPath ? { entry: newEntry, content: newContent } : tab))
|
||||
if (activeTabPathRef.current === oldPath) handleSwitchTab(result.new_path)
|
||||
onEntryRenamed(oldPath, newEntry, newContent)
|
||||
await reloadTabsAfterRename(otherTabPaths, updateTabContent)
|
||||
setToastMessage(renameToastMessage(result.updated_files))
|
||||
}, [entries, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent, setToastMessage])
|
||||
|
||||
const handleRenameNote = useCallback(async (
|
||||
path: string, newTitle: string, vaultPath: string,
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
@@ -89,19 +142,37 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
|
||||
try {
|
||||
const entry = entries.find((e) => e.path === path)
|
||||
const result = await performRename(path, newTitle, vaultPath, entry?.title)
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
const newEntry = buildRenamedEntry(entry ?? {} as VaultEntry, newTitle, result.new_path)
|
||||
const otherTabPaths = tabsRef.current.filter(t => t.entry.path !== path).map(t => t.entry.path)
|
||||
setTabs((prev) => prev.map((t) => t.entry.path === path ? { entry: newEntry, content: newContent } : t))
|
||||
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
||||
onEntryRenamed(path, newEntry, newContent)
|
||||
await reloadTabsAfterRename(otherTabPaths, updateTabContent)
|
||||
setToastMessage(renameToastMessage(result.updated_files))
|
||||
await applyRenameResult(
|
||||
path,
|
||||
result,
|
||||
(currentEntry, newPath) => buildRenamedEntry(currentEntry ?? {} as VaultEntry, newTitle, newPath),
|
||||
onEntryRenamed,
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to rename note:', err)
|
||||
setToastMessage('Failed to rename note')
|
||||
setToastMessage(renameErrorMessage(err))
|
||||
}
|
||||
}, [entries, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent, setToastMessage])
|
||||
}, [entries, applyRenameResult, setToastMessage])
|
||||
|
||||
return { handleRenameNote, tabsRef }
|
||||
const handleRenameFilename = useCallback(async (
|
||||
path: string,
|
||||
newFilenameStem: string,
|
||||
vaultPath: string,
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const result = await performFilenameRename(path, newFilenameStem, vaultPath)
|
||||
await applyRenameResult(
|
||||
path,
|
||||
result,
|
||||
(currentEntry, newPath) => buildFilenameRenamedEntry(currentEntry ?? {} as VaultEntry, newPath),
|
||||
onEntryRenamed,
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to rename note filename:', err)
|
||||
setToastMessage(renameErrorMessage(err))
|
||||
}
|
||||
}, [applyRenameResult, setToastMessage])
|
||||
|
||||
return { handleRenameNote, handleRenameFilename, tabsRef }
|
||||
}
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
41
src/main.tsx
@@ -3,6 +3,21 @@ import { createRoot } from 'react-dom/client'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import {
|
||||
APP_COMMAND_EVENT_NAME,
|
||||
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>
|
||||
|
||||
@@ -110,35 +110,118 @@ let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vaul
|
||||
|
||||
let mockDeviceFlowPollCount = 0
|
||||
|
||||
function escapeRegex({ text }: { text: string }) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function relativePathStem({ path, vaultPath }: { path: string; vaultPath: string }) {
|
||||
const prefix = vaultPath.endsWith('/') ? vaultPath : `${vaultPath}/`
|
||||
if (path.startsWith(prefix)) return path.slice(prefix.length).replace(/\.md$/, '')
|
||||
return (path.split('/').pop() ?? path).replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
function canonicalRenameTargets({ oldTitle, oldPathStem }: { oldTitle: string; oldPathStem: string }) {
|
||||
const oldFilenameStem = oldPathStem.split('/').pop() ?? oldPathStem
|
||||
return [...new Set([oldTitle, oldPathStem, oldFilenameStem].filter(Boolean))]
|
||||
}
|
||||
|
||||
function slugifyMockTitle({ title }: { title: string }) {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
}
|
||||
|
||||
function buildRenamedMockPath({ oldPath, newTitle }: { oldPath: string; newTitle: string }) {
|
||||
const parentDir = oldPath.replace(/\/[^/]+$/, '')
|
||||
return `${parentDir}/${slugifyMockTitle({ title: newTitle })}.md`
|
||||
}
|
||||
|
||||
function replaceMockTitleFrontmatter({ content, newTitle }: { content: string; newTitle: string }) {
|
||||
return /^title:\s*/m.test(content)
|
||||
? content.replace(/^title:\s*.*$/m, `title: ${newTitle}`)
|
||||
: content
|
||||
}
|
||||
|
||||
function replaceRenamedWikilinks({ content, oldTargets, newPathStem }: {
|
||||
content: string
|
||||
oldTargets: string[]
|
||||
newPathStem: string
|
||||
}) {
|
||||
if (oldTargets.length === 0) return content
|
||||
const pattern = new RegExp(`\\[\\[(?:${oldTargets.map((target) => escapeRegex({ text: target })).join('|')})(\\|[^\\]]*?)?\\]\\]`, 'g')
|
||||
return content.replace(pattern, (_match: string, pipe: string | undefined) =>
|
||||
pipe ? `[[${newPathStem}${pipe}]]` : `[[${newPathStem}]]`
|
||||
)
|
||||
}
|
||||
|
||||
function updateMockRenameReferences({ newPath, newPathStem, oldTargets }: {
|
||||
newPath: string
|
||||
newPathStem: string
|
||||
oldTargets: string[]
|
||||
}) {
|
||||
let updatedFiles = 0
|
||||
for (const [path, content] of Object.entries(MOCK_CONTENT)) {
|
||||
if (path === newPath) continue
|
||||
const replaced = replaceRenamedWikilinks({ content, oldTargets, newPathStem })
|
||||
if (replaced === content) continue
|
||||
MOCK_CONTENT[path] = replaced
|
||||
updatedFiles += 1
|
||||
}
|
||||
return updatedFiles
|
||||
}
|
||||
|
||||
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string; old_title?: string | null }) {
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldTitle = args.old_title ?? oldEntry?.title ?? ''
|
||||
if (oldTitle === args.new_title) {
|
||||
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
|
||||
const newPath = buildRenamedMockPath({ oldPath: args.old_path, newTitle: args.new_title })
|
||||
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
|
||||
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
|
||||
|
||||
if (oldTitle === args.new_title && newPath === args.old_path) {
|
||||
return { new_path: args.old_path, updated_files: 0 }
|
||||
}
|
||||
|
||||
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
|
||||
const slug = args.new_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
|
||||
const newPath = `${parentDir}/${slug}.md`
|
||||
const newContent = oldContent.replace(/^# .+$/m, `# ${args.new_title}`)
|
||||
|
||||
const newContent = replaceMockTitleFrontmatter({ content: oldContent, newTitle: args.new_title })
|
||||
delete MOCK_CONTENT[args.old_path]
|
||||
MOCK_CONTENT[newPath] = newContent
|
||||
let updatedFiles = 0
|
||||
if (oldTitle) {
|
||||
const pattern = new RegExp(`\\[\\[${oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\|[^\\]]*?)?\\]\\]`, 'g')
|
||||
for (const [path, content] of Object.entries(MOCK_CONTENT)) {
|
||||
if (path === newPath) continue
|
||||
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
|
||||
pipe ? `[[${args.new_title}${pipe}]]` : `[[${args.new_title}]]`
|
||||
)
|
||||
if (replaced !== content) {
|
||||
MOCK_CONTENT[path] = replaced
|
||||
updatedFiles++
|
||||
}
|
||||
}
|
||||
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
|
||||
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
|
||||
|
||||
syncWindowContent()
|
||||
return { new_path: newPath, updated_files: updatedFiles }
|
||||
}
|
||||
|
||||
function handleRenameNoteFilename(args: {
|
||||
vault_path: string
|
||||
old_path: string
|
||||
new_filename_stem: string
|
||||
}) {
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
|
||||
const oldTitle = oldEntry?.title ?? ''
|
||||
const normalizedStem = args.new_filename_stem.trim().replace(/\.md$/, '')
|
||||
const oldFilename = args.old_path.split('/').pop() ?? ''
|
||||
const newFilename = `${normalizedStem}.md`
|
||||
|
||||
if (!normalizedStem) {
|
||||
throw new Error('Invalid filename')
|
||||
}
|
||||
if (oldFilename === newFilename) {
|
||||
return { new_path: args.old_path, updated_files: 0 }
|
||||
}
|
||||
|
||||
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
|
||||
const newPath = `${parentDir}/${newFilename}`
|
||||
if (newPath !== args.old_path && Object.prototype.hasOwnProperty.call(MOCK_CONTENT, newPath)) {
|
||||
throw new Error('A note with that name already exists')
|
||||
}
|
||||
|
||||
delete MOCK_CONTENT[args.old_path]
|
||||
MOCK_CONTENT[newPath] = oldContent
|
||||
|
||||
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
|
||||
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
|
||||
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
|
||||
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
|
||||
|
||||
syncWindowContent()
|
||||
return { new_path: newPath, updated_files: updatedFiles }
|
||||
@@ -229,6 +312,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }),
|
||||
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
|
||||
rename_note: handleRenameNote,
|
||||
rename_note_filename: handleRenameNoteFilename,
|
||||
github_list_repos: () => [
|
||||
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
|
||||
{ name: 'laputa-app', full_name: 'lucaong/laputa-app', description: 'Laputa desktop app — Tauri + React + CodeMirror 6', private: false, clone_url: 'https://github.com/lucaong/laputa-app.git', html_url: 'https://github.com/lucaong/laputa-app', updated_at: '2026-02-19T15:00:00Z' },
|
||||
|
||||
@@ -46,6 +46,16 @@ const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => Vaul
|
||||
args.path ? { url: '/api/vault/save', method: 'POST', body: { path: args.path, content: args.content } } : null,
|
||||
rename_note: (args) =>
|
||||
args.old_path ? { url: '/api/vault/rename', method: 'POST', body: { vault_path: args.vault_path, old_path: args.old_path, new_title: args.new_title } } : null,
|
||||
rename_note_filename: (args) =>
|
||||
args.old_path ? {
|
||||
url: '/api/vault/rename-filename',
|
||||
method: 'POST',
|
||||
body: {
|
||||
vault_path: args.vault_path,
|
||||
old_path: args.old_path,
|
||||
new_filename_stem: args.new_filename_stem,
|
||||
},
|
||||
} : null,
|
||||
delete_note: (args) =>
|
||||
args.path ? { url: '/api/vault/delete', method: 'POST', body: { path: args.path } } : null,
|
||||
search_vault: (args) => {
|
||||
|
||||
117
src/utils/rawEditorUtils.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { buildRawEditorBaseItems, detectYamlError, extractWikilinkQuery, getRawEditorDropdownPosition, replaceActiveWikilinkQuery } from './rawEditorUtils'
|
||||
|
||||
describe('extractWikilinkQuery', () => {
|
||||
it('returns null when no [[ trigger', () => {
|
||||
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns empty string immediately after [[', () => {
|
||||
const text = 'see [['
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('')
|
||||
})
|
||||
|
||||
it('returns query after [[', () => {
|
||||
const text = 'see [[Proj'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
|
||||
})
|
||||
|
||||
it('returns null when ]] closes the link', () => {
|
||||
const text = '[[Proj]]'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when newline is in query', () => {
|
||||
const text = '[[Proj\ncontinued'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles cursor before end of text', () => {
|
||||
const text = '[[Proj after'
|
||||
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceActiveWikilinkQuery', () => {
|
||||
it('replaces the active wikilink query with the canonical target', () => {
|
||||
expect(replaceActiveWikilinkQuery('See [[Proj', 10, 'projects/alpha')).toEqual({
|
||||
text: 'See [[projects/alpha]]',
|
||||
cursor: 22,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves text after the cursor', () => {
|
||||
expect(replaceActiveWikilinkQuery('See [[Proj today', 10, 'projects/alpha')).toEqual({
|
||||
text: 'See [[projects/alpha]] today',
|
||||
cursor: 22,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when no active wikilink trigger exists', () => {
|
||||
expect(replaceActiveWikilinkQuery('See Proj', 8, 'projects/alpha')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectYamlError', () => {
|
||||
it('returns null for content without frontmatter', () => {
|
||||
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid frontmatter', () => {
|
||||
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns error for unclosed frontmatter', () => {
|
||||
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
|
||||
expect(error).toContain('Unclosed frontmatter')
|
||||
})
|
||||
|
||||
it('returns error for tab indentation in frontmatter', () => {
|
||||
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
|
||||
expect(error).toContain('tab indentation')
|
||||
})
|
||||
|
||||
it('returns null for content not starting with ---', () => {
|
||||
expect(detectYamlError('Not frontmatter')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRawEditorBaseItems', () => {
|
||||
it('includes filename aliases and deduplicates entries by path', () => {
|
||||
expect(buildRawEditorBaseItems([
|
||||
{
|
||||
title: 'Project Alpha',
|
||||
aliases: ['Alpha'],
|
||||
filename: 'project-alpha.md',
|
||||
isA: 'Project',
|
||||
path: 'projects/project-alpha.md',
|
||||
},
|
||||
{
|
||||
title: 'Project Alpha',
|
||||
aliases: ['Ignored'],
|
||||
filename: 'project-alpha.md',
|
||||
isA: 'Project',
|
||||
path: 'projects/project-alpha.md',
|
||||
},
|
||||
] as VaultEntry[])).toEqual([
|
||||
{
|
||||
title: 'Project Alpha',
|
||||
aliases: ['project-alpha', 'Alpha'],
|
||||
group: 'Project',
|
||||
entryTitle: 'Project Alpha',
|
||||
path: 'projects/project-alpha.md',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRawEditorDropdownPosition', () => {
|
||||
it('renders above the cursor when there is not enough space below', () => {
|
||||
expect(getRawEditorDropdownPosition(
|
||||
{ caretTop: 500, caretLeft: 900, selectedIndex: 0, items: [] },
|
||||
200,
|
||||
{ innerHeight: 640, innerWidth: 1000 },
|
||||
)).toEqual({ top: 276, left: 740 })
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,33 @@
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { attachClickHandlers, enrichSuggestionItems } from './suggestionEnrichment'
|
||||
import { deduplicateByPath, preFilterWikilinks } from './wikilinkSuggestions'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { WikilinkSuggestionItem } from '../components/WikilinkSuggestionMenu'
|
||||
|
||||
export interface RawEditorBaseItem {
|
||||
title: string
|
||||
aliases: string[]
|
||||
group: string
|
||||
entryTitle: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface RawEditorAutocompleteState {
|
||||
caretTop: number
|
||||
caretLeft: number
|
||||
selectedIndex: number
|
||||
items: WikilinkSuggestionItem[]
|
||||
}
|
||||
|
||||
interface RawEditorAutocompleteParams {
|
||||
view: EditorView
|
||||
baseItems: RawEditorBaseItem[]
|
||||
query: string
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onInsertTarget: (target: string) => void
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
/** Extract the wikilink query that the user is currently typing after [[ */
|
||||
export function extractWikilinkQuery(text: string, cursor: number): string | null {
|
||||
const before = text.slice(0, cursor)
|
||||
@@ -9,6 +39,75 @@ export function extractWikilinkQuery(text: string, cursor: number): string | nul
|
||||
return afterTrigger
|
||||
}
|
||||
|
||||
export function replaceActiveWikilinkQuery(
|
||||
text: string,
|
||||
cursor: number,
|
||||
target: string,
|
||||
): { text: string; cursor: number } | null {
|
||||
const before = text.slice(0, cursor)
|
||||
const triggerIdx = before.lastIndexOf('[[')
|
||||
if (triggerIdx === -1) return null
|
||||
const after = text.slice(cursor)
|
||||
return {
|
||||
text: `${text.slice(0, triggerIdx)}[[${target}]]${after}`,
|
||||
cursor: triggerIdx + target.length + 4,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRawEditorBaseItems(entries: VaultEntry[]): RawEditorBaseItem[] {
|
||||
return deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
})))
|
||||
}
|
||||
|
||||
export function getRawEditorCursorCoords(view: EditorView): { top: number; left: number } | null {
|
||||
const pos = view.state.selection.main.head
|
||||
const coords = view.coordsAtPos(pos)
|
||||
if (!coords) return null
|
||||
return { top: coords.bottom, left: coords.left }
|
||||
}
|
||||
|
||||
export function buildRawEditorAutocompleteState({
|
||||
view,
|
||||
baseItems,
|
||||
query,
|
||||
typeEntryMap,
|
||||
onInsertTarget,
|
||||
vaultPath,
|
||||
}: RawEditorAutocompleteParams): RawEditorAutocompleteState | null {
|
||||
const coords = getRawEditorCursorCoords(view)
|
||||
if (!coords) return null
|
||||
|
||||
const candidates = preFilterWikilinks(baseItems, query)
|
||||
const withHandlers = attachClickHandlers(candidates, onInsertTarget, vaultPath)
|
||||
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
|
||||
|
||||
return {
|
||||
caretTop: coords.top,
|
||||
caretLeft: coords.left,
|
||||
selectedIndex: 0,
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
export function getRawEditorDropdownPosition(
|
||||
autocomplete: RawEditorAutocompleteState | null,
|
||||
maxHeight: number,
|
||||
viewport: { innerHeight: number; innerWidth: number },
|
||||
): { top: number; left: number } {
|
||||
if (!autocomplete) return { top: 0, left: 0 }
|
||||
|
||||
const dropdownBelow = autocomplete.caretTop + 20 + maxHeight <= viewport.innerHeight
|
||||
return {
|
||||
top: dropdownBelow ? autocomplete.caretTop + 4 : autocomplete.caretTop - maxHeight - 24,
|
||||
left: Math.min(autocomplete.caretLeft, viewport.innerWidth - 260),
|
||||
}
|
||||
}
|
||||
|
||||
/** Basic YAML frontmatter structural checks. */
|
||||
export function detectYamlError(content: string): string | null {
|
||||
if (!content.startsWith('---')) return null
|
||||
|
||||
@@ -81,11 +81,15 @@ export function getStatusColorKey(status: string): string | null {
|
||||
return colorOverrides[status] ?? null
|
||||
}
|
||||
|
||||
export function getStatusStyle(status: string): StatusStyle {
|
||||
export function getMappedStatusStyle(status: string): StatusStyle | null {
|
||||
const overrideKey = colorOverrides[status]
|
||||
if (overrideKey) {
|
||||
const style = COLOR_KEY_TO_STYLE[overrideKey]
|
||||
if (style) return style
|
||||
}
|
||||
return STATUS_STYLES[status] ?? DEFAULT_STATUS_STYLE
|
||||
return STATUS_STYLES[status] ?? null
|
||||
}
|
||||
|
||||
export function getStatusStyle(status: string): StatusStyle {
|
||||
return getMappedStatusStyle(status) ?? DEFAULT_STATUS_STYLE
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ describe('attachClickHandlers', () => {
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
result[0].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('a|Note A')
|
||||
expect(insertWikilink).toHaveBeenCalledWith('a')
|
||||
result[1].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('b|Note B')
|
||||
expect(insertWikilink).toHaveBeenCalledWith('b')
|
||||
})
|
||||
|
||||
it('preserves all original properties', () => {
|
||||
@@ -55,13 +55,13 @@ describe('attachClickHandlers', () => {
|
||||
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
|
||||
|
||||
result[0].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack|ADR 001')
|
||||
expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack')
|
||||
})
|
||||
|
||||
it('omits pipe display when title matches path stem', () => {
|
||||
it('omits any default alias even when the title differs from the path stem', () => {
|
||||
const insertWikilink = vi.fn()
|
||||
const candidates = [
|
||||
{ title: 'roadmap', aliases: [], group: 'Note', entryTitle: 'roadmap', path: '/vault/roadmap.md' },
|
||||
{ title: 'Roadmap', aliases: [], group: 'Note', entryTitle: 'Roadmap', path: '/vault/roadmap.md' },
|
||||
]
|
||||
|
||||
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
|
||||
|
||||
@@ -17,17 +17,14 @@ interface BaseSuggestionItem {
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Build the wikilink target: relative path stem with pipe display for the title.
|
||||
* e.g. "docs/adr/0001-tauri-stack|Tauri Stack" for subfolders,
|
||||
* "roadmap|Roadmap" for root files. */
|
||||
/** Build the canonical wikilink target: vault-relative path stem without a default alias. */
|
||||
function buildTarget(item: BaseSuggestionItem, vaultPath: string): string {
|
||||
const stem = relativePathStem(item.path, vaultPath)
|
||||
return stem === item.entryTitle ? stem : `${stem}|${item.entryTitle}`
|
||||
return relativePathStem(item.path, vaultPath)
|
||||
}
|
||||
|
||||
/** Add onItemClick to raw suggestion candidates.
|
||||
* Always inserts the vault-relative path as the wikilink target
|
||||
* so links are unambiguous and work across subfolders. */
|
||||
* Always inserts the canonical vault-relative path target so links are
|
||||
* unambiguous and remain stable across renames. */
|
||||
export function attachClickHandlers(
|
||||
candidates: BaseSuggestionItem[],
|
||||
insertWikilink: (target: string) => void,
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { wikilinkTarget, wikilinkDisplay, resolveEntry, relativePathStem } from './wikilink'
|
||||
import {
|
||||
wikilinkTarget,
|
||||
wikilinkDisplay,
|
||||
resolveEntry,
|
||||
relativePathStem,
|
||||
slugifyWikilinkTarget,
|
||||
canonicalWikilinkTargetForEntry,
|
||||
canonicalWikilinkTargetForTitle,
|
||||
formatWikilinkRef,
|
||||
} from './wikilink'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
|
||||
return {
|
||||
@@ -124,3 +133,42 @@ describe('relativePathStem', () => {
|
||||
expect(relativePathStem('/other/path/note.md', '/Users/luca/Vault')).toBe('note')
|
||||
})
|
||||
})
|
||||
|
||||
describe('slugifyWikilinkTarget', () => {
|
||||
it('slugifies a human title to a canonical target', () => {
|
||||
expect(slugifyWikilinkTarget('Weekly Review')).toBe('weekly-review')
|
||||
})
|
||||
|
||||
it('falls back to untitled when the title has no slug characters', () => {
|
||||
expect(slugifyWikilinkTarget('+++')).toBe('untitled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('canonicalWikilinkTargetForEntry', () => {
|
||||
it('returns a vault-relative path stem', () => {
|
||||
const entry = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha' })
|
||||
expect(canonicalWikilinkTargetForEntry(entry, '/vault')).toBe('projects/alpha')
|
||||
})
|
||||
})
|
||||
|
||||
describe('canonicalWikilinkTargetForTitle', () => {
|
||||
const project = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha Project' })
|
||||
|
||||
it('resolves an existing entry to its canonical path target', () => {
|
||||
expect(canonicalWikilinkTargetForTitle('Alpha Project', [project], '/vault')).toBe('projects/alpha')
|
||||
})
|
||||
|
||||
it('keeps a canonical path input canonical', () => {
|
||||
expect(canonicalWikilinkTargetForTitle('projects/alpha', [project], '/vault')).toBe('projects/alpha')
|
||||
})
|
||||
|
||||
it('falls back to a slug for a newly created note title', () => {
|
||||
expect(canonicalWikilinkTargetForTitle('Brand New Note', [], '/vault')).toBe('brand-new-note')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatWikilinkRef', () => {
|
||||
it('wraps a canonical target in wikilink syntax', () => {
|
||||
expect(formatWikilinkRef('projects/alpha')).toBe('[[projects/alpha]]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,86 @@ export function relativePathStem(absolutePath: string, vaultPath: string): strin
|
||||
return filename.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
/** Slugify a human-readable title into the canonical wikilink filename stem. */
|
||||
export function slugifyWikilinkTarget(title: string): string {
|
||||
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
return slug || 'untitled'
|
||||
}
|
||||
|
||||
/** Build the canonical wikilink target for a vault entry. */
|
||||
export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: string): string {
|
||||
return relativePathStem(entry.path, vaultPath)
|
||||
}
|
||||
|
||||
/** Resolve a user-facing title/path input to the canonical wikilink target. */
|
||||
export function canonicalWikilinkTargetForTitle(
|
||||
titleOrTarget: string,
|
||||
entries: VaultEntry[],
|
||||
vaultPath: string,
|
||||
): string {
|
||||
const trimmed = titleOrTarget.trim()
|
||||
const resolved = resolveEntry(entries, trimmed)
|
||||
return resolved
|
||||
? canonicalWikilinkTargetForEntry(resolved, vaultPath)
|
||||
: trimmed.includes('/')
|
||||
? trimmed.replace(/^\/+/, '').replace(/\.md$/, '')
|
||||
: slugifyWikilinkTarget(trimmed)
|
||||
}
|
||||
|
||||
/** Wrap a target in wikilink syntax. */
|
||||
export function formatWikilinkRef(target: string): string {
|
||||
return `[[${target}]]`
|
||||
}
|
||||
|
||||
interface ResolutionKey {
|
||||
exactTarget: string
|
||||
lastSegment: string
|
||||
pathSuffix: string | null
|
||||
humanizedTarget: string | null
|
||||
}
|
||||
|
||||
function buildResolutionKey(rawTarget: string): ResolutionKey {
|
||||
const exactTarget = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
|
||||
const normalizedTarget = exactTarget.toLowerCase()
|
||||
const lastSegment = exactTarget.includes('/') ? (exactTarget.split('/').pop() ?? exactTarget).toLowerCase() : normalizedTarget
|
||||
const humanizedTarget = lastSegment.replace(/-/g, ' ')
|
||||
|
||||
return {
|
||||
exactTarget: normalizedTarget,
|
||||
lastSegment,
|
||||
pathSuffix: exactTarget.includes('/') ? `/${normalizedTarget}.md` : null,
|
||||
humanizedTarget: humanizedTarget === normalizedTarget ? null : humanizedTarget,
|
||||
}
|
||||
}
|
||||
|
||||
function findEntryByPathSuffix(entries: VaultEntry[], pathSuffix: string | null): VaultEntry | undefined {
|
||||
if (!pathSuffix) return undefined
|
||||
return entries.find(entry => entry.path.toLowerCase().endsWith(pathSuffix))
|
||||
}
|
||||
|
||||
function findEntryByFilename(entries: VaultEntry[], { exactTarget, lastSegment }: ResolutionKey): VaultEntry | undefined {
|
||||
return entries.find((entry) => {
|
||||
const stem = entry.filename.replace(/\.md$/, '').toLowerCase()
|
||||
return stem === exactTarget || stem === lastSegment
|
||||
})
|
||||
}
|
||||
|
||||
function findEntryByAlias(entries: VaultEntry[], exactTarget: string): VaultEntry | undefined {
|
||||
return entries.find(entry => entry.aliases.some(alias => alias.toLowerCase() === exactTarget))
|
||||
}
|
||||
|
||||
function findEntryByTitle(entries: VaultEntry[], exactTarget: string, lastSegment: string): VaultEntry | undefined {
|
||||
return entries.find((entry) => {
|
||||
const lowerTitle = entry.title.toLowerCase()
|
||||
return lowerTitle === exactTarget || lowerTitle === lastSegment
|
||||
})
|
||||
}
|
||||
|
||||
function findEntryByHumanizedTitle(entries: VaultEntry[], humanizedTarget: string | null): VaultEntry | undefined {
|
||||
if (!humanizedTarget) return undefined
|
||||
return entries.find(entry => entry.title.toLowerCase() === humanizedTarget)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified wikilink resolution: find the VaultEntry matching a wikilink target.
|
||||
* Handles pipe syntax, case-insensitive matching.
|
||||
@@ -38,37 +118,12 @@ export function relativePathStem(absolutePath: string, vaultPath: string): strin
|
||||
* 5. Humanized title match (kebab-case → words)
|
||||
*/
|
||||
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
|
||||
const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
|
||||
const keyLower = key.toLowerCase()
|
||||
const lastSegment = key.includes('/') ? (key.split('/').pop() ?? key) : key
|
||||
const lastSegmentLower = lastSegment.toLowerCase()
|
||||
const asWords = lastSegmentLower.replace(/-/g, ' ')
|
||||
const pathSuffix = key.includes('/') ? '/' + key.toLowerCase() + '.md' : null
|
||||
|
||||
// Pass 1: path-suffix match (for subfolder targets like "docs/adr/0031-foo")
|
||||
if (pathSuffix) {
|
||||
for (const e of entries) {
|
||||
if (e.path.toLowerCase().endsWith(pathSuffix)) return e
|
||||
}
|
||||
}
|
||||
// Pass 2: filename stem (strongest for flat vault)
|
||||
for (const e of entries) {
|
||||
const stem = e.filename.replace(/\.md$/, '').toLowerCase()
|
||||
if (stem === keyLower || stem === lastSegmentLower) return e
|
||||
}
|
||||
// Pass 3: alias
|
||||
for (const e of entries) {
|
||||
if (e.aliases.some(a => a.toLowerCase() === keyLower)) return e
|
||||
}
|
||||
// Pass 4: exact title
|
||||
for (const e of entries) {
|
||||
if (e.title.toLowerCase() === keyLower || e.title.toLowerCase() === lastSegmentLower) return e
|
||||
}
|
||||
// Pass 5: humanized title (kebab-case → words)
|
||||
if (asWords !== keyLower) {
|
||||
for (const e of entries) {
|
||||
if (e.title.toLowerCase() === asWords) return e
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
const resolutionKey = buildResolutionKey(rawTarget)
|
||||
return (
|
||||
findEntryByPathSuffix(entries, resolutionKey.pathSuffix)
|
||||
?? findEntryByFilename(entries, resolutionKey)
|
||||
?? findEntryByAlias(entries, resolutionKey.exactTarget)
|
||||
?? findEntryByTitle(entries, resolutionKey.exactTarget, resolutionKey.lastSegment)
|
||||
?? findEntryByHumanizedTitle(entries, resolutionKey.humanizedTarget)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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__))
|
||||
}
|
||||
|
||||
@@ -33,14 +33,14 @@ async function openNote(page: import('@playwright/test').Page, title: string) {
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
/** Helper: rename the active note through the stable TitleField. */
|
||||
async function renameActiveNote(page: import('@playwright/test').Page, nextTitle: string) {
|
||||
const titleInput = page.getByTestId('title-field-input')
|
||||
await expect(titleInput).toBeVisible({ timeout: 5_000 })
|
||||
await titleInput.click()
|
||||
await titleInput.fill(nextTitle)
|
||||
await titleInput.press('Enter')
|
||||
await expect(titleInput).toHaveValue(nextTitle, { timeout: 5_000 })
|
||||
/** Helper: rename the active note filename through the breadcrumb control. */
|
||||
async function renameActiveNoteFilename(page: import('@playwright/test').Page, nextFilename: string) {
|
||||
await page.getByTestId('breadcrumb-filename-trigger').dblclick()
|
||||
const filenameInput = page.getByTestId('breadcrumb-filename-input')
|
||||
await expect(filenameInput).toBeVisible({ timeout: 5_000 })
|
||||
await filenameInput.fill(nextFilename)
|
||||
await filenameInput.press('Enter')
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(nextFilename, { timeout: 5_000 })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -123,7 +123,7 @@ test('rename note updates filename on disk', async ({ page }) => {
|
||||
// Open Note B
|
||||
await openNote(page, 'Note B')
|
||||
|
||||
await renameActiveNote(page, 'Note B Renamed')
|
||||
await renameActiveNoteFilename(page, 'note-b-renamed')
|
||||
|
||||
// Verify filesystem: old file gone, new file exists
|
||||
const oldPath = path.join(tempVaultDir, 'note', 'note-b.md')
|
||||
@@ -135,7 +135,7 @@ test('rename note updates filename on disk', async ({ page }) => {
|
||||
}).toPass({ timeout: 5_000 })
|
||||
|
||||
const newContent = fs.readFileSync(newPath, 'utf-8')
|
||||
expect(newContent).toContain('# Note B Renamed')
|
||||
expect(newContent).toContain('# Note B')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -145,7 +145,7 @@ test('rename note updates filename on disk', async ({ page }) => {
|
||||
test('rename note updates wikilinks in other files', async ({ page }) => {
|
||||
// Open Note B and rename it
|
||||
await openNote(page, 'Note B')
|
||||
await renameActiveNote(page, 'Note B Updated')
|
||||
await renameActiveNoteFilename(page, 'note-b-updated')
|
||||
|
||||
// Wait for rename to complete (file to be moved)
|
||||
const newPath = path.join(tempVaultDir, 'note', 'note-b-updated.md')
|
||||
@@ -153,9 +153,10 @@ test('rename note updates wikilinks in other files', async ({ page }) => {
|
||||
expect(fs.existsSync(newPath)).toBe(true)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
|
||||
// Verify alpha-project.md now references [[Note B Updated]] instead of [[Note B]]
|
||||
// Verify alpha-project.md now references the canonical path target.
|
||||
const alphaContent = fs.readFileSync(path.join(tempVaultDir, 'project', 'alpha-project.md'), 'utf-8')
|
||||
expect(alphaContent).toContain('[[Note B Updated]]')
|
||||
expect(alphaContent).toContain('[[note/note-b-updated]]')
|
||||
expect(alphaContent).not.toContain('[[note/note-b]]')
|
||||
expect(alphaContent).not.toContain('[[Note B]]')
|
||||
})
|
||||
|
||||
|
||||
107
tests/smoke/breadcrumb-filename-rename.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { executeCommand, openCommandPalette } from './helpers'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
async function openNote(page: Page, title: string) {
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.getByText(title, { exact: true }).click()
|
||||
}
|
||||
|
||||
async function openRawMode(page: Page) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function openBlockNoteMode(page: Page) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function getRawEditorContent(page: Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const el = document.querySelector('.cm-content')
|
||||
if (!el) return ''
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const view = (el as any).cmTile?.view
|
||||
if (view) return view.state.doc.toString() as string
|
||||
return el.textContent ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
async function setRawEditorContent(page: Page, content: string) {
|
||||
await page.evaluate((newContent) => {
|
||||
const el = document.querySelector('.cm-content')
|
||||
if (!el) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const view = (el as any).cmTile?.view
|
||||
if (!view) return
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: newContent },
|
||||
})
|
||||
}, content)
|
||||
}
|
||||
|
||||
test('breadcrumb sync button and inline rename keep the file path aligned with H1 title changes', async ({ page }) => {
|
||||
const syncedPath = path.join(tempVaultDir, 'note', 'breadcrumb-sync-target.md')
|
||||
const manuallyRenamedPath = path.join(tempVaultDir, 'note', 'manual-breadcrumb-name.md')
|
||||
const originalPath = path.join(tempVaultDir, 'note', 'note-b.md')
|
||||
const updatedTitle = 'Breadcrumb Sync Target'
|
||||
|
||||
await openNote(page, 'Note B')
|
||||
await openRawMode(page)
|
||||
|
||||
const rawContent = await getRawEditorContent(page)
|
||||
expect(rawContent).toContain('# Note B')
|
||||
|
||||
await setRawEditorContent(page, rawContent.replace('# Note B', `# ${updatedTitle}`))
|
||||
await page.keyboard.press('Meta+s')
|
||||
await openBlockNoteMode(page)
|
||||
|
||||
await expect(page.getByRole('heading', { name: updatedTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('note-b')
|
||||
await expect(page.getByTestId('breadcrumb-sync-button')).toBeVisible()
|
||||
|
||||
await page.getByTestId('breadcrumb-sync-button').click()
|
||||
await expect(page.locator('.fixed.bottom-8')).toContainText('Renamed', { timeout: 5_000 })
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('breadcrumb-sync-target')
|
||||
await expect(page.getByTestId('breadcrumb-sync-button')).toHaveCount(0)
|
||||
|
||||
await expect.poll(() => fs.existsSync(originalPath)).toBe(false)
|
||||
await expect.poll(() => fs.existsSync(syncedPath)).toBe(true)
|
||||
|
||||
await page.getByTestId('breadcrumb-filename-trigger').focus()
|
||||
await page.keyboard.press('Enter')
|
||||
const firstInput = page.getByTestId('breadcrumb-filename-input')
|
||||
await expect(firstInput).toHaveValue('breadcrumb-sync-target')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByTestId('breadcrumb-filename-input')).toHaveCount(0)
|
||||
|
||||
await page.getByTestId('breadcrumb-filename-trigger').dblclick()
|
||||
const renameInput = page.getByTestId('breadcrumb-filename-input')
|
||||
await renameInput.fill('manual-breadcrumb-name')
|
||||
await renameInput.press('Enter')
|
||||
|
||||
await expect(page.locator('.fixed.bottom-8')).toContainText('Renamed', { timeout: 5_000 })
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('manual-breadcrumb-name')
|
||||
await expect.poll(() => fs.existsSync(syncedPath)).toBe(false)
|
||||
await expect.poll(() => fs.existsSync(manuallyRenamedPath)).toBe(true)
|
||||
|
||||
await openRawMode(page)
|
||||
await expect.poll(async () => getRawEditorContent(page)).toContain(`# ${updatedTitle}`)
|
||||
})
|
||||