Compare commits
8 Commits
v0.2026041
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce84b34890 | ||
|
|
e98a186389 | ||
|
|
258b54b074 | ||
|
|
f694b9b5e4 | ||
|
|
32ee2b781b | ||
|
|
c78793cfe3 | ||
|
|
cd9e5fc403 | ||
|
|
798a6b2125 |
@@ -727,12 +727,17 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
|
||||
Selection-dependent note actions are wired through both the command palette and the native Note menu. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled.
|
||||
|
||||
Shortcut ownership is explicit:
|
||||
Shortcut routing is explicit:
|
||||
|
||||
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and ownership
|
||||
- Renderer-owned shortcuts flow through `useAppKeyboard` into `appCommandDispatcher.ts`
|
||||
- Native-owned shortcuts flow through `menu.rs` into `useMenuEvents`, then into `appCommandDispatcher.ts`
|
||||
- Deterministic QA uses `trigger_menu_command` in Tauri and `window.__laputaTest.triggerMenuCommand()` in browser runs to exercise the native-menu command path without flaky macOS key synthesis
|
||||
- `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
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ laputa-app/
|
||||
│ │ ├── useCommandRegistry.ts # Command palette registry
|
||||
│ │ ├── useAppCommands.ts # App-level commands
|
||||
│ │ ├── useAppKeyboard.ts # Keyboard shortcuts
|
||||
│ │ ├── appCommandCatalog.ts # Shortcut ownership + command metadata
|
||||
│ │ ├── appCommandCatalog.ts # Shortcut combos + command metadata
|
||||
│ │ ├── appCommandDispatcher.ts # Shared shortcut/menu command IDs + dispatch
|
||||
│ │ ├── useSettings.ts # App settings
|
||||
│ │ ├── useOnboarding.ts # First-launch flow
|
||||
@@ -282,11 +282,16 @@ 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. Shortcut ownership now lives in `appCommandCatalog.ts`: renderer shortcuts (`useAppKeyboard`) resolve commands from that catalog, native menu events (`useMenuEvents`) emit the same command IDs, and `appCommandDispatcher.ts` owns execution rather than duplicate shortcut facts.
|
||||
`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 QA, prefer the `window.__laputaTest.triggerMenuCommand()` bridge for native-owned shortcuts and `window.__laputaTest.dispatchAppCommand()` only for renderer-only command paths. Do not treat flaky synthesized macOS keystrokes as proof that a native accelerator works.
|
||||
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
|
||||
|
||||
@@ -341,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 `appCommandCatalog.ts` with the canonical command ID, ownership (`renderer` vs `native-menu`), and modifier rule
|
||||
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
|
||||
|
||||
@@ -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
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.
|
||||
@@ -106,4 +106,7 @@ proposed → active → superseded
|
||||
| [0048](0048-relative-date-expressions-in-view-filters.md) | Relative date expressions in view filter conditions | active |
|
||||
| [0049](0049-per-note-icon-property.md) | Per-note icon property (_icon on individual notes) | active |
|
||||
| [0050](0050-deterministic-shortcut-command-routing.md) | Deterministic shortcut command routing | superseded → [0051](0051-shared-shortcut-manifest-for-testable-routing.md) |
|
||||
| [0051](0051-shared-shortcut-manifest-for-testable-routing.md) | Shared shortcut manifest for testable routing | active |
|
||||
| [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 |
|
||||
|
||||
234
patches/prosemirror-tables@1.8.5.patch
Normal file
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
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
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"] }
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -214,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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,9 +212,10 @@ fn build_view_menu(app: &App) -> MenuResult {
|
||||
.id(VIEW_ALL)
|
||||
.accelerator("CmdOrCtrl+3")
|
||||
.build(app)?;
|
||||
// Keep Cmd+Shift+I on the renderer path. The menu item stays available,
|
||||
// but the native accelerator has proven unreliable for this command.
|
||||
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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,9 +51,27 @@ export type AppCommandShortcutCombo =
|
||||
| 'command-or-ctrl'
|
||||
| 'command-or-ctrl-shift'
|
||||
| 'command-shift'
|
||||
export type AppCommandShortcutOwner = 'native-menu' | 'renderer'
|
||||
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'
|
||||
@@ -104,20 +122,20 @@ interface AppCommandShortcutDefinition {
|
||||
aliases?: string[]
|
||||
code?: string
|
||||
display: string
|
||||
owner: AppCommandShortcutOwner
|
||||
}
|
||||
|
||||
export interface AppCommandDefinition {
|
||||
route: AppCommandRoute
|
||||
menuOwned: boolean
|
||||
shortcut?: AppCommandShortcutDefinition
|
||||
preferredShortcutQaMode?: AppCommandDeterministicQaMode
|
||||
}
|
||||
|
||||
export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition> = {
|
||||
[APP_COMMAND_IDS.appSettings]: {
|
||||
route: { kind: 'handler', handler: 'onOpenSettings' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: ',', display: '⌘,', owner: 'native-menu' },
|
||||
shortcut: { combo: 'command-or-ctrl', key: ',', display: '⌘,' },
|
||||
},
|
||||
[APP_COMMAND_IDS.appCheckForUpdates]: {
|
||||
route: { kind: 'handler', handler: 'onCheckForUpdates' },
|
||||
@@ -126,7 +144,7 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
|
||||
[APP_COMMAND_IDS.fileNewNote]: {
|
||||
route: { kind: 'handler', handler: 'onCreateNote' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'n', code: 'KeyN', display: '⌘N', owner: 'native-menu' },
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'n', code: 'KeyN', display: '⌘N' },
|
||||
},
|
||||
[APP_COMMAND_IDS.fileNewType]: {
|
||||
route: { kind: 'handler', handler: 'onCreateType' },
|
||||
@@ -135,27 +153,27 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
|
||||
[APP_COMMAND_IDS.fileDailyNote]: {
|
||||
route: { kind: 'handler', handler: 'onOpenDailyNote' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'j', code: 'KeyJ', display: '⌘J', owner: 'native-menu' },
|
||||
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', owner: 'native-menu' },
|
||||
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', owner: 'native-menu' },
|
||||
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', owner: 'native-menu' },
|
||||
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: '⌘\\', owner: 'native-menu' },
|
||||
shortcut: { combo: 'command-or-ctrl', key: '\\', display: '⌘\\' },
|
||||
},
|
||||
[APP_COMMAND_IDS.editToggleDiff]: {
|
||||
route: { kind: 'handler', handler: 'onToggleDiff' },
|
||||
@@ -164,27 +182,28 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
|
||||
[APP_COMMAND_IDS.viewEditorOnly]: {
|
||||
route: { kind: 'view-mode', value: 'editor-only' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: '1', display: '⌘1', owner: 'native-menu' },
|
||||
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', owner: 'native-menu' },
|
||||
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', owner: 'native-menu' },
|
||||
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', owner: 'native-menu' },
|
||||
preferredShortcutQaMode: 'renderer-shortcut-event',
|
||||
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', owner: 'native-menu' },
|
||||
shortcut: { combo: 'command-shift', key: 'l', code: 'KeyL', display: '⌘⇧L' },
|
||||
},
|
||||
[APP_COMMAND_IDS.viewToggleBacklinks]: {
|
||||
route: { kind: 'handler', handler: 'onToggleInspector' },
|
||||
@@ -193,32 +212,32 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
|
||||
[APP_COMMAND_IDS.viewCommandPalette]: {
|
||||
route: { kind: 'handler', handler: 'onCommandPalette' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'k', code: 'KeyK', display: '⌘K', owner: 'native-menu' },
|
||||
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: '⌘=', owner: 'native-menu' },
|
||||
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: '⌘-', owner: 'native-menu' },
|
||||
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', owner: 'native-menu' },
|
||||
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: '⌘[', owner: 'native-menu' },
|
||||
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: '⌘]', owner: 'native-menu' },
|
||||
shortcut: { combo: 'command-or-ctrl', key: ']', display: '⌘]' },
|
||||
},
|
||||
[APP_COMMAND_IDS.goAllNotes]: {
|
||||
route: { kind: 'filter', value: 'all' },
|
||||
@@ -239,12 +258,12 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
|
||||
[APP_COMMAND_IDS.noteToggleOrganized]: {
|
||||
route: { kind: 'active-tab-handler', handler: 'onToggleOrganized' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'e', code: 'KeyE', display: '⌘E', owner: 'native-menu' },
|
||||
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', owner: 'renderer' },
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'd', code: 'KeyD', display: '⌘D' },
|
||||
},
|
||||
[APP_COMMAND_IDS.noteArchive]: {
|
||||
route: { kind: 'active-tab-handler', handler: 'onArchiveNote' },
|
||||
@@ -253,12 +272,12 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
|
||||
[APP_COMMAND_IDS.noteDelete]: {
|
||||
route: { kind: 'active-tab-handler', handler: 'onDeleteNote' },
|
||||
menuOwned: true,
|
||||
shortcut: { combo: 'command-or-ctrl', key: 'Backspace', aliases: ['Delete'], display: '⌘⌫', owner: 'native-menu' },
|
||||
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', owner: 'native-menu' },
|
||||
shortcut: { combo: 'command-or-ctrl-shift', key: 'o', code: 'KeyO', display: '⌘⇧O' },
|
||||
},
|
||||
[APP_COMMAND_IDS.noteRestoreDeleted]: {
|
||||
route: { kind: 'handler', handler: 'onRestoreDeletedNote' },
|
||||
@@ -314,6 +333,19 @@ const NATIVE_MENU_COMMAND_SET = new Set<string>(
|
||||
.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.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>(),
|
||||
@@ -355,8 +387,41 @@ export function isNativeMenuCommandId(value: string): value is AppCommandId {
|
||||
return NATIVE_MENU_COMMAND_SET.has(value)
|
||||
}
|
||||
|
||||
export function isNativeMenuShortcutCommand(id: AppCommandId): boolean {
|
||||
return APP_COMMAND_DEFINITIONS[id].shortcut?.owner === 'native-menu'
|
||||
export function getDeterministicShortcutQaDefinition(
|
||||
id: AppCommandId,
|
||||
): AppCommandDeterministicQaDefinition | null {
|
||||
const definition = APP_COMMAND_DEFINITIONS[id]
|
||||
if (!definition.shortcut) return null
|
||||
|
||||
return {
|
||||
preferredMode:
|
||||
definition.preferredShortcutQaMode
|
||||
?? (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({
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
APP_COMMAND_IDS,
|
||||
dispatchAppCommand,
|
||||
executeAppCommand,
|
||||
findShortcutCommandId,
|
||||
findShortcutCommandIdForEvent,
|
||||
isAppCommandId,
|
||||
isNativeMenuCommandId,
|
||||
isNativeMenuShortcutCommand,
|
||||
resetAppCommandDispatchStateForTests,
|
||||
type AppCommandHandlers,
|
||||
} from './appCommandDispatcher'
|
||||
import {
|
||||
getDeterministicShortcutQaDefinition,
|
||||
getShortcutEventInit,
|
||||
} from './appCommandCatalog'
|
||||
|
||||
function makeHandlers(): AppCommandHandlers {
|
||||
return {
|
||||
@@ -53,6 +58,10 @@ function makeHandlers(): AppCommandHandlers {
|
||||
}
|
||||
|
||||
describe('appCommandDispatcher', () => {
|
||||
afterEach(() => {
|
||||
resetAppCommandDispatchStateForTests()
|
||||
})
|
||||
|
||||
it('recognizes valid command ids', () => {
|
||||
expect(isAppCommandId(APP_COMMAND_IDS.fileNewNote)).toBe(true)
|
||||
expect(isAppCommandId('not-a-command')).toBe(false)
|
||||
@@ -63,16 +72,50 @@ describe('appCommandDispatcher', () => {
|
||||
expect(isNativeMenuCommandId(APP_COMMAND_IDS.noteToggleFavorite)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches shortcut ownership from the shared catalog', () => {
|
||||
expect(isNativeMenuShortcutCommand(APP_COMMAND_IDS.fileNewNote)).toBe(true)
|
||||
expect(isNativeMenuShortcutCommand(APP_COMMAND_IDS.noteToggleFavorite)).toBe(false)
|
||||
})
|
||||
|
||||
it('finds raw editor and AI shortcuts from the shared catalog', () => {
|
||||
expect(findShortcutCommandId('command-or-ctrl', '\\')).toBe(APP_COMMAND_IDS.editToggleRawEditor)
|
||||
expect(findShortcutCommandId('command-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat)
|
||||
})
|
||||
|
||||
it('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.viewToggleProperties)).toMatchObject({
|
||||
preferredMode: 'renderer-shortcut-event',
|
||||
supportsRendererShortcutEvent: true,
|
||||
supportsNativeMenuCommand: true,
|
||||
requiresManualNativeAcceleratorQa: false,
|
||||
})
|
||||
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({
|
||||
@@ -150,4 +193,20 @@ describe('appCommandDispatcher', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,10 +15,15 @@ export {
|
||||
findShortcutCommandIdForEvent,
|
||||
isAppCommandId,
|
||||
isNativeMenuCommandId,
|
||||
isNativeMenuShortcutCommand,
|
||||
} 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
|
||||
@@ -138,6 +143,36 @@ const ACTIVE_TAB_HANDLER_EXECUTORS: Record<ActiveTabHandlerKey, (handlers: AppCo
|
||||
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,
|
||||
@@ -175,5 +210,26 @@ function dispatchDefinition(
|
||||
}
|
||||
|
||||
export function dispatchAppCommand(id: AppCommandId, handlers: AppCommandHandlers): boolean {
|
||||
return dispatchDefinition(APP_COMMAND_DEFINITIONS[id], handlers)
|
||||
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,10 +1,8 @@
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import {
|
||||
APP_COMMAND_IDS,
|
||||
dispatchAppCommand,
|
||||
executeAppCommand,
|
||||
findShortcutCommandIdForEvent,
|
||||
isNativeMenuShortcutCommand,
|
||||
type AppCommandHandlers,
|
||||
} from './appCommandDispatcher'
|
||||
|
||||
@@ -47,11 +45,10 @@ export function handleAppKeyboardEvent(actions: KeyboardActions, event: Keyboard
|
||||
const commandId = findShortcutCommandIdForEvent(event)
|
||||
if (commandId === null) return
|
||||
if (TEXT_EDITING_KEYS.has(event.key) && isTextInputFocused()) return
|
||||
if (isTauri() && isNativeMenuShortcutCommand(commandId)) return
|
||||
|
||||
event.preventDefault()
|
||||
if (commandId === APP_COMMAND_IDS.editFindInVault) {
|
||||
trackEvent('search_used')
|
||||
}
|
||||
dispatchAppCommand(commandId, actions)
|
||||
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,
|
||||
@@ -50,6 +51,7 @@ function makeActions() {
|
||||
describe('useAppKeyboard', () => {
|
||||
afterEach(() => {
|
||||
delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__
|
||||
resetAppCommandDispatchStateForTests()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -95,39 +97,39 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onCreateNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+N defers to native menu in Tauri', () => {
|
||||
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).not.toHaveBeenCalled()
|
||||
expect(actions.onCreateNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+\\ defers to native menu in Tauri', () => {
|
||||
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).not.toHaveBeenCalled()
|
||||
expect(actions.onToggleRawEditor).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+I defers to native menu in Tauri', () => {
|
||||
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).not.toHaveBeenCalled()
|
||||
expect(onToggleInspector).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+L defers to native menu in Tauri', () => {
|
||||
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).not.toHaveBeenCalled()
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+D triggers toggle favorite on active note', () => {
|
||||
@@ -138,7 +140,7 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
|
||||
})
|
||||
|
||||
it('Cmd+D still uses JS shortcut in Tauri when no native menu owns it', () => {
|
||||
it('Cmd+D still works in Tauri mode', () => {
|
||||
const actions = makeActions()
|
||||
actions.onToggleFavorite = vi.fn()
|
||||
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
|
||||
@@ -155,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,8 +1,9 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import type { AppCommandShortcutEventInit, AppCommandShortcutEventOptions } from './appCommandCatalog'
|
||||
import {
|
||||
APP_COMMAND_EVENT_NAME,
|
||||
dispatchAppCommand,
|
||||
executeAppCommand,
|
||||
isAppCommandId,
|
||||
type AppCommandHandlers,
|
||||
} from './appCommandDispatcher'
|
||||
@@ -11,8 +12,10 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +95,7 @@ function useWindowAppCommandListener(handlersRef: { current: MenuEventHandlers }
|
||||
useEffect(() => {
|
||||
const handleCommandEvent = createWindowCommandListener((detail) => {
|
||||
if (isAppCommandId(detail)) {
|
||||
dispatchAppCommand(detail, handlersRef.current)
|
||||
executeAppCommand(detail, handlersRef.current, 'app-event')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -129,7 +132,7 @@ function useNativeMenuStateSync(state: MenuStatePayload) {
|
||||
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
|
||||
export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
|
||||
if (!isAppCommandId(id)) return
|
||||
dispatchAppCommand(id, h)
|
||||
executeAppCommand(id, h, 'native-menu')
|
||||
}
|
||||
|
||||
/** Listen for native macOS menu events and dispatch them to the appropriate handlers. */
|
||||
|
||||
31
src/main.tsx
31
src/main.tsx
@@ -8,13 +8,20 @@ import {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +34,15 @@ 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)) {
|
||||
@@ -34,6 +50,9 @@ window.__laputaTest = {
|
||||
}
|
||||
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}`)
|
||||
@@ -51,6 +70,18 @@ window.__laputaTest = {
|
||||
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(
|
||||
|
||||
@@ -5,6 +5,8 @@ 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 })
|
||||
@@ -27,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(
|
||||
@@ -113,7 +120,14 @@ export async function openFixtureVault(
|
||||
})
|
||||
}
|
||||
|
||||
export async function openFixtureVaultTauri(
|
||||
/**
|
||||
* 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> {
|
||||
@@ -122,6 +136,116 @@ export async function openFixtureVaultTauri(
|
||||
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) {
|
||||
@@ -199,6 +323,16 @@ export async function openFixtureVaultTauri(
|
||||
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',
|
||||
@@ -261,3 +395,5 @@ export async function openFixtureVaultTauri(
|
||||
|
||||
await page.waitForFunction(() => Boolean(window.__TAURI_INTERNALS__))
|
||||
}
|
||||
|
||||
export const openFixtureVaultTauri = openFixtureVaultDesktopHarness
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { triggerMenuCommand } from './testBridge'
|
||||
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog'
|
||||
import {
|
||||
dispatchShortcutEvent,
|
||||
triggerMenuCommand,
|
||||
triggerShortcutCommand,
|
||||
} from './testBridge'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVaultDesktopHarness,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../helpers/fixtureVault'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
function untitledNoteListMatcher(typeLabel: string) {
|
||||
return new RegExp(`Untitled ${typeLabel}(?: \\d+)?`, 'i')
|
||||
}
|
||||
|
||||
test.describe('keyboard command routing', () => {
|
||||
test.beforeEach(() => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
@@ -17,64 +22,91 @@ test.describe('keyboard command routing', () => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('native menu trigger creates a note through the shared command path @smoke', async ({ page }) => {
|
||||
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 openFixtureVault(page, tempVaultDir)
|
||||
await triggerMenuCommand(page, 'file-new-note')
|
||||
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 })
|
||||
await expect(
|
||||
page.locator('[data-testid="note-list-container"]').getByText(untitledNoteListMatcher('note')).first(),
|
||||
).toBeVisible({ timeout: 5_000 })
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('native menu trigger toggles the properties panel through the shared command path', async ({ page }) => {
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
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, 'view-toggle-properties')
|
||||
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleProperties)
|
||||
await expect(page.getByTitle('Close Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await triggerMenuCommand(page, 'view-toggle-properties')
|
||||
await triggerMenuCommand(page, APP_COMMAND_IDS.viewToggleProperties)
|
||||
await expect(page.getByTitle('Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('native menu trigger toggles the raw editor through the shared command path', async ({ page }) => {
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
test('desktop keyboard shortcut toggles the properties panel through the renderer shortcut 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, 'edit-toggle-raw-editor')
|
||||
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+Shift+I' : 'Control+Shift+I')
|
||||
await expect(page.getByTitle('Close Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+Shift+I' : 'Control+Shift+I')
|
||||
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 triggerMenuCommand(page, 'edit-toggle-raw-editor')
|
||||
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('Meta+Backslash toggles the raw editor through the keyboard path', async ({ page }) => {
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
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 page.keyboard.press('Meta+Backslash')
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
|
||||
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 page.keyboard.press('Meta+Backslash')
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('native menu trigger toggles the AI panel through the shared command path', async ({ page }) => {
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
await page.getByText('Alpha Project', { exact: true }).first().click()
|
||||
|
||||
await triggerMenuCommand(page, 'view-toggle-ai-chat')
|
||||
await 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, 'view-toggle-ai-chat')
|
||||
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
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([])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,9 @@
|
||||
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) => {
|
||||
@@ -32,3 +37,30 @@ export async function triggerMenuCommand(page: Page, id: string): Promise<void>
|
||||
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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user