Compare commits
27 Commits
v0.2026041
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e98a186389 | ||
|
|
258b54b074 | ||
|
|
f694b9b5e4 | ||
|
|
32ee2b781b | ||
|
|
c78793cfe3 | ||
|
|
cd9e5fc403 | ||
|
|
798a6b2125 | ||
|
|
dbf54657f0 | ||
|
|
0c365eb7dd | ||
|
|
20b789271d | ||
|
|
36d3c8731b | ||
|
|
e412fa8fd7 | ||
|
|
76c37cf783 | ||
|
|
4b60b9539d | ||
|
|
69e520b5aa | ||
|
|
272f2c0b3c | ||
|
|
d7bdab5b2e | ||
|
|
5a5ea8d6f0 | ||
|
|
86306dc9de | ||
|
|
974ca6148b | ||
|
|
b1bc056afb | ||
|
|
c24f60c594 | ||
|
|
71a3be577d | ||
|
|
717fa9d1a6 | ||
|
|
78e76e0d54 | ||
|
|
b01c6b4a4b | ||
|
|
db4359981f |
@@ -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,18 @@ 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 routing is explicit:
|
||||
|
||||
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
|
||||
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
|
||||
- macOS browser-reserved chords such as `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
|
||||
- `menu.rs` and `useMenuEvents` emit the same command IDs for native menu clicks and accelerators
|
||||
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
|
||||
- Deterministic QA uses two explicit proof paths from the shared manifest:
|
||||
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
|
||||
- native menu-command proof through `trigger_menu_command`
|
||||
- The browser harness is only a deterministic desktop command bridge; exact native accelerator delivery still requires real Tauri QA for commands flagged as manual-native-critical
|
||||
|
||||
## 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 combos + 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,17 @@ 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 combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
|
||||
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 shortcut QA, use the explicit proof path from `appCommandCatalog.ts`:
|
||||
|
||||
- `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage
|
||||
- `window.__laputaTest.triggerMenuCommand()` for deterministic native menu-command coverage
|
||||
|
||||
That browser harness is a deterministic desktop command bridge, not real native accelerator QA. For macOS browser-reserved chords, still perform native QA in the real Tauri app because the webview-init prevent-default layer is only active there. Do not treat flaky synthesized macOS keystrokes as proof that a shortcut works unless you also confirm the visible app behavior.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
@@ -337,7 +346,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, modifier rule, and deterministic QA mode, then wire the matching native menu item in `menu.rs` if it should also appear in the menu bar
|
||||
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.”
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0052"
|
||||
title: "Renderer-first shortcut execution with native-menu dedupe"
|
||||
status: active
|
||||
date: 2026-04-11
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0051 gave Laputa a shared shortcut manifest and shared command IDs, but it still treated many shortcuts as native-menu-owned at execution time. In practice that meant `useAppKeyboard` deferred commands like `Cmd+Shift+I`, `Cmd+Shift+L`, and `Cmd+\` whenever the app ran under Tauri, and automated QA had to prove those flows by injecting menu-command IDs instead of pressing the real keys.
|
||||
|
||||
That is not a strong enough QA story for a keyboard-first app. If a user presses a shortcut while the editor is focused, we need a deterministic way to prove the actual key combo works. At the same time, we still want a native macOS menu bar with working menu items and accelerators.
|
||||
|
||||
## Decision
|
||||
|
||||
**Renderer keyboard handling is now the primary execution path for all shortcut-capable app commands, including commands that also have native menu accelerators. Native menu clicks and accelerators still emit the same command IDs, but the shared dispatcher suppresses the duplicate native/renderer echo from a single keypress so the command runs exactly once.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Renderer-first shortcut execution plus native-menu dedupe. This keeps shortcuts testable with real key events in a Tauri-like environment while preserving menu-bar parity and clickable native menu items. Downside: the dispatcher has to understand and suppress paired native/renderer echoes.
|
||||
- **Option B**: Keep deferring native-owned shortcuts out of the renderer and prove them only through `trigger_menu_command`. Lower implementation churn, but it still leaves the real keystroke path unproven.
|
||||
- **Option C**: Remove native accelerators entirely and keep shortcuts renderer-only. Simplest to reason about, but weaker desktop UX and poorer macOS menu discoverability.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `appCommandCatalog.ts` remains the single manifest for command IDs and shortcut combos, but keyboard execution no longer depends on a separate owner flag.
|
||||
- `useAppKeyboard` handles the actual key event for every shortcut-capable command, even in Tauri mode.
|
||||
- `useMenuEvents` still handles menu clicks and test-triggered native command IDs, but shared dispatcher dedupe prevents a focused keypress from firing twice when the native menu accelerator also echoes back into the renderer.
|
||||
- Deterministic QA now has two complementary proofs:
|
||||
- real keyboard events in a Tauri-like environment for the actual shortcut combo
|
||||
- `trigger_menu_command` for the native menu click/accelerator command path
|
||||
- This ADR supersedes ADR 0051 by replacing “execution ownership lives in the manifest” with “shortcut combos live in the manifest, while execution is renderer-first and native menu dispatch is deduped.”
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0053"
|
||||
title: "Webview-init prevention for browser-reserved shortcuts"
|
||||
status: active
|
||||
date: 2026-04-11
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0052 made renderer-first shortcut handling the primary path for command execution, with native menu accelerators deduped afterward. That works for normal shortcuts, but native QA on macOS showed that `Cmd+Shift+L` still failed to reach the app even though the shared command path and the Note menu item both worked.
|
||||
|
||||
The gap is WKWebView itself: some browser-reserved chords are swallowed by the webview before the renderer-level shortcut listener can execute. That makes the shortcut untestable with the real native keypress even though the command bus is correct.
|
||||
|
||||
## Decision
|
||||
|
||||
**Laputa will keep renderer-first shortcut execution, but for macOS browser-reserved chords we will add a narrow Tauri webview-init prevention layer using `tauri-plugin-prevent-default` so the real keystroke reaches the shared command path.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Add a narrow `tauri-plugin-prevent-default` registration for only the known browser-reserved chords we actually use. This preserves ADR 0052, keeps the command bus unified, and fixes the real native keystroke path without broad shortcut capture.
|
||||
- **Option B**: Keep relying on renderer capture listeners alone. Simpler, but it fails for chords that WKWebView consumes before renderer code sees them.
|
||||
- **Option C**: Use a global shortcut plugin as the fallback path. This would catch the keystroke natively, but it reserves the chord outside Laputa and is too heavy for app-local shortcuts.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Shortcut ownership stays unified: command IDs and execution still live in the shared renderer/native command bus.
|
||||
- macOS-only browser-reserved chords now have one extra declaration point in `src-tauri/src/lib.rs`, and that list must stay intentionally small.
|
||||
- Native QA remains mandatory for any shortcut added to that list, because browser dev and mocked Tauri tests do not exercise the webview-init layer.
|
||||
- Re-evaluate this decision if Tauri/WKWebView exposes a better app-local native shortcut hook that does not require browser-reserved-key workarounds.
|
||||
35
docs/adr/0054-deterministic-shortcut-qa-matrix.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0054"
|
||||
title: "Deterministic shortcut QA matrix"
|
||||
status: active
|
||||
date: 2026-04-11
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0052 made renderer-first shortcut execution the primary runtime path, and ADR 0053 added a narrow macOS webview-init prevent-default layer for browser-reserved chords such as `Cmd+Shift+L`. Those decisions improved behavior, but the automated QA story was still muddy:
|
||||
|
||||
- browser smoke tests were describing a mocked desktop harness as if it were native Tauri QA
|
||||
- some tests used `page.keyboard.press()` for commands whose real desktop accelerators are intercepted or reserved by the browser shell
|
||||
- native menu command coverage existed, but the catalog did not declare which deterministic proof path each shortcut should use
|
||||
|
||||
That made it too easy to ship a shortcut with passing automation while overstating what the automation had actually proven.
|
||||
|
||||
## Decision
|
||||
|
||||
**Laputa will treat shortcut QA as an explicit part of the shared command manifest. Every shortcut-capable command must have a deterministic automated proof path, and the test harness must distinguish renderer shortcut-event proof from native menu-command proof instead of calling the browser harness “native Tauri QA”.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Add a deterministic shortcut QA matrix to the shared command catalog. Renderer shortcut handling can be exercised through synthetic `keydown` events generated from the manifest, while native menu commands are exercised through `trigger_menu_command`. Pros: deterministic, explicit, and honest about what is being proved. Cons: still requires real native QA for exact accelerator delivery on macOS.
|
||||
- **Option B**: Keep using ad hoc Playwright key presses and browser-side menu shims. Lower change cost, but still allows false claims about native coverage and still depends on browser-reserved shortcuts behaving nicely.
|
||||
- **Option C**: Block all shortcut work until full native Tauri automation exists. Strongest eventual guarantee, but it would leave the keyboard-first app without a usable deterministic QA strategy today.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `appCommandCatalog.ts` now owns not just command IDs and modifier rules, but also the deterministic QA mode for each shortcut-capable command.
|
||||
- Browser harness smoke tests must describe themselves as a desktop command bridge, not native app QA.
|
||||
- Renderer shortcut behavior can be verified deterministically without depending on browser chrome or flaky AppleScript key synthesis.
|
||||
- Native menu-command behavior can be verified deterministically through the Tauri command bridge.
|
||||
- Exact desktop accelerator delivery still requires real Tauri QA for commands flagged as needing manual native verification, especially browser-reserved macOS chords.
|
||||
@@ -105,3 +105,8 @@ 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 | superseded → [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) |
|
||||
| [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) | Renderer-first shortcut execution with native-menu dedupe | active |
|
||||
| [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active |
|
||||
| [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | 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",
|
||||
|
||||
234
patches/prosemirror-tables@1.8.5.patch
Normal file
@@ -0,0 +1,234 @@
|
||||
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||
index 6c65eb7d57e207a8cd1f2a2ae3bb99507c405cef..c163932957846385623eb2980bf1141eb6631db1 100644
|
||||
--- a/dist/index.cjs
|
||||
+++ b/dist/index.cjs
|
||||
@@ -2443,6 +2443,21 @@ function handleMouseLeave(view) {
|
||||
const pluginState = columnResizingPluginKey.getState(view.state);
|
||||
if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1);
|
||||
}
|
||||
+function safeDispatch(view, tr) {
|
||||
+ try {
|
||||
+ view.dispatch(tr);
|
||||
+ return true;
|
||||
+ } catch (_error) {
|
||||
+ return false;
|
||||
+ }
|
||||
+}
|
||||
+function safeDomAtPos(view, pos) {
|
||||
+ try {
|
||||
+ return view.domAtPos(pos);
|
||||
+ } catch (_error) {
|
||||
+ return null;
|
||||
+ }
|
||||
+}
|
||||
function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
|
||||
var _view$dom$ownerDocume;
|
||||
if (!view.editable) return false;
|
||||
@@ -2451,17 +2466,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
|
||||
if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) return false;
|
||||
const cell = view.state.doc.nodeAt(pluginState.activeHandle);
|
||||
const width = currentColWidth(view, pluginState.activeHandle, cell.attrs);
|
||||
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
|
||||
+ if (width == null) return false;
|
||||
+ if (!safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
|
||||
startX: event.clientX,
|
||||
startWidth: width
|
||||
- } }));
|
||||
+ } }))) return false;
|
||||
function finish(event$1) {
|
||||
win.removeEventListener("mouseup", finish);
|
||||
win.removeEventListener("mousemove", move);
|
||||
const pluginState$1 = columnResizingPluginKey.getState(view.state);
|
||||
if (pluginState$1 === null || pluginState$1 === void 0 ? void 0 : pluginState$1.dragging) {
|
||||
updateColumnWidth(view, pluginState$1.activeHandle, draggedWidth(pluginState$1.dragging, event$1, cellMinWidth));
|
||||
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
|
||||
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
|
||||
}
|
||||
}
|
||||
function move(event$1) {
|
||||
@@ -2482,15 +2498,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
|
||||
function currentColWidth(view, cellPos, { colspan, colwidth }) {
|
||||
const width = colwidth && colwidth[colwidth.length - 1];
|
||||
if (width) return width;
|
||||
- const dom = view.domAtPos(cellPos);
|
||||
- let domWidth = dom.node.childNodes[dom.offset].offsetWidth, parts = colspan;
|
||||
+ const dom = safeDomAtPos(view, cellPos);
|
||||
+ if (!dom) return null;
|
||||
+ const cellDom = dom.node && dom.node.childNodes ? dom.node.childNodes[dom.offset] : null;
|
||||
+ if (!cellDom || typeof cellDom.offsetWidth != "number") return null;
|
||||
+ let domWidth = cellDom.offsetWidth, parts = colspan;
|
||||
if (colwidth) {
|
||||
for (let i = 0; i < colspan; i++) if (colwidth[i]) {
|
||||
domWidth -= colwidth[i];
|
||||
parts--;
|
||||
}
|
||||
}
|
||||
- return domWidth / parts;
|
||||
+ return parts ? domWidth / parts : null;
|
||||
}
|
||||
function domCellAround(target) {
|
||||
while (target && target.nodeName != "TD" && target.nodeName != "TH") target = target.classList && target.classList.contains("ProseMirror") ? null : target.parentNode;
|
||||
@@ -2516,10 +2535,15 @@ function draggedWidth(dragging, event, resizeMinWidth) {
|
||||
return Math.max(resizeMinWidth, dragging.startWidth + offset);
|
||||
}
|
||||
function updateHandle(view, value) {
|
||||
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
|
||||
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
|
||||
}
|
||||
function updateColumnWidth(view, cell, width) {
|
||||
- const $cell = view.state.doc.resolve(cell);
|
||||
+ let $cell;
|
||||
+ try {
|
||||
+ $cell = view.state.doc.resolve(cell);
|
||||
+ } catch (_error) {
|
||||
+ return false;
|
||||
+ }
|
||||
const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1);
|
||||
const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
|
||||
const tr = view.state.tr;
|
||||
@@ -2537,16 +2561,24 @@ function updateColumnWidth(view, cell, width) {
|
||||
colwidth
|
||||
});
|
||||
}
|
||||
- if (tr.docChanged) view.dispatch(tr);
|
||||
+ if (tr.docChanged) return safeDispatch(view, tr);
|
||||
+ return true;
|
||||
}
|
||||
function displayColumnWidth(view, cell, width, defaultCellMinWidth) {
|
||||
- const $cell = view.state.doc.resolve(cell);
|
||||
+ let $cell;
|
||||
+ try {
|
||||
+ $cell = view.state.doc.resolve(cell);
|
||||
+ } catch (_error) {
|
||||
+ return false;
|
||||
+ }
|
||||
const table = $cell.node(-1), start = $cell.start(-1);
|
||||
const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
|
||||
- let dom = view.domAtPos($cell.start(-1)).node;
|
||||
+ const domAtPos = safeDomAtPos(view, $cell.start(-1));
|
||||
+ let dom = domAtPos && domAtPos.node;
|
||||
while (dom && dom.nodeName != "TABLE") dom = dom.parentNode;
|
||||
- if (!dom) return;
|
||||
+ if (!dom) return false;
|
||||
updateColumnsOnResize(table, dom.firstChild, dom, defaultCellMinWidth, col, width);
|
||||
+ return true;
|
||||
}
|
||||
function zeroes(n) {
|
||||
return Array(n).fill(0);
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 5b4ac25594ba5722409332b1e5c812f108c9bf11..5b811ee70ff2fcb0c7587f838f00bc6fc19e906a 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -2443,6 +2443,21 @@ function handleMouseLeave(view) {
|
||||
const pluginState = columnResizingPluginKey.getState(view.state);
|
||||
if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1);
|
||||
}
|
||||
+function safeDispatch(view, tr) {
|
||||
+ try {
|
||||
+ view.dispatch(tr);
|
||||
+ return true;
|
||||
+ } catch (_error) {
|
||||
+ return false;
|
||||
+ }
|
||||
+}
|
||||
+function safeDomAtPos(view, pos) {
|
||||
+ try {
|
||||
+ return view.domAtPos(pos);
|
||||
+ } catch (_error) {
|
||||
+ return null;
|
||||
+ }
|
||||
+}
|
||||
function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
|
||||
var _view$dom$ownerDocume;
|
||||
if (!view.editable) return false;
|
||||
@@ -2451,17 +2466,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
|
||||
if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) return false;
|
||||
const cell = view.state.doc.nodeAt(pluginState.activeHandle);
|
||||
const width = currentColWidth(view, pluginState.activeHandle, cell.attrs);
|
||||
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
|
||||
+ if (width == null) return false;
|
||||
+ if (!safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
|
||||
startX: event.clientX,
|
||||
startWidth: width
|
||||
- } }));
|
||||
+ } }))) return false;
|
||||
function finish(event$1) {
|
||||
win.removeEventListener("mouseup", finish);
|
||||
win.removeEventListener("mousemove", move);
|
||||
const pluginState$1 = columnResizingPluginKey.getState(view.state);
|
||||
if (pluginState$1 === null || pluginState$1 === void 0 ? void 0 : pluginState$1.dragging) {
|
||||
updateColumnWidth(view, pluginState$1.activeHandle, draggedWidth(pluginState$1.dragging, event$1, cellMinWidth));
|
||||
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
|
||||
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
|
||||
}
|
||||
}
|
||||
function move(event$1) {
|
||||
@@ -2482,15 +2498,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
|
||||
function currentColWidth(view, cellPos, { colspan, colwidth }) {
|
||||
const width = colwidth && colwidth[colwidth.length - 1];
|
||||
if (width) return width;
|
||||
- const dom = view.domAtPos(cellPos);
|
||||
- let domWidth = dom.node.childNodes[dom.offset].offsetWidth, parts = colspan;
|
||||
+ const dom = safeDomAtPos(view, cellPos);
|
||||
+ if (!dom) return null;
|
||||
+ const cellDom = dom.node && dom.node.childNodes ? dom.node.childNodes[dom.offset] : null;
|
||||
+ if (!cellDom || typeof cellDom.offsetWidth != "number") return null;
|
||||
+ let domWidth = cellDom.offsetWidth, parts = colspan;
|
||||
if (colwidth) {
|
||||
for (let i = 0; i < colspan; i++) if (colwidth[i]) {
|
||||
domWidth -= colwidth[i];
|
||||
parts--;
|
||||
}
|
||||
}
|
||||
- return domWidth / parts;
|
||||
+ return parts ? domWidth / parts : null;
|
||||
}
|
||||
function domCellAround(target) {
|
||||
while (target && target.nodeName != "TD" && target.nodeName != "TH") target = target.classList && target.classList.contains("ProseMirror") ? null : target.parentNode;
|
||||
@@ -2516,10 +2535,15 @@ function draggedWidth(dragging, event, resizeMinWidth) {
|
||||
return Math.max(resizeMinWidth, dragging.startWidth + offset);
|
||||
}
|
||||
function updateHandle(view, value) {
|
||||
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
|
||||
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
|
||||
}
|
||||
function updateColumnWidth(view, cell, width) {
|
||||
- const $cell = view.state.doc.resolve(cell);
|
||||
+ let $cell;
|
||||
+ try {
|
||||
+ $cell = view.state.doc.resolve(cell);
|
||||
+ } catch (_error) {
|
||||
+ return false;
|
||||
+ }
|
||||
const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1);
|
||||
const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
|
||||
const tr = view.state.tr;
|
||||
@@ -2537,16 +2561,24 @@ function updateColumnWidth(view, cell, width) {
|
||||
colwidth
|
||||
});
|
||||
}
|
||||
- if (tr.docChanged) view.dispatch(tr);
|
||||
+ if (tr.docChanged) return safeDispatch(view, tr);
|
||||
+ return true;
|
||||
}
|
||||
function displayColumnWidth(view, cell, width, defaultCellMinWidth) {
|
||||
- const $cell = view.state.doc.resolve(cell);
|
||||
+ let $cell;
|
||||
+ try {
|
||||
+ $cell = view.state.doc.resolve(cell);
|
||||
+ } catch (_error) {
|
||||
+ return false;
|
||||
+ }
|
||||
const table = $cell.node(-1), start = $cell.start(-1);
|
||||
const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
|
||||
- let dom = view.domAtPos($cell.start(-1)).node;
|
||||
+ const domAtPos = safeDomAtPos(view, $cell.start(-1));
|
||||
+ let dom = domAtPos && domAtPos.node;
|
||||
while (dom && dom.nodeName != "TABLE") dom = dom.parentNode;
|
||||
- if (!dom) return;
|
||||
+ if (!dom) return false;
|
||||
updateColumnsOnResize(table, dom.firstChild, dom, defaultCellMinWidth, col, width);
|
||||
+ return true;
|
||||
}
|
||||
function zeroes(n) {
|
||||
return Array(n).fill(0);
|
||||
9
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ patchedDependencies:
|
||||
'@blocknote/core@0.46.2':
|
||||
hash: 9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec
|
||||
path: patches/@blocknote__core@0.46.2.patch
|
||||
prosemirror-tables@1.8.5:
|
||||
hash: cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b
|
||||
path: patches/prosemirror-tables@1.8.5.patch
|
||||
|
||||
importers:
|
||||
|
||||
@@ -4491,7 +4494,7 @@ snapshots:
|
||||
prosemirror-highlight: 0.13.1(@shikijs/types@3.22.0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.11.0)(prosemirror-view@1.41.6)
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b)
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
rehype-format: 5.0.1
|
||||
@@ -6245,7 +6248,7 @@ snapshots:
|
||||
prosemirror-schema-basic: 1.2.4
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b)
|
||||
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
@@ -8099,7 +8102,7 @@ snapshots:
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
prosemirror-tables@1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b):
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
@@ -6,3 +6,4 @@ ignoredBuiltDependencies:
|
||||
|
||||
patchedDependencies:
|
||||
'@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch
|
||||
prosemirror-tables@1.8.5: patches/prosemirror-tables@1.8.5.patch
|
||||
|
||||
51
src-tauri/Cargo.lock
generated
@@ -1012,6 +1012,12 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
@@ -2150,6 +2156,15 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -2278,6 +2293,7 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-prevent-default",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
@@ -4504,6 +4520,27 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4887,6 +4924,20 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-prevent-default"
|
||||
version = "4.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6260061932cad80647a823d0c5a3633f4eec62160a8f57bad0ab82b537477ef2"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"itertools",
|
||||
"serde",
|
||||
"strum",
|
||||
"tauri",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
|
||||
@@ -35,6 +35,7 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-updater = "2.10.0"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-prevent-default = "4.0.4"
|
||||
sentry = "0.37"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
|
||||
|
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]
|
||||
|
||||
@@ -86,6 +86,7 @@ fn setup_common_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
setup_macos_webview_shortcut_prevention(app)?;
|
||||
app.handle()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
app.handle().plugin(tauri_plugin_process::init())?;
|
||||
@@ -94,6 +95,35 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];
|
||||
|
||||
#[cfg(all(desktop, target_os = "macos"))]
|
||||
fn setup_macos_webview_shortcut_prevention(
|
||||
app: &mut tauri::App,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use tauri_plugin_prevent_default::ModifierKey::{MetaKey, ShiftKey};
|
||||
use tauri_plugin_prevent_default::{Flags, KeyboardShortcut};
|
||||
|
||||
let mut builder = tauri_plugin_prevent_default::Builder::new().with_flags(Flags::empty());
|
||||
|
||||
// WKWebView can swallow some browser-reserved chords before our shared
|
||||
// renderer shortcut handler sees them. Keep this list narrow and verify
|
||||
// every addition with native QA.
|
||||
for key in MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS {
|
||||
builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey, ShiftKey]));
|
||||
}
|
||||
|
||||
app.handle().plugin(builder.build())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(desktop, target_os = "macos")))]
|
||||
fn setup_macos_webview_shortcut_prevention(
|
||||
_app: &mut tauri::App,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
setup_common_plugins(app)?;
|
||||
|
||||
@@ -113,6 +143,77 @@ fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_invoke_handler(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
builder.invoke_handler(tauri::generate_handler![
|
||||
commands::list_vault,
|
||||
commands::list_vault_folders,
|
||||
commands::get_note_content,
|
||||
commands::save_note_content,
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::rename_note_filename,
|
||||
commands::auto_rename_untitled,
|
||||
commands::detect_renames,
|
||||
commands::update_wikilinks_for_renames,
|
||||
commands::get_file_history,
|
||||
commands::get_modified_files,
|
||||
commands::get_file_diff,
|
||||
commands::get_file_diff_at_commit,
|
||||
commands::get_vault_pulse,
|
||||
commands::git_commit,
|
||||
commands::get_build_number,
|
||||
commands::get_last_commit_info,
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::git_remote_status,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
commands::git_commit_conflict_resolution,
|
||||
commands::git_discard_file,
|
||||
commands::is_git_repo,
|
||||
commands::init_git_repo,
|
||||
commands::check_claude_cli,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::sync_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::delete_note,
|
||||
commands::batch_delete_notes,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::create_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::get_settings,
|
||||
commands::update_menu_state,
|
||||
commands::trigger_menu_command,
|
||||
commands::save_settings,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
commands::github_list_repos,
|
||||
commands::github_create_repo,
|
||||
commands::clone_repo,
|
||||
commands::github_device_flow_start,
|
||||
commands::github_device_flow_poll,
|
||||
commands::github_get_user,
|
||||
commands::search_vault,
|
||||
commands::create_empty_vault,
|
||||
commands::create_getting_started_vault,
|
||||
commands::check_vault_exists,
|
||||
commands::get_default_vault_path,
|
||||
commands::register_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
|
||||
use tauri::Manager;
|
||||
@@ -134,75 +235,8 @@ pub fn run() {
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.manage(WsBridgeChild(Mutex::new(None)));
|
||||
|
||||
builder
|
||||
with_invoke_handler(builder)
|
||||
.setup(setup_app)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::list_vault,
|
||||
commands::list_vault_folders,
|
||||
commands::get_note_content,
|
||||
commands::save_note_content,
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::rename_note_filename,
|
||||
commands::auto_rename_untitled,
|
||||
commands::detect_renames,
|
||||
commands::update_wikilinks_for_renames,
|
||||
commands::get_file_history,
|
||||
commands::get_modified_files,
|
||||
commands::get_file_diff,
|
||||
commands::get_file_diff_at_commit,
|
||||
commands::get_vault_pulse,
|
||||
commands::git_commit,
|
||||
commands::get_build_number,
|
||||
commands::get_last_commit_info,
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::git_remote_status,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
commands::git_commit_conflict_resolution,
|
||||
commands::git_discard_file,
|
||||
commands::is_git_repo,
|
||||
commands::init_git_repo,
|
||||
commands::check_claude_cli,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::sync_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::delete_note,
|
||||
commands::batch_delete_notes,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::create_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::get_settings,
|
||||
commands::update_menu_state,
|
||||
commands::save_settings,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
commands::github_list_repos,
|
||||
commands::github_create_repo,
|
||||
commands::clone_repo,
|
||||
commands::github_device_flow_start,
|
||||
commands::github_device_flow_poll,
|
||||
commands::github_get_user,
|
||||
commands::search_vault,
|
||||
commands::create_empty_vault,
|
||||
commands::create_getting_started_vault,
|
||||
commands::check_vault_exists,
|
||||
commands::get_default_vault_path,
|
||||
commands::register_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
@@ -210,3 +244,13 @@ pub fn run() {
|
||||
handle_run_event(app_handle, &event);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
|
||||
|
||||
#[test]
|
||||
fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() {
|
||||
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -100,11 +100,7 @@ fn update_wikilinks_in_vault(
|
||||
Some(r) => r,
|
||||
None => return 0,
|
||||
};
|
||||
replace_wikilinks_in_files(
|
||||
collect_md_files(vault_path, exclude_path),
|
||||
&re,
|
||||
new_target,
|
||||
)
|
||||
replace_wikilinks_in_files(collect_md_files(vault_path, exclude_path), &re, new_target)
|
||||
}
|
||||
|
||||
fn replace_wikilinks_in_files(
|
||||
@@ -621,7 +617,11 @@ mod tests {
|
||||
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");
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -49,6 +49,52 @@ interface BreadcrumbBarProps {
|
||||
}
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
const BREADCRUMB_ICON_CLASS = 'size-[16px]'
|
||||
|
||||
function focusFilenameInput(
|
||||
isEditing: boolean,
|
||||
inputRef: React.RefObject<HTMLInputElement | null>,
|
||||
) {
|
||||
if (!isEditing) return
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}
|
||||
|
||||
function beginFilenameEditing(
|
||||
onRenameFilename: BreadcrumbBarProps['onRenameFilename'],
|
||||
filenameStem: string,
|
||||
setDraftStem: (value: string) => void,
|
||||
setIsEditing: (value: boolean) => void,
|
||||
) {
|
||||
if (!onRenameFilename) return
|
||||
setDraftStem(filenameStem)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
function resolveFilenameRenameTarget(draftStem: string, filenameStem: string): string | null {
|
||||
const nextStem = normalizeFilenameStemInput(draftStem)
|
||||
if (!nextStem || nextStem === filenameStem) return null
|
||||
return nextStem
|
||||
}
|
||||
|
||||
function handleFilenameInputKeyDown(
|
||||
event: KeyboardEvent<HTMLInputElement>,
|
||||
submitRename: () => void,
|
||||
cancelEditing: () => void,
|
||||
) {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
submitRename()
|
||||
return
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
cancelEditing()
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function IconActionButton({
|
||||
title,
|
||||
@@ -74,7 +120,7 @@ function IconActionButton({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn('text-muted-foreground', className)}
|
||||
className={cn('text-muted-foreground [&_svg:not([class*=size-])]:size-4', className)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
@@ -94,7 +140,7 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle
|
||||
onClick={onToggleRaw}
|
||||
className={cn(rawMode ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
<Code size={16} />
|
||||
<Code size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -106,7 +152,7 @@ function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onT
|
||||
onClick={onToggleFavorite}
|
||||
className={cn(favorite ? 'text-yellow-500' : 'hover:text-foreground')}
|
||||
>
|
||||
<Star size={16} weight={favorite ? 'fill' : 'regular'} />
|
||||
<Star size={16} weight={favorite ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -125,7 +171,7 @@ function OrganizedAction({
|
||||
onClick={onToggleOrganized}
|
||||
className={cn(organized ? 'text-green-600' : 'hover:text-foreground')}
|
||||
>
|
||||
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} />
|
||||
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -133,7 +179,7 @@ function OrganizedAction({
|
||||
function SearchAction() {
|
||||
return (
|
||||
<IconActionButton title="Search in file" className="hover:text-foreground">
|
||||
<MagnifyingGlass size={16} />
|
||||
<MagnifyingGlass size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -147,7 +193,7 @@ function DiffAction({
|
||||
if (!showDiffToggle) {
|
||||
return (
|
||||
<IconActionButton title="No changes" style={DISABLED_ICON_STYLE} tabIndex={-1}>
|
||||
<GitBranch size={16} />
|
||||
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -159,7 +205,7 @@ function DiffAction({
|
||||
disabled={diffLoading}
|
||||
className={cn(diffMode ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
<GitBranch size={16} />
|
||||
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -179,7 +225,7 @@ function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, '
|
||||
onClick={onToggleAIChat}
|
||||
className={cn(showAIChat ? 'text-primary' : 'hover:text-foreground')}
|
||||
>
|
||||
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} />
|
||||
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -192,14 +238,14 @@ function ArchiveAction({
|
||||
if (archived) {
|
||||
return (
|
||||
<IconActionButton title="Unarchive" onClick={onUnarchive} className="hover:text-foreground">
|
||||
<ArrowUUpLeft size={16} />
|
||||
<ArrowUUpLeft size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IconActionButton title="Archive" onClick={onArchive} className="hover:text-foreground">
|
||||
<Archive size={16} />
|
||||
<Archive size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -207,7 +253,7 @@ function ArchiveAction({
|
||||
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
|
||||
return (
|
||||
<IconActionButton title="Delete (Cmd+Delete)" onClick={onDelete} className="hover:text-destructive">
|
||||
<Trash size={16} />
|
||||
<Trash size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -219,7 +265,7 @@ function InspectorAction({
|
||||
if (!inspectorCollapsed) return null
|
||||
return (
|
||||
<IconActionButton title="Properties (⌘⇧I)" onClick={onToggleInspector} className="hover:text-foreground">
|
||||
<SlidersHorizontal size={16} />
|
||||
<SlidersHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -358,15 +404,11 @@ function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'en
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) return
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
focusFilenameInput(isEditing, inputRef)
|
||||
}, [isEditing])
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
if (!onRenameFilename) return
|
||||
setDraftStem(filenameStem)
|
||||
setIsEditing(true)
|
||||
beginFilenameEditing(onRenameFilename, filenameStem, setDraftStem, setIsEditing)
|
||||
}, [onRenameFilename, filenameStem])
|
||||
|
||||
const cancelEditing = useCallback(() => {
|
||||
@@ -375,22 +417,14 @@ function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'en
|
||||
}, [filenameStem])
|
||||
|
||||
const submitRename = useCallback(() => {
|
||||
const nextStem = normalizeFilenameStemInput(draftStem)
|
||||
setIsEditing(false)
|
||||
if (!nextStem || nextStem === filenameStem) return
|
||||
const nextStem = resolveFilenameRenameTarget(draftStem, filenameStem)
|
||||
if (!nextStem) return
|
||||
onRenameFilename?.(entry.path, nextStem)
|
||||
}, [draftStem, filenameStem, onRenameFilename, entry.path])
|
||||
|
||||
const handleInputKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
submitRename()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
cancelEditing()
|
||||
}
|
||||
handleFilenameInputKeyDown(event, submitRename, cancelEditing)
|
||||
}, [submitRename, cancelEditing])
|
||||
|
||||
if (isEditing) {
|
||||
@@ -448,14 +482,14 @@ function BreadcrumbActions({
|
||||
/>
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
|
||||
<PlaceholderAction title="Coming soon">
|
||||
<CursorText size={16} />
|
||||
<CursorText size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</PlaceholderAction>
|
||||
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
|
||||
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
|
||||
<DeleteAction onDelete={onDelete} />
|
||||
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
|
||||
<PlaceholderAction title="Coming soon">
|
||||
<DotsThree size={16} />
|
||||
<DotsThree size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</PlaceholderAction>
|
||||
</div>
|
||||
)
|
||||
|
||||
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;')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
@@ -47,6 +47,9 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
|
||||
{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed
|
||||
</Badge>
|
||||
</div>
|
||||
<DialogDescription className="sr-only">
|
||||
Review changed files and enter a commit message before committing and pushing.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -52,6 +52,9 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Note</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Enter a title and choose a type for the new note.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('NoteItem', () => {
|
||||
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} />)
|
||||
|
||||
const dateRow = screen.getByTestId('note-date-row')
|
||||
expect(dateRow.className).toContain('justify-between')
|
||||
expect(dateRow.className).toContain('grid')
|
||||
expect(dateRow).toHaveTextContent('2d ago')
|
||||
expect(dateRow).toHaveTextContent('Created 5d ago')
|
||||
})
|
||||
|
||||
@@ -163,7 +163,7 @@ function StandardNoteContent({
|
||||
<>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
|
||||
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
|
||||
<div className="space-y-2 pr-5" data-testid="note-content-stack">
|
||||
<div className="space-y-2" data-testid="note-content-stack">
|
||||
<NoteTitleRow
|
||||
entry={entry}
|
||||
isBinary={isBinary}
|
||||
@@ -206,7 +206,7 @@ function NoteTitleRow({
|
||||
noteStatus: NoteStatus
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('truncate text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
|
||||
<div className={cn('truncate pr-5 text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
|
||||
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
|
||||
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
|
||||
{entry.title}
|
||||
@@ -222,9 +222,9 @@ function NoteDateRow({ entry }: { entry: VaultEntry }) {
|
||||
if (!modifiedLabel && !createdLabel) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 text-[10px] text-muted-foreground" data-testid="note-date-row">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-[10px] text-muted-foreground" data-testid="note-date-row">
|
||||
<span>{modifiedLabel}</span>
|
||||
{createdLabel && <span className="ml-auto">{createdLabel}</span>}
|
||||
{createdLabel && <span className="justify-self-end text-right">{createdLabel}</span>}
|
||||
</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')
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
455
src/hooks/appCommandCatalog.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
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 AppCommandDeterministicQaMode =
|
||||
| 'renderer-shortcut-event'
|
||||
| 'native-menu-command'
|
||||
type ShortcutEventLike = Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'key' | 'code'>
|
||||
|
||||
export interface AppCommandDeterministicQaDefinition {
|
||||
preferredMode: AppCommandDeterministicQaMode
|
||||
supportsRendererShortcutEvent: boolean
|
||||
supportsNativeMenuCommand: boolean
|
||||
requiresManualNativeAcceleratorQa: boolean
|
||||
}
|
||||
|
||||
export interface AppCommandShortcutEventOptions {
|
||||
preferControl?: boolean
|
||||
}
|
||||
|
||||
export type AppCommandShortcutEventInit = Pick<
|
||||
KeyboardEventInit,
|
||||
'altKey' | 'bubbles' | 'cancelable' | 'code' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'
|
||||
>
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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: '⌘,' },
|
||||
},
|
||||
[APP_COMMAND_IDS.appCheckForUpdates]: {
|
||||
route: { kind: 'handler', handler: 'onCheckForUpdates' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.fileNewNote]: {
|
||||
route: { kind: 'handler', handler: 'onCreateNote' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'n', code: 'KeyN', display: '⌘N' },
|
||||
},
|
||||
[APP_COMMAND_IDS.fileNewType]: {
|
||||
route: { kind: 'handler', handler: 'onCreateType' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.fileDailyNote]: {
|
||||
route: { kind: 'handler', handler: 'onOpenDailyNote' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'j', code: 'KeyJ', display: '⌘J' },
|
||||
},
|
||||
[APP_COMMAND_IDS.fileQuickOpen]: {
|
||||
route: { kind: 'handler', handler: 'onQuickOpen' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'p', code: 'KeyP', display: '⌘P' },
|
||||
},
|
||||
[APP_COMMAND_IDS.fileSave]: {
|
||||
route: { kind: 'handler', handler: 'onSave' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 's', code: 'KeyS', display: '⌘S' },
|
||||
},
|
||||
[APP_COMMAND_IDS.editFindInVault]: {
|
||||
route: { kind: 'handler', handler: 'onSearch' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl-shift', key: 'f', code: 'KeyF', display: '⌘⇧F' },
|
||||
},
|
||||
[APP_COMMAND_IDS.editToggleRawEditor]: {
|
||||
route: { kind: 'handler', handler: 'onToggleRawEditor' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: '\\', display: '⌘\\' },
|
||||
},
|
||||
[APP_COMMAND_IDS.editToggleDiff]: {
|
||||
route: { kind: 'handler', handler: 'onToggleDiff' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.viewEditorOnly]: {
|
||||
route: { kind: 'view-mode', value: 'editor-only' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: '1', display: '⌘1' },
|
||||
},
|
||||
[APP_COMMAND_IDS.viewEditorList]: {
|
||||
route: { kind: 'view-mode', value: 'editor-list' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: '2', display: '⌘2' },
|
||||
},
|
||||
[APP_COMMAND_IDS.viewAll]: {
|
||||
route: { kind: 'view-mode', value: 'all' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: '3', display: '⌘3' },
|
||||
},
|
||||
[APP_COMMAND_IDS.viewToggleProperties]: {
|
||||
route: { kind: 'handler', handler: 'onToggleInspector' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl-shift', key: 'i', code: 'KeyI', display: '⌘⇧I' },
|
||||
},
|
||||
[APP_COMMAND_IDS.viewToggleAiChat]: {
|
||||
route: { kind: 'handler', handler: 'onToggleAIChat' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-shift', key: 'l', code: 'KeyL', display: '⌘⇧L' },
|
||||
},
|
||||
[APP_COMMAND_IDS.viewToggleBacklinks]: {
|
||||
route: { kind: 'handler', handler: 'onToggleInspector' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.viewCommandPalette]: {
|
||||
route: { kind: 'handler', handler: 'onCommandPalette' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'k', code: 'KeyK', display: '⌘K' },
|
||||
},
|
||||
[APP_COMMAND_IDS.viewZoomIn]: {
|
||||
route: { kind: 'handler', handler: 'onZoomIn' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: '=', aliases: ['+'], display: '⌘=' },
|
||||
},
|
||||
[APP_COMMAND_IDS.viewZoomOut]: {
|
||||
route: { kind: 'handler', handler: 'onZoomOut' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: '-', display: '⌘-' },
|
||||
},
|
||||
[APP_COMMAND_IDS.viewZoomReset]: {
|
||||
route: { kind: 'handler', handler: 'onZoomReset' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: '0', display: '⌘0' },
|
||||
},
|
||||
[APP_COMMAND_IDS.viewGoBack]: {
|
||||
route: { kind: 'handler', handler: 'onGoBack' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: '[', display: '⌘[' },
|
||||
},
|
||||
[APP_COMMAND_IDS.viewGoForward]: {
|
||||
route: { kind: 'handler', handler: 'onGoForward' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: ']', display: '⌘]' },
|
||||
},
|
||||
[APP_COMMAND_IDS.goAllNotes]: {
|
||||
route: { kind: 'filter', value: 'all' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.goArchived]: {
|
||||
route: { kind: 'filter', value: 'archived' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.goChanges]: {
|
||||
route: { kind: 'filter', value: 'changes' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.goInbox]: {
|
||||
route: { kind: 'filter', value: 'inbox' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.noteToggleOrganized]: {
|
||||
route: { kind: 'active-tab-handler', handler: 'onToggleOrganized' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'e', code: 'KeyE', display: '⌘E' },
|
||||
},
|
||||
[APP_COMMAND_IDS.noteToggleFavorite]: {
|
||||
route: { kind: 'active-tab-handler', handler: 'onToggleFavorite' },
|
||||
menuOwned: false,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'd', code: 'KeyD', display: '⌘D' },
|
||||
},
|
||||
[APP_COMMAND_IDS.noteArchive]: {
|
||||
route: { kind: 'active-tab-handler', handler: 'onArchiveNote' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.noteDelete]: {
|
||||
route: { kind: 'active-tab-handler', handler: 'onDeleteNote' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'Backspace', aliases: ['Delete'], display: '⌘⌫' },
|
||||
},
|
||||
[APP_COMMAND_IDS.noteOpenInNewWindow]: {
|
||||
route: { kind: 'handler', handler: 'onOpenInNewWindow' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl-shift', key: 'o', code: 'KeyO', display: '⌘⇧O' },
|
||||
},
|
||||
[APP_COMMAND_IDS.noteRestoreDeleted]: {
|
||||
route: { kind: 'handler', handler: 'onRestoreDeletedNote' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.vaultOpen]: {
|
||||
route: { kind: 'handler', handler: 'onOpenVault' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.vaultRemove]: {
|
||||
route: { kind: 'handler', handler: 'onRemoveActiveVault' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.vaultRestoreGettingStarted]: {
|
||||
route: { kind: 'handler', handler: 'onRestoreGettingStarted' },
|
||||
menuOwned: true,
|
||||
},
|
||||
[APP_COMMAND_IDS.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 MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET = new Set<AppCommandId>([
|
||||
APP_COMMAND_IDS.appSettings,
|
||||
APP_COMMAND_IDS.fileNewNote,
|
||||
APP_COMMAND_IDS.fileDailyNote,
|
||||
APP_COMMAND_IDS.fileQuickOpen,
|
||||
APP_COMMAND_IDS.fileSave,
|
||||
APP_COMMAND_IDS.editFindInVault,
|
||||
APP_COMMAND_IDS.viewToggleProperties,
|
||||
APP_COMMAND_IDS.viewToggleAiChat,
|
||||
APP_COMMAND_IDS.viewCommandPalette,
|
||||
APP_COMMAND_IDS.noteToggleOrganized,
|
||||
APP_COMMAND_IDS.noteToggleFavorite,
|
||||
])
|
||||
|
||||
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 getDeterministicShortcutQaDefinition(
|
||||
id: AppCommandId,
|
||||
): AppCommandDeterministicQaDefinition | null {
|
||||
const definition = APP_COMMAND_DEFINITIONS[id]
|
||||
if (!definition.shortcut) return null
|
||||
|
||||
return {
|
||||
preferredMode: definition.menuOwned ? 'native-menu-command' : 'renderer-shortcut-event',
|
||||
supportsRendererShortcutEvent: true,
|
||||
supportsNativeMenuCommand: definition.menuOwned,
|
||||
requiresManualNativeAcceleratorQa: MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET.has(id),
|
||||
}
|
||||
}
|
||||
|
||||
export function getShortcutEventInit(
|
||||
id: AppCommandId,
|
||||
options: AppCommandShortcutEventOptions = {},
|
||||
): AppCommandShortcutEventInit | null {
|
||||
const shortcut = APP_COMMAND_DEFINITIONS[id].shortcut
|
||||
if (!shortcut) return null
|
||||
|
||||
const useControl = options.preferControl ?? false
|
||||
|
||||
return {
|
||||
key: shortcut.key,
|
||||
code: shortcut.code,
|
||||
altKey: false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey: useControl,
|
||||
metaKey: !useControl,
|
||||
shiftKey: shortcut.combo !== 'command-or-ctrl',
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
206
src/hooks/appCommandDispatcher.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
APP_COMMAND_IDS,
|
||||
dispatchAppCommand,
|
||||
executeAppCommand,
|
||||
findShortcutCommandId,
|
||||
findShortcutCommandIdForEvent,
|
||||
isAppCommandId,
|
||||
isNativeMenuCommandId,
|
||||
resetAppCommandDispatchStateForTests,
|
||||
type AppCommandHandlers,
|
||||
} from './appCommandDispatcher'
|
||||
import {
|
||||
getDeterministicShortcutQaDefinition,
|
||||
getShortcutEventInit,
|
||||
} from './appCommandCatalog'
|
||||
|
||||
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', () => {
|
||||
afterEach(() => {
|
||||
resetAppCommandDispatchStateForTests()
|
||||
})
|
||||
|
||||
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('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('gives every shortcut command an explicit deterministic QA strategy', () => {
|
||||
expect(getDeterministicShortcutQaDefinition(APP_COMMAND_IDS.fileNewNote)).toMatchObject({
|
||||
preferredMode: 'native-menu-command',
|
||||
supportsRendererShortcutEvent: true,
|
||||
supportsNativeMenuCommand: true,
|
||||
requiresManualNativeAcceleratorQa: true,
|
||||
})
|
||||
expect(getDeterministicShortcutQaDefinition(APP_COMMAND_IDS.noteToggleFavorite)).toMatchObject({
|
||||
preferredMode: 'renderer-shortcut-event',
|
||||
supportsRendererShortcutEvent: true,
|
||||
supportsNativeMenuCommand: false,
|
||||
requiresManualNativeAcceleratorQa: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('builds deterministic keyboard events from the shared shortcut manifest', () => {
|
||||
expect(getShortcutEventInit(APP_COMMAND_IDS.viewToggleAiChat)).toMatchObject({
|
||||
key: 'l',
|
||||
code: 'KeyL',
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
shiftKey: true,
|
||||
})
|
||||
expect(getShortcutEventInit(APP_COMMAND_IDS.viewToggleAiChat, { preferControl: true })).toMatchObject({
|
||||
key: 'l',
|
||||
code: 'KeyL',
|
||||
metaKey: false,
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
})
|
||||
expect(getShortcutEventInit(APP_COMMAND_IDS.appCheckForUpdates)).toBeNull()
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('suppresses a native-menu echo after renderer keyboard dispatch', () => {
|
||||
const handlers = makeHandlers()
|
||||
|
||||
expect(executeAppCommand(APP_COMMAND_IDS.viewToggleProperties, handlers, 'renderer-keyboard')).toBe(true)
|
||||
expect(executeAppCommand(APP_COMMAND_IDS.viewToggleProperties, handlers, 'native-menu')).toBe(false)
|
||||
expect(handlers.onToggleInspector).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('suppresses a renderer keyboard echo after native-menu dispatch', () => {
|
||||
const handlers = makeHandlers()
|
||||
|
||||
expect(executeAppCommand(APP_COMMAND_IDS.viewToggleAiChat, handlers, 'native-menu')).toBe(true)
|
||||
expect(executeAppCommand(APP_COMMAND_IDS.viewToggleAiChat, handlers, 'renderer-keyboard')).toBe(false)
|
||||
expect(handlers.onToggleAIChat).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
235
src/hooks/appCommandDispatcher.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
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,
|
||||
} from './appCommandCatalog'
|
||||
export type { AppCommandDefinition, AppCommandId, AppCommandShortcutCombo } from './appCommandCatalog'
|
||||
|
||||
export type AppCommandDispatchSource =
|
||||
| 'direct'
|
||||
| 'renderer-keyboard'
|
||||
| 'native-menu'
|
||||
| 'app-event'
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
const SHORTCUT_ECHO_DEDUPE_WINDOW_MS = 150
|
||||
let lastCommandDispatch:
|
||||
| {
|
||||
id: AppCommandId
|
||||
source: AppCommandDispatchSource
|
||||
timestamp: number
|
||||
}
|
||||
| null = null
|
||||
|
||||
function now(): number {
|
||||
return globalThis.performance?.now?.() ?? Date.now()
|
||||
}
|
||||
|
||||
function isShortcutEchoPair(a: AppCommandDispatchSource, b: AppCommandDispatchSource): boolean {
|
||||
return (
|
||||
(a === 'renderer-keyboard' && b === 'native-menu') ||
|
||||
(a === 'native-menu' && b === 'renderer-keyboard')
|
||||
)
|
||||
}
|
||||
|
||||
function shouldSuppressDuplicateCommand(
|
||||
id: AppCommandId,
|
||||
source: AppCommandDispatchSource,
|
||||
currentTimestamp: number,
|
||||
): boolean {
|
||||
if (!lastCommandDispatch || lastCommandDispatch.id !== id) return false
|
||||
if (!isShortcutEchoPair(source, lastCommandDispatch.source)) return false
|
||||
return currentTimestamp - lastCommandDispatch.timestamp <= SHORTCUT_ECHO_DEDUPE_WINDOW_MS
|
||||
}
|
||||
|
||||
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 executeAppCommand(id, handlers, 'direct')
|
||||
}
|
||||
|
||||
export function executeAppCommand(
|
||||
id: AppCommandId,
|
||||
handlers: AppCommandHandlers,
|
||||
source: AppCommandDispatchSource,
|
||||
): boolean {
|
||||
const timestamp = now()
|
||||
if (shouldSuppressDuplicateCommand(id, source, timestamp)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dispatched = dispatchDefinition(APP_COMMAND_DEFINITIONS[id], handlers)
|
||||
if (dispatched) {
|
||||
lastCommandDispatch = { id, source, timestamp }
|
||||
}
|
||||
return dispatched
|
||||
}
|
||||
|
||||
export function resetAppCommandDispatchStateForTests(): void {
|
||||
lastCommandDispatch = null
|
||||
}
|
||||
@@ -1,43 +1,39 @@
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import {
|
||||
APP_COMMAND_IDS,
|
||||
executeAppCommand,
|
||||
findShortcutCommandIdForEvent,
|
||||
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 +41,14 @@ 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
|
||||
|
||||
event.preventDefault()
|
||||
if (commandId === APP_COMMAND_IDS.editFindInVault) {
|
||||
trackEvent('search_used')
|
||||
}
|
||||
executeAppCommand(commandId, actions, 'renderer-keyboard')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useAppKeyboard } from './useAppKeyboard'
|
||||
import { resetAppCommandDispatchStateForTests } from './appCommandDispatcher'
|
||||
|
||||
function fireKey(
|
||||
key: string,
|
||||
@@ -48,7 +49,11 @@ function makeActions() {
|
||||
}
|
||||
|
||||
describe('useAppKeyboard', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
afterEach(() => {
|
||||
delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__
|
||||
resetAppCommandDispatchStateForTests()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('Cmd+1 sets view mode to editor-only', () => {
|
||||
const actions = makeActions()
|
||||
@@ -92,6 +97,41 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onCreateNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+N still works in Tauri mode', () => {
|
||||
const actions = makeActions()
|
||||
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('n', { metaKey: true })
|
||||
expect(actions.onCreateNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+\\ still works in Tauri mode', () => {
|
||||
const actions = makeActions()
|
||||
actions.onToggleRawEditor = vi.fn()
|
||||
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('\\', { metaKey: true })
|
||||
expect(actions.onToggleRawEditor).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+I still works in Tauri mode', () => {
|
||||
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).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+L still works in Tauri mode', () => {
|
||||
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).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+D triggers toggle favorite on active note', () => {
|
||||
const actions = makeActions()
|
||||
actions.onToggleFavorite = vi.fn()
|
||||
@@ -100,6 +140,15 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
|
||||
})
|
||||
|
||||
it('Cmd+D still works in Tauri mode', () => {
|
||||
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))
|
||||
@@ -108,6 +157,18 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onArchiveNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+E still works when editor focus stops propagation', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleOrganized = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleOrganized }))
|
||||
withFocusedContentEditable((editable) => {
|
||||
editable.addEventListener('keydown', (event) => event.stopPropagation())
|
||||
fireKeyOnTarget(editable, 'e', { metaKey: true })
|
||||
expect(onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
|
||||
expect(actions.onArchiveNote).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+J triggers open daily note', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import type { SetStateAction } from 'react'
|
||||
import { useAppSave } from './useAppSave'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
@@ -22,6 +23,7 @@ describe('useAppSave', () => {
|
||||
const deps = {
|
||||
updateEntry: vi.fn(),
|
||||
setTabs: vi.fn(),
|
||||
handleSwitchTab: vi.fn(),
|
||||
setToastMessage: vi.fn(),
|
||||
loadModifiedFiles: vi.fn(),
|
||||
clearUnsaved: vi.fn(),
|
||||
@@ -147,6 +149,44 @@ describe('useAppSave', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('switches the active tab to the renamed path after untitled H1 auto-rename', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
|
||||
const oldPath = '/vault/untitled-note-123.md'
|
||||
const newPath = '/vault/fresh-title.md'
|
||||
const entry = makeEntry(oldPath, 'Untitled Note 123', 'untitled-note-123.md')
|
||||
let tabsState = [{ entry, content: '# Fresh Title\n\nBody' }]
|
||||
const setTabs = vi.fn((updater: SetStateAction<typeof tabsState>) => {
|
||||
tabsState = typeof updater === 'function' ? updater(tabsState) : updater
|
||||
})
|
||||
|
||||
vi.mocked(invoke).mockImplementation(async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'save_note_content') return undefined
|
||||
if (command === 'auto_rename_untitled') return { new_path: newPath, updated_files: 0 }
|
||||
if (command === 'reload_vault_entry') return makeEntry(newPath, 'Fresh Title', 'fresh-title.md')
|
||||
if (command === 'get_note_content' && args?.path === newPath) return '# Fresh Title\n\nBody'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const { result } = renderSave({
|
||||
setTabs,
|
||||
tabs: tabsState,
|
||||
activeTabPath: oldPath,
|
||||
unsavedPaths: new Set([oldPath]),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody')
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
})
|
||||
|
||||
expect(deps.handleSwitchTab).toHaveBeenCalledWith(newPath)
|
||||
expect(tabsState[0].entry.path).toBe(newPath)
|
||||
expect(tabsState[0].entry.filename).toBe('fresh-title.md')
|
||||
expect(tabsState[0].content).toBe('# Fresh Title\n\nBody')
|
||||
})
|
||||
|
||||
it('cancels a pending untitled auto-rename when the user navigates away', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
|
||||
@@ -92,6 +92,10 @@ function pendingRenameOutsideActiveTab(
|
||||
async function reloadAutoRenamedNote(
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
tabs: TabState[],
|
||||
activeTabPath: string | null,
|
||||
setTabs: AppSaveDeps['setTabs'],
|
||||
handleSwitchTab: AppSaveDeps['handleSwitchTab'],
|
||||
replaceEntry: AppSaveDeps['replaceEntry'],
|
||||
loadModifiedFiles: AppSaveDeps['loadModifiedFiles'],
|
||||
): Promise<void> {
|
||||
@@ -99,13 +103,31 @@ async function reloadAutoRenamedNote(
|
||||
invoke<VaultEntry>('reload_vault_entry', { path: newPath }),
|
||||
invoke<string>('get_note_content', { path: newPath }),
|
||||
])
|
||||
|
||||
const otherTabPaths = tabs
|
||||
.filter((tab) => tab.entry.path !== oldPath && tab.entry.path !== newPath)
|
||||
.map((tab) => tab.entry.path)
|
||||
|
||||
setTabs((prev: TabState[]) => prev.map((tab) => (
|
||||
tab.entry.path === oldPath
|
||||
? { entry: { ...tab.entry, ...newEntry, path: newPath }, content: newContent }
|
||||
: tab
|
||||
)))
|
||||
if (activeTabPath === oldPath) handleSwitchTab(newPath)
|
||||
replaceEntry(oldPath, { ...newEntry, path: newPath }, newContent)
|
||||
await Promise.all(otherTabPaths.map(async (path) => {
|
||||
const content = await invoke<string>('get_note_content', { path })
|
||||
setTabs((prev: TabState[]) => prev.map((tab) => (
|
||||
tab.entry.path === path ? { ...tab, content } : tab
|
||||
)))
|
||||
}))
|
||||
loadModifiedFiles()
|
||||
}
|
||||
|
||||
interface AppSaveDeps {
|
||||
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
|
||||
setTabs: Parameters<typeof useEditorSaveWithLinks>[0]['setTabs']
|
||||
handleSwitchTab: (path: string) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
loadModifiedFiles: () => void
|
||||
reloadViews?: () => Promise<void>
|
||||
@@ -119,7 +141,7 @@ interface AppSaveDeps {
|
||||
}
|
||||
|
||||
export function useAppSave({
|
||||
updateEntry, setTabs, setToastMessage,
|
||||
updateEntry, setTabs, handleSwitchTab, setToastMessage,
|
||||
loadModifiedFiles, reloadViews, clearUnsaved, unsavedPaths,
|
||||
tabs, activeTabPath,
|
||||
handleRenameNote, replaceEntry, resolvedPath,
|
||||
@@ -142,12 +164,21 @@ export function useAppSave({
|
||||
notePath: path,
|
||||
})
|
||||
if (!result) return false
|
||||
await reloadAutoRenamedNote(path, result.new_path, replaceEntry, loadModifiedFiles)
|
||||
await reloadAutoRenamedNote(
|
||||
path,
|
||||
result.new_path,
|
||||
tabs,
|
||||
activeTabPath,
|
||||
setTabs,
|
||||
handleSwitchTab,
|
||||
replaceEntry,
|
||||
loadModifiedFiles,
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [resolvedPath, replaceEntry, loadModifiedFiles])
|
||||
}, [resolvedPath, tabs, activeTabPath, setTabs, handleSwitchTab, replaceEntry, loadModifiedFiles])
|
||||
|
||||
const flushPendingUntitledRename = useCallback(async (path?: string) => {
|
||||
const pending = takePendingRename(pendingUntitledRenameRef, path)
|
||||
|
||||
@@ -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,159 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import type { SidebarFilter } from '../types'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
import type { AppCommandShortcutEventInit, AppCommandShortcutEventOptions } from './appCommandCatalog'
|
||||
import {
|
||||
APP_COMMAND_EVENT_NAME,
|
||||
executeAppCommand,
|
||||
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
|
||||
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
|
||||
dispatchBrowserMenuCommand?: (id: string) => void
|
||||
triggerMenuCommand?: (id: string) => Promise<unknown>
|
||||
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
executeAppCommand(detail, handlersRef.current, 'app-event')
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
executeAppCommand(id, h, 'native-menu')
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
72
src/main.tsx
@@ -3,6 +3,28 @@ 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'
|
||||
import {
|
||||
getShortcutEventInit,
|
||||
type AppCommandShortcutEventInit,
|
||||
type AppCommandShortcutEventOptions,
|
||||
} from './hooks/appCommandCatalog'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__laputaTest?: {
|
||||
dispatchAppCommand?: (id: string) => void
|
||||
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
|
||||
dispatchBrowserMenuCommand?: (id: string) => void
|
||||
triggerMenuCommand?: (id: string) => Promise<unknown>
|
||||
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
|
||||
// at native level before React's synthetic events can call preventDefault).
|
||||
@@ -12,6 +34,56 @@ if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) {
|
||||
document.addEventListener('contextmenu', (e) => e.preventDefault(), true)
|
||||
}
|
||||
|
||||
function dispatchDeterministicShortcutEvent(init: AppCommandShortcutEventInit) {
|
||||
const target =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: document.body ?? window
|
||||
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', init))
|
||||
}
|
||||
|
||||
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 }))
|
||||
},
|
||||
dispatchShortcutEvent(init: AppCommandShortcutEventInit) {
|
||||
dispatchDeterministicShortcutEvent(init)
|
||||
},
|
||||
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
|
||||
},
|
||||
triggerShortcutCommand(id: string, options?: AppCommandShortcutEventOptions) {
|
||||
if (!isAppCommandId(id)) {
|
||||
throw new Error(`Unknown app command: ${id}`)
|
||||
}
|
||||
|
||||
const init = getShortcutEventInit(id, options)
|
||||
if (!init) {
|
||||
throw new Error(`Command ${id} does not define a keyboard shortcut`)
|
||||
}
|
||||
|
||||
dispatchDeterministicShortcutEvent(init)
|
||||
},
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -190,6 +190,43 @@ function handleRenameNote(args: { vault_path: string; old_path: string; new_titl
|
||||
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 }
|
||||
}
|
||||
|
||||
const trimOrNull = (v: string | null | undefined): string | null => v?.trim() || null
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
|
||||
@@ -275,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' },
|
||||
|
||||
@@ -4,6 +4,9 @@ import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault')
|
||||
const FIXTURE_VAULT_READY_TIMEOUT = 30_000
|
||||
const FIXTURE_VAULT_REMOVE_RETRIES = 10
|
||||
const FIXTURE_VAULT_REMOVE_RETRY_DELAY_MS = 100
|
||||
|
||||
function copyDirSync(src: string, dest: string): void {
|
||||
fs.mkdirSync(dest, { recursive: true })
|
||||
@@ -26,7 +29,12 @@ export function createFixtureVaultCopy(): string {
|
||||
|
||||
export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined): void {
|
||||
if (!tempVaultDir) return
|
||||
fs.rmSync(tempVaultDir, { recursive: true, force: true })
|
||||
fs.rmSync(tempVaultDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: FIXTURE_VAULT_REMOVE_RETRIES,
|
||||
retryDelay: FIXTURE_VAULT_REMOVE_RETRY_DELAY_MS,
|
||||
})
|
||||
}
|
||||
|
||||
export async function openFixtureVault(
|
||||
@@ -54,29 +62,338 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser harness for desktop command-routing tests.
|
||||
*
|
||||
* This stubs the Tauri invoke bridge inside Playwright so tests can exercise
|
||||
* renderer shortcut dispatch and desktop menu-command dispatch without a native
|
||||
* shell. It is deterministic, but it is not a substitute for real native QA.
|
||||
*/
|
||||
export async function openFixtureVaultDesktopHarness(
|
||||
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 FRONTMATTER_OPEN = '---\n'
|
||||
const FRONTMATTER_CLOSE = '\n---\n'
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const splitFrontmatter = (content: string) => {
|
||||
if (!content.startsWith(FRONTMATTER_OPEN)) {
|
||||
return { frontmatter: null as string | null, body: content }
|
||||
}
|
||||
|
||||
const closeIndex = content.indexOf(FRONTMATTER_CLOSE, FRONTMATTER_OPEN.length)
|
||||
if (closeIndex === -1) {
|
||||
return { frontmatter: null as string | null, body: content }
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter: content.slice(FRONTMATTER_OPEN.length, closeIndex),
|
||||
body: content.slice(closeIndex + FRONTMATTER_CLOSE.length),
|
||||
}
|
||||
}
|
||||
|
||||
const splitFrontmatterEntries = (frontmatter: string) => {
|
||||
const lines = frontmatter.split('\n')
|
||||
const entries: Array<{ key: string; lines: string[] }> = []
|
||||
let current: { key: string; lines: string[] } | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([^:\n]+):(.*)$/)
|
||||
if (match && !line.startsWith(' ')) {
|
||||
if (current) entries.push(current)
|
||||
current = { key: match[1].trim(), lines: [line] }
|
||||
continue
|
||||
}
|
||||
|
||||
if (current) {
|
||||
current.lines.push(line)
|
||||
} else if (line.trim() !== '') {
|
||||
current = { key: '', lines: [line] }
|
||||
}
|
||||
}
|
||||
|
||||
if (current) entries.push(current)
|
||||
return entries
|
||||
}
|
||||
|
||||
const serializeFrontmatterValue = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return ['[]']
|
||||
return [''].concat(value.map((item) => ` - ${JSON.stringify(String(item))}`))
|
||||
}
|
||||
if (typeof value === 'boolean' || typeof value === 'number') {
|
||||
return [String(value)]
|
||||
}
|
||||
return [JSON.stringify(String(value ?? ''))]
|
||||
}
|
||||
|
||||
const replaceFrontmatterEntry = (content: string, key: string, value: unknown) => {
|
||||
const { frontmatter, body } = splitFrontmatter(content)
|
||||
const entryLines = serializeFrontmatterValue(value)
|
||||
const nextEntryLines =
|
||||
entryLines[0] === ''
|
||||
? [`${key}:`, ...entryLines.slice(1)]
|
||||
: [`${key}: ${entryLines[0]}`]
|
||||
|
||||
if (frontmatter === null) {
|
||||
return `${FRONTMATTER_OPEN}${nextEntryLines.join('\n')}${FRONTMATTER_CLOSE}${body}`
|
||||
}
|
||||
|
||||
const entries = splitFrontmatterEntries(frontmatter).filter((entry) => entry.key !== '')
|
||||
const keyPattern = new RegExp(`^${escapeRegExp(key)}$`)
|
||||
let replaced = false
|
||||
const nextEntries = entries.map((entry) => {
|
||||
if (!keyPattern.test(entry.key)) return entry
|
||||
replaced = true
|
||||
return { key, lines: nextEntryLines }
|
||||
})
|
||||
|
||||
if (!replaced) {
|
||||
nextEntries.push({ key, lines: nextEntryLines })
|
||||
}
|
||||
|
||||
return `${FRONTMATTER_OPEN}${nextEntries.flatMap((entry) => entry.lines).join('\n')}${FRONTMATTER_CLOSE}${body}`
|
||||
}
|
||||
|
||||
const removeFrontmatterEntry = (content: string, key: string) => {
|
||||
const { frontmatter, body } = splitFrontmatter(content)
|
||||
if (frontmatter === null) return content
|
||||
|
||||
const keyPattern = new RegExp(`^${escapeRegExp(key)}$`)
|
||||
const nextEntries = splitFrontmatterEntries(frontmatter)
|
||||
.filter((entry) => entry.key !== '' && !keyPattern.test(entry.key))
|
||||
|
||||
if (nextEntries.length === 0) {
|
||||
return body
|
||||
}
|
||||
|
||||
return `${FRONTMATTER_OPEN}${nextEntries.flatMap((entry) => entry.lines).join('\n')}${FRONTMATTER_CLOSE}${body}`
|
||||
}
|
||||
|
||||
const persistFrontmatterChange = async (path: string, transform: (content: string) => string) => {
|
||||
const current = await readJson(`/api/vault/content?path=${encodeURIComponent(path)}`) as { content: string }
|
||||
const updatedContent = transform(current.content)
|
||||
await readJson('/api/vault/save', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ path, content: updatedContent }),
|
||||
})
|
||||
return updatedContent
|
||||
}
|
||||
|
||||
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 'update_frontmatter':
|
||||
return persistFrontmatterChange(
|
||||
String(args?.path ?? ''),
|
||||
(content) => replaceFrontmatterEntry(content, String(args?.key ?? ''), args?.value),
|
||||
)
|
||||
case 'delete_frontmatter_property':
|
||||
return persistFrontmatterChange(
|
||||
String(args?.path ?? ''),
|
||||
(content) => removeFrontmatterEntry(content, String(args?.key ?? '')),
|
||||
)
|
||||
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__))
|
||||
}
|
||||
|
||||
export const openFixtureVaultTauri = openFixtureVaultDesktopHarness
|
||||
|
||||
@@ -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]]')
|
||||
})
|
||||
|
||||
|
||||
47
tests/smoke/breadcrumb-icon-size.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
async function expectIconSize(pageTitle: string, page: Page) {
|
||||
const icon = page.locator(`button[title="${pageTitle}"] svg`)
|
||||
await expect(icon).toBeVisible({ timeout: 5_000 })
|
||||
const box = await icon.boundingBox()
|
||||
expect(box?.width).toBeGreaterThanOrEqual(15)
|
||||
expect(box?.width).toBeLessThanOrEqual(17)
|
||||
expect(box?.height).toBeGreaterThanOrEqual(15)
|
||||
expect(box?.height).toBeLessThanOrEqual(17)
|
||||
}
|
||||
|
||||
async function selectAlphaProject(page: Page) {
|
||||
await expect(async () => {
|
||||
const note = page
|
||||
.locator('[data-testid="note-list-container"]')
|
||||
.getByText('Alpha Project', { exact: true })
|
||||
.first()
|
||||
await expect(note).toBeVisible({ timeout: 5_000 })
|
||||
await note.click({ timeout: 5_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
test.describe('Breadcrumb action icon size regression', () => {
|
||||
test.beforeEach(() => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('breadcrumb action icons render at the pre-regression 16px size', async ({ page }) => {
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
await selectAlphaProject(page)
|
||||
|
||||
await expect(page.locator('.breadcrumb-bar')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await expectIconSize('Search in file', page)
|
||||
await expectIconSize('Raw editor', page)
|
||||
await expectIconSize('Open AI Chat', page)
|
||||
await expectIconSize('Archive', page)
|
||||
})
|
||||
})
|
||||
110
tests/smoke/h1-untitled-auto-rename.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import fs from 'fs'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { triggerMenuCommand } from './testBridge'
|
||||
|
||||
const KNOWN_EDITOR_ERRORS = ['isConnected']
|
||||
|
||||
function isKnownEditorError(message: string): boolean {
|
||||
return KNOWN_EDITOR_ERRORS.some((known) => message.includes(known))
|
||||
}
|
||||
|
||||
function markdownFiles(vaultPath: string): string[] {
|
||||
return fs.readdirSync(vaultPath).filter((name) => name.endsWith('.md')).sort()
|
||||
}
|
||||
|
||||
function slugifyTitle(title: string): string {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
}
|
||||
|
||||
async function createUntitledNote(page: Page): Promise<void> {
|
||||
await triggerMenuCommand(page, 'file-new-note')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+(?:-\d+)?/i, {
|
||||
timeout: 5_000,
|
||||
})
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
|
||||
}), {
|
||||
timeout: 5_000,
|
||||
}).toBe(true)
|
||||
}
|
||||
|
||||
async function writeNewHeading(page: Page, title: string): Promise<void> {
|
||||
await page.keyboard.type(`# ${title}`)
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function expectRenamedFile(vaultPath: string, filename: string): Promise<void> {
|
||||
await expect(async () => {
|
||||
expect(markdownFiles(vaultPath)).toContain(filename)
|
||||
}).toPass({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function expectFileContentContains(vaultPath: string, filename: string, text: string): Promise<void> {
|
||||
await expect(async () => {
|
||||
const content = fs.readFileSync(`${vaultPath}/${filename}`, 'utf-8')
|
||||
expect(content).toContain(text)
|
||||
}).toPass({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function expectActiveFilename(page: Page, filenameStem: string): Promise<void> {
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(filenameStem, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function expectEditorFocused(page: Page): Promise<void> {
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
|
||||
}), {
|
||||
timeout: 5_000,
|
||||
}).toBe(true)
|
||||
}
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(90_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVaultTauri(page, tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('@smoke new-note H1 auto-rename keeps the editor usable and leaves no untitled duplicates', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => {
|
||||
if (!isKnownEditorError(err.message)) errors.push(err.message)
|
||||
})
|
||||
|
||||
const titles = [
|
||||
'Fresh Focus Title',
|
||||
'Rapid Rename 2',
|
||||
'Rapid Rename 3',
|
||||
'Rapid Rename 4',
|
||||
'Rapid Rename 5',
|
||||
]
|
||||
|
||||
for (const [index, title] of titles.entries()) {
|
||||
await createUntitledNote(page)
|
||||
await expectEditorFocused(page)
|
||||
await writeNewHeading(page, title)
|
||||
await expectActiveFilename(page, slugifyTitle(title))
|
||||
await expectRenamedFile(tempVaultDir, `${slugifyTitle(title)}.md`)
|
||||
await expectEditorFocused(page)
|
||||
|
||||
if (index === 0) {
|
||||
await page.keyboard.type(' focus-probe')
|
||||
await expectFileContentContains(tempVaultDir, 'fresh-focus-title.md', 'focus-probe')
|
||||
}
|
||||
}
|
||||
|
||||
const files = markdownFiles(tempVaultDir)
|
||||
expect(files).toContain('fresh-focus-title.md')
|
||||
expect(files.filter((name) => name.startsWith('untitled-note-'))).toEqual([])
|
||||
expect(files.filter((name) => /^rapid-rename-\d+\.md$/.test(name))).toHaveLength(4)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
100
tests/smoke/keyboard-command-routing.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog'
|
||||
import {
|
||||
dispatchShortcutEvent,
|
||||
triggerMenuCommand,
|
||||
triggerShortcutCommand,
|
||||
} from './testBridge'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVaultDesktopHarness,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../helpers/fixtureVault'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
test.describe('keyboard command routing', () => {
|
||||
test.beforeEach(() => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('desktop menu-command bridge creates a note through the shared command path @smoke', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await triggerMenuCommand(page, APP_COMMAND_IDS.fileNewNote)
|
||||
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i, { timeout: 5_000 })
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('desktop menu-command bridge toggles the properties panel through the shared command path @smoke', async ({ page }) => {
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.getByText('Alpha Project', { exact: true }).first().click()
|
||||
await page.locator('.bn-editor').click()
|
||||
|
||||
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleProperties)
|
||||
await expect(page.getByTitle('Close Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleProperties)
|
||||
await expect(page.getByTitle('Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('desktop menu-command bridge toggles organized state through the shared command path @smoke', async ({ page }) => {
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.getByText('Alpha Project', { exact: true }).first().click()
|
||||
await page.locator('.bn-editor').click()
|
||||
|
||||
await expect(page.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await triggerMenuCommand(page, APP_COMMAND_IDS.noteToggleOrganized)
|
||||
await expect(page.getByTitle('Mark as unorganized (back to Inbox) (Cmd+E)')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await triggerMenuCommand(page, APP_COMMAND_IDS.noteToggleOrganized)
|
||||
await expect(page.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('renderer shortcut bridge toggles the raw editor through the shared keyboard handler @smoke', async ({ page }) => {
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.getByText('Alpha Project', { exact: true }).first().click()
|
||||
await page.locator('.bn-editor').click()
|
||||
|
||||
await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('desktop menu-command bridge toggles the AI panel, while the wrong modifier event does not @smoke', async ({ page }) => {
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.getByText('Alpha Project', { exact: true }).first().click()
|
||||
await page.locator('.bn-editor').click()
|
||||
|
||||
await dispatchShortcutEvent(page, {
|
||||
key: 'l',
|
||||
code: 'KeyL',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
await page.waitForTimeout(200)
|
||||
await expect(page.getByTestId('ai-panel')).not.toBeVisible()
|
||||
|
||||
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleAiChat)
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTitle('Close AI panel')).toBeVisible()
|
||||
|
||||
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleAiChat)
|
||||
await expect(page.getByTestId('ai-panel')).not.toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
122
tests/smoke/table-resize-crash.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { triggerMenuCommand } from './testBridge'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
function trackUnexpectedErrors(page: Page): string[] {
|
||||
const errors: string[] = []
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
errors.push(error.message)
|
||||
})
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return
|
||||
const text = message.text()
|
||||
if (text.includes('ws://localhost:9711')) return
|
||||
errors.push(text)
|
||||
})
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
async function createUntitledNote(page: Page): Promise<void> {
|
||||
await triggerMenuCommand(page, 'file-new-note')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
|
||||
}), {
|
||||
timeout: 5_000,
|
||||
}).toBe(true)
|
||||
}
|
||||
|
||||
async function insertTable(page: Page): Promise<void> {
|
||||
const editorSurface = page.locator('.bn-editor')
|
||||
await expect(editorSurface).toBeVisible({ timeout: 5_000 })
|
||||
await editorSurface.click()
|
||||
await page.keyboard.type('/table')
|
||||
|
||||
const tableCommand = page.getByText('Table with editable cells', { exact: true })
|
||||
await expect(tableCommand).toBeVisible({ timeout: 5_000 })
|
||||
await tableCommand.click()
|
||||
|
||||
await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function dragFirstColumn(page: Page, deltaX: number): Promise<void> {
|
||||
const firstCell = page.locator('table td, table th').first()
|
||||
await expect(firstCell).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
const box = await firstCell.boundingBox()
|
||||
if (!box) throw new Error('Could not measure first table cell')
|
||||
|
||||
const handleX = box.x + box.width - 1
|
||||
const handleY = box.y + (box.height / 2)
|
||||
|
||||
await page.mouse.move(handleX, handleY)
|
||||
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(handleX + deltaX, handleY, { steps: 8 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
test.describe('table resize crash fix', () => {
|
||||
test.beforeEach((_, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('resizing a table column keeps the editor stable and changes the cell width', async ({ page }) => {
|
||||
const errors = trackUnexpectedErrors(page)
|
||||
|
||||
await openFixtureVaultTauri(page, tempVaultDir)
|
||||
await createUntitledNote(page)
|
||||
await insertTable(page)
|
||||
|
||||
const firstCell = page.locator('table td, table th').first()
|
||||
const before = await firstCell.boundingBox()
|
||||
if (!before) throw new Error('Could not measure first table cell before resize')
|
||||
|
||||
await dragFirstColumn(page, 80)
|
||||
|
||||
const after = await firstCell.boundingBox()
|
||||
if (!after) throw new Error('Could not measure first table cell after resize')
|
||||
|
||||
expect(after.width).toBeGreaterThan(before.width)
|
||||
await expect(page.locator('table')).toHaveCount(1)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('unmounting the editor mid-resize does not surface stale ProseMirror view errors', async ({ page }) => {
|
||||
const errors = trackUnexpectedErrors(page)
|
||||
|
||||
await openFixtureVaultTauri(page, tempVaultDir)
|
||||
await createUntitledNote(page)
|
||||
await insertTable(page)
|
||||
|
||||
const firstCell = page.locator('table td, table th').first()
|
||||
await expect(firstCell).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
const box = await firstCell.boundingBox()
|
||||
if (!box) throw new Error('Could not measure first table cell before resize')
|
||||
|
||||
const handleX = box.x + box.width - 1
|
||||
const handleY = box.y + (box.height / 2)
|
||||
|
||||
await page.mouse.move(handleX, handleY)
|
||||
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
|
||||
await page.mouse.down()
|
||||
await page.keyboard.press('Meta+Backslash')
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
|
||||
await page.mouse.move(handleX + 80, handleY, { steps: 8 })
|
||||
await page.mouse.up()
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
})
|
||||
66
tests/smoke/testBridge.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { type Page } from '@playwright/test'
|
||||
import type {
|
||||
AppCommandId,
|
||||
AppCommandShortcutEventInit,
|
||||
AppCommandShortcutEventOptions,
|
||||
} from '../../src/hooks/appCommandCatalog'
|
||||
|
||||
export async function triggerMenuCommand(page: Page, id: string): Promise<void> {
|
||||
await page.evaluate(async (commandId) => {
|
||||
const deadline = Date.now() + 5_000
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const bridge = window.__laputaTest
|
||||
const dispatchBrowserMenuCommand = bridge?.dispatchBrowserMenuCommand
|
||||
const triggerMenuCommand = bridge?.triggerMenuCommand
|
||||
|
||||
if (typeof dispatchBrowserMenuCommand === 'function') {
|
||||
if (typeof triggerMenuCommand === 'function') {
|
||||
try {
|
||||
await triggerMenuCommand(commandId)
|
||||
return
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (!message.includes('dispatchBrowserMenuCommand')) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatchBrowserMenuCommand(commandId)
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 50))
|
||||
}
|
||||
|
||||
throw new Error('Laputa test bridge is missing dispatchBrowserMenuCommand')
|
||||
}, id)
|
||||
}
|
||||
|
||||
export async function dispatchShortcutEvent(
|
||||
page: Page,
|
||||
init: AppCommandShortcutEventInit,
|
||||
): Promise<void> {
|
||||
await page.evaluate((eventInit) => {
|
||||
const bridge = window.__laputaTest?.dispatchShortcutEvent
|
||||
if (typeof bridge !== 'function') {
|
||||
throw new Error('Laputa test bridge is missing dispatchShortcutEvent')
|
||||
}
|
||||
bridge(eventInit)
|
||||
}, init)
|
||||
}
|
||||
|
||||
export async function triggerShortcutCommand(
|
||||
page: Page,
|
||||
id: AppCommandId,
|
||||
options?: AppCommandShortcutEventOptions,
|
||||
): Promise<void> {
|
||||
await page.evaluate((payload) => {
|
||||
const bridge = window.__laputaTest?.triggerShortcutCommand
|
||||
if (typeof bridge !== 'function') {
|
||||
throw new Error('Laputa test bridge is missing triggerShortcutCommand')
|
||||
}
|
||||
bridge(payload.id, payload.options)
|
||||
}, { id, options })
|
||||
}
|
||||
102
tests/smoke/view-item-hover-count.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
type MockHandlers = Record<string, ((args?: unknown) => unknown)> | null | undefined
|
||||
|
||||
async function installPersistentViewMocks(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
type ViewRecord = {
|
||||
filename: string
|
||||
definition: unknown
|
||||
}
|
||||
|
||||
const clone = <T,>(value: T): T => structuredClone(value)
|
||||
const views: ViewRecord[] = []
|
||||
|
||||
const apply = (handlers: MockHandlers) => {
|
||||
if (!handlers) return handlers
|
||||
|
||||
handlers.list_views = () => views.map((view) => ({
|
||||
filename: view.filename,
|
||||
definition: clone(view.definition),
|
||||
}))
|
||||
|
||||
handlers.save_view_cmd = (args?: unknown) => {
|
||||
const next = clone(args as { filename: string; definition: unknown })
|
||||
const index = views.findIndex((view) => view.filename === next.filename)
|
||||
if (index >= 0) {
|
||||
views[index] = next
|
||||
} else {
|
||||
views.push(next)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
handlers.delete_view_cmd = (args?: unknown) => {
|
||||
const { filename } = args as { filename: string }
|
||||
const index = views.findIndex((view) => view.filename === filename)
|
||||
if (index >= 0) views.splice(index, 1)
|
||||
return null
|
||||
}
|
||||
|
||||
return handlers
|
||||
}
|
||||
|
||||
let ref = apply(window.__mockHandlers as MockHandlers) ?? null
|
||||
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return apply(ref) ?? ref
|
||||
},
|
||||
set(value) {
|
||||
ref = apply(value as MockHandlers) ?? null
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function openCreateViewDialog(page: Page) {
|
||||
const viewsHeader = page.locator('button:has(span:text("VIEWS"))')
|
||||
await viewsHeader.waitFor({ timeout: 10_000 })
|
||||
await viewsHeader.locator('svg').last().click({ force: true })
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function opacityOf(locator: Locator): Promise<number> {
|
||||
return locator.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity))
|
||||
}
|
||||
|
||||
test('view row hover swaps the note count with edit/delete actions', async ({ page }) => {
|
||||
await installPersistentViewMocks(page)
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await openCreateViewDialog(page)
|
||||
await page.getByPlaceholder('e.g. Active Projects, Reading List...').fill('Project View')
|
||||
await page.getByTestId('filter-value-input').fill('Project')
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
const viewRow = page.locator('div.group').filter({
|
||||
has: page.getByText('Project View', { exact: true }),
|
||||
}).first()
|
||||
await expect(viewRow).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const countChip = viewRow.locator('span').last()
|
||||
const actionStrip = viewRow.locator('div.absolute.right-2.top-1\\/2').first()
|
||||
|
||||
await expect(countChip).toHaveText(/\d+/)
|
||||
await expect.poll(() => opacityOf(countChip)).toBeGreaterThan(0.9)
|
||||
await expect.poll(() => opacityOf(actionStrip)).toBeLessThan(0.1)
|
||||
|
||||
await viewRow.hover()
|
||||
|
||||
await expect.poll(() => opacityOf(countChip)).toBeLessThan(0.1)
|
||||
await expect.poll(() => opacityOf(actionStrip)).toBeGreaterThan(0.9)
|
||||
await expect(viewRow.getByTitle('Edit view')).toBeVisible()
|
||||
await expect(viewRow.getByTitle('Delete view')).toBeVisible()
|
||||
|
||||
await page.getByTestId('sidebar-top-nav').hover()
|
||||
|
||||
await expect.poll(() => opacityOf(countChip)).toBeGreaterThan(0.9)
|
||||
await expect.poll(() => opacityOf(actionStrip)).toBeLessThan(0.1)
|
||||
})
|
||||
@@ -210,18 +210,30 @@ function readExistingQueryPath(url: URL, res: ServerResponse, key: string): stri
|
||||
return filePath
|
||||
}
|
||||
|
||||
function updateTitleWikilinks(vaultPath: string, oldTitle: string, newTitle: string, excludePath: string): number {
|
||||
if (!oldTitle) return 0
|
||||
function updateTitleWikilinks(vaultPath: string, oldTitle: string, _newTitle: string, excludePath: string): number {
|
||||
const newPathStem = path.relative(vaultPath, excludePath).replace(/\.md$/i, '')
|
||||
const oldTargets = collectLegacyWikilinkTargets(oldTitle, excludePath, vaultPath)
|
||||
return updateWikilinksForTargets(vaultPath, oldTargets, newPathStem, excludePath)
|
||||
}
|
||||
|
||||
function collectLegacyWikilinkTargets(oldTitle: string, oldPath: string, vaultPath: string): string[] {
|
||||
const oldRelativeStem = path.relative(vaultPath, oldPath).replace(/\.md$/i, '')
|
||||
const oldFilenameStem = path.basename(oldPath, '.md')
|
||||
return [...new Set([oldTitle, oldRelativeStem, oldFilenameStem].filter(Boolean))]
|
||||
}
|
||||
|
||||
function updateWikilinksForTargets(vaultPath: string, oldTargets: string[], newTarget: string, excludePath: string): number {
|
||||
if (oldTargets.length === 0) return 0
|
||||
const allFiles = findMarkdownFiles(vaultPath)
|
||||
const escaped = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g')
|
||||
const escaped = oldTargets.map(target => target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
const pattern = new RegExp(`\\[\\[(?:${escaped.join('|')})(\\|[^\\]]*?)?\\]\\]`, 'g')
|
||||
let updatedFiles = 0
|
||||
for (const filePath of allFiles) {
|
||||
if (filePath === excludePath) continue
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
|
||||
pipe ? `[[${newTitle}${pipe}]]` : `[[${newTitle}]]`
|
||||
pipe ? `[[${newTarget}${pipe}]]` : `[[${newTarget}]]`
|
||||
)
|
||||
if (replaced !== content) {
|
||||
fs.writeFileSync(filePath, replaced, 'utf-8')
|
||||
@@ -234,28 +246,10 @@ function updateTitleWikilinks(vaultPath: string, oldTitle: string, newTitle: str
|
||||
return updatedFiles
|
||||
}
|
||||
|
||||
function updatePathWikilinks(vaultPath: string, oldPath: string, newPath: string): number {
|
||||
const oldRelativeStem = path.relative(vaultPath, oldPath).replace(/\.md$/i, '')
|
||||
function updatePathWikilinks(vaultPath: string, oldPath: string, newPath: string, oldTitle: string): number {
|
||||
const newRelativeStem = path.relative(vaultPath, newPath).replace(/\.md$/i, '')
|
||||
const escaped = oldRelativeStem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g')
|
||||
let updatedFiles = 0
|
||||
for (const filePath of findMarkdownFiles(vaultPath)) {
|
||||
if (filePath === newPath) continue
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
|
||||
pipe ? `[[${newRelativeStem}${pipe}]]` : `[[${newRelativeStem}]]`
|
||||
)
|
||||
if (replaced !== content) {
|
||||
fs.writeFileSync(filePath, replaced, 'utf-8')
|
||||
updatedFiles++
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable files in the dev vault API.
|
||||
}
|
||||
}
|
||||
return updatedFiles
|
||||
const oldTargets = collectLegacyWikilinkTargets(oldTitle, oldPath, vaultPath)
|
||||
return updateWikilinksForTargets(vaultPath, oldTargets, newRelativeStem, newPath)
|
||||
}
|
||||
|
||||
function handleVaultPing(url: URL, res: ServerResponse): boolean {
|
||||
@@ -388,13 +382,14 @@ async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: Se
|
||||
}
|
||||
|
||||
const newPath = path.join(path.dirname(oldPath), `${trimmed}.md`)
|
||||
const oldTitle = parseMarkdownFile(oldPath)?.title ?? path.basename(oldPath, '.md')
|
||||
if (newPath !== oldPath && fs.existsSync(newPath)) {
|
||||
sendJson(res, { error: 'A note with that name already exists' }, 409)
|
||||
return true
|
||||
}
|
||||
|
||||
fs.renameSync(oldPath, newPath)
|
||||
const updatedFiles = vaultPath ? updatePathWikilinks(vaultPath, oldPath, newPath) : 0
|
||||
const updatedFiles = vaultPath ? updatePathWikilinks(vaultPath, oldPath, newPath, oldTitle) : 0
|
||||
sendJson(res, { new_path: newPath, updated_files: updatedFiles })
|
||||
} catch (err: unknown) {
|
||||
sendJson(res, { error: err instanceof Error ? err.message : 'Rename failed' }, 500)
|
||||
|
||||