Compare commits

...

9 Commits

Author SHA1 Message Date
lucaronin
32ee2b781b test: stabilize keyboard shortcut smoke 2026-04-11 17:36:23 +02:00
lucaronin
c78793cfe3 fix: verify editor-focused keyboard shortcuts 2026-04-11 17:23:20 +02:00
lucaronin
cd9e5fc403 fix: guard table resize against stale editor views 2026-04-11 16:37:44 +02:00
lucaronin
798a6b2125 refactor: make shortcut execution renderer-first 2026-04-11 15:42:53 +02:00
lucaronin
dbf54657f0 fix: harden untitled h1 auto-rename flow 2026-04-11 15:22:34 +02:00
lucaronin
0c365eb7dd test: cover raw editor keyboard toggle 2026-04-11 14:23:25 +02:00
lucaronin
20b789271d fix: verify view row hover state 2026-04-11 14:06:03 +02:00
lucaronin
36d3c8731b fix: hide view counts while row actions are visible 2026-04-11 13:52:35 +02:00
lucaronin
e412fa8fd7 fix: restore breadcrumb action icon size 2026-04-11 13:33:42 +02:00
34 changed files with 1547 additions and 158 deletions

View File

@@ -727,12 +727,13 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
Selection-dependent note actions are wired through both the command palette and the native Note menu. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled.
Shortcut ownership is explicit:
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 and modifier rules
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
- `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 both real key events in a Tauri-like environment and `trigger_menu_command` to prove the keyboard path and the native menu path without relying on flaky macOS key synthesis
## Auto-Release & In-App Updates

View File

@@ -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,11 @@ 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.
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 QA, prefer real key events in a Tauri-like environment for shortcut behavior and `window.__laputaTest.triggerMenuCommand()` for the native menu click path. Do not treat flaky synthesized macOS keystrokes as proof that a shortcut works.
## Running Tests
@@ -341,7 +341,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
1. Register the command in `useAppCommands.ts` via the command registry
2. Add a corresponding menu bar item in `menu.rs` for discoverability
3. If it has a keyboard shortcut, register it in `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 and modifier rule, 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

View File

@@ -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.”

View File

@@ -106,4 +106,5 @@ 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 |

View File

@@ -13,7 +13,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/keyboard-command-routing.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",

View 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
View File

@@ -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

View File

@@ -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

View File

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

View File

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

View File

@@ -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}

View File

@@ -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">

View File

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

View File

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

View File

@@ -51,7 +51,6 @@ export type AppCommandShortcutCombo =
| 'command-or-ctrl'
| 'command-or-ctrl-shift'
| 'command-shift'
export type AppCommandShortcutOwner = 'native-menu' | 'renderer'
type ShortcutEventLike = Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'key' | 'code'>
type SimpleHandlerKey =
@@ -104,7 +103,6 @@ interface AppCommandShortcutDefinition {
aliases?: string[]
code?: string
display: string
owner: AppCommandShortcutOwner
}
export interface AppCommandDefinition {
@@ -117,7 +115,7 @@ 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 +124,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 +133,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 +162,27 @@ 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' },
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 +191,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 +237,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 +251,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' },
@@ -355,10 +353,6 @@ export function isNativeMenuCommandId(value: string): value is AppCommandId {
return NATIVE_MENU_COMMAND_SET.has(value)
}
export function isNativeMenuShortcutCommand(id: AppCommandId): boolean {
return APP_COMMAND_DEFINITIONS[id].shortcut?.owner === 'native-menu'
}
export function shortcutCombosForEvent({
altKey,
ctrlKey,

View File

@@ -1,12 +1,13 @@
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'
@@ -53,6 +54,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,11 +68,6 @@ 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)
@@ -150,4 +150,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)
})
})

View File

@@ -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
}

View File

@@ -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')
}

View File

@@ -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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'
import { isTauri } from '../mock-tauri'
import {
APP_COMMAND_EVENT_NAME,
dispatchAppCommand,
executeAppCommand,
isAppCommandId,
type AppCommandHandlers,
} from './appCommandDispatcher'
@@ -92,7 +92,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 +129,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. */

View File

@@ -342,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() })
@@ -358,6 +372,21 @@ describe('useNoteCreation hook', () => {
expect(handleSelectNote).toHaveBeenCalledWith(existing)
})
it('handleOpenDailyNote requests focus for the daily note path', () => {
const focusListener = vi.fn()
window.addEventListener('laputa:focus-editor', focusListener)
const today = todayDateString()
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleOpenDailyNote() })
expect(focusListener).toHaveBeenCalledTimes(1)
const event = focusListener.mock.calls[0][0] as CustomEvent
expect(event.detail.path).toBe(`/test/vault/${today}.md`)
window.removeEventListener('laputa:focus-editor', focusListener)
})
it('handleCreateType creates type entry', () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateType('Recipe') })

View File

@@ -161,9 +161,9 @@ function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: Vaul
}
/** 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 },
}))
}
@@ -200,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 {
@@ -250,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 {

View File

@@ -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,329 @@ export async function openFixtureVault(
return nativeFetch(input, init)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ref: any = null
const applyFixtureVaultOverrides = (
handlers: Record<string, ((args?: unknown) => unknown)> | null | undefined,
) => {
if (!handlers) return handlers
handlers.load_vault_list = () => ({
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
hidden_defaults: [],
})
handlers.check_vault_exists = () => true
handlers.get_last_vault_path = () => resolvedVaultPath
handlers.get_default_vault_path = () => resolvedVaultPath
handlers.save_vault_list = () => null
return handlers
}
let ref = applyFixtureVaultOverrides(
(window.__mockHandlers as Record<string, ((args?: unknown) => unknown)> | undefined),
) ?? null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value
ref.load_vault_list = () => ({
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
hidden_defaults: [],
})
ref.check_vault_exists = () => true
ref.get_last_vault_path = () => resolvedVaultPath
ref.get_default_vault_path = () => resolvedVaultPath
ref.save_vault_list = () => null
ref = applyFixtureVaultOverrides(
value as Record<string, ((args?: unknown) => unknown)> | undefined,
) ?? null
},
get() {
return ref
return applyFixtureVaultOverrides(ref) ?? ref
},
})
}, vaultPath)
await page.goto('/')
await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: 15_000 })
await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({ timeout: 15_000 })
await page.goto('/', { waitUntil: 'domcontentloaded' })
await page.waitForFunction(() => Boolean(window.__mockHandlers))
await page.evaluate((resolvedVaultPath: string) => {
const handlers = window.__mockHandlers
if (!handlers) {
throw new Error('Mock handlers unavailable for fixture vault override')
}
handlers.load_vault_list = () => ({
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
hidden_defaults: [],
})
handlers.check_vault_exists = () => true
handlers.get_last_vault_path = () => resolvedVaultPath
handlers.get_default_vault_path = () => resolvedVaultPath
handlers.save_vault_list = () => null
}, vaultPath)
await page.reload({ waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: FIXTURE_VAULT_READY_TIMEOUT })
await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({
timeout: FIXTURE_VAULT_READY_TIMEOUT,
})
}
export async function openFixtureVaultTauri(
page: Page,
vaultPath: string,
): Promise<void> {
await openFixtureVault(page, vaultPath)
await page.evaluate((resolvedVaultPath: string) => {
const jsonHeaders = { 'Content-Type': 'application/json' }
const nativeFetch = window.fetch.bind(window)
const 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__))
}

View File

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

View File

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

View File

@@ -1,13 +1,9 @@
import { test, expect } from '@playwright/test'
import { triggerMenuCommand } from './testBridge'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { createFixtureVaultCopy, openFixtureVaultTauri, 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()
@@ -21,48 +17,68 @@ test.describe('keyboard command routing', () => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await openFixtureVault(page, tempVaultDir)
await openFixtureVaultTauri(page, tempVaultDir)
await triggerMenuCommand(page, 'file-new-note')
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i, { timeout: 5_000 })
await expect(
page.locator('[data-testid="note-list-container"]').getByText(untitledNoteListMatcher('note')).first(),
).toBeVisible({ timeout: 5_000 })
expect(errors).toEqual([])
})
test('native menu trigger toggles the properties panel through the shared command path', async ({ page }) => {
await openFixtureVault(page, tempVaultDir)
test('Meta+Shift+I toggles the properties panel in Tauri mode through the shared keyboard path @smoke', async ({ page }) => {
await openFixtureVaultTauri(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await page.locator('.bn-editor').click()
await triggerMenuCommand(page, 'view-toggle-properties')
await page.keyboard.press('Meta+Shift+I')
await expect(page.getByTitle('Close Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
await triggerMenuCommand(page, 'view-toggle-properties')
await page.keyboard.press('Meta+Shift+I')
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('native menu trigger toggles organized state through the shared command path @smoke', async ({ page }) => {
await openFixtureVaultTauri(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 expect(page.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeVisible({ timeout: 5_000 })
// Chromium reserves Meta+E for the browser chrome, so the exact keystroke
// needs native-app QA. The smoke suite proves the shared command path here.
await triggerMenuCommand(page, 'note-toggle-organized')
await expect(page.getByTitle('Mark as unorganized (back to Inbox) (Cmd+E)')).toBeVisible({ timeout: 5_000 })
await triggerMenuCommand(page, 'note-toggle-organized')
await expect(page.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeVisible({ timeout: 5_000 })
})
test('Meta+Backslash toggles the raw editor in Tauri mode through the shared keyboard path @smoke', async ({ page }) => {
await openFixtureVaultTauri(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 triggerMenuCommand(page, 'edit-toggle-raw-editor')
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)
test('Meta+Shift+L toggles the AI panel in Tauri mode, while Ctrl+Shift+L does not @smoke', async ({ page }) => {
await openFixtureVaultTauri(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await page.locator('.bn-editor').click()
await triggerMenuCommand(page, 'view-toggle-ai-chat')
await page.keyboard.press('Control+Shift+L')
await page.waitForTimeout(200)
await expect(page.getByTestId('ai-panel')).not.toBeVisible()
await page.keyboard.press('Meta+Shift+L')
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 page.keyboard.press('Meta+Shift+L')
await expect(page.getByTestId('ai-panel')).not.toBeVisible({ timeout: 5_000 })
})
})

View 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([])
})
})

View File

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