refactor: centralize shortcut routing metadata

This commit is contained in:
lucaronin
2026-04-11 12:59:11 +02:00
parent 69e520b5aa
commit 4b60b9539d
10 changed files with 639 additions and 349 deletions

View File

@@ -729,6 +729,7 @@ Selection-dependent note actions are wired through both the command palette and
Shortcut ownership is explicit:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and ownership
- Renderer-owned shortcuts flow through `useAppKeyboard` into `appCommandDispatcher.ts`
- Native-owned shortcuts flow through `menu.rs` into `useMenuEvents`, then into `appCommandDispatcher.ts`
- Deterministic QA uses `trigger_menu_command` in Tauri and `window.__laputaTest.triggerMenuCommand()` in browser runs to exercise the native-menu command path without flaky macOS key synthesis

View File

@@ -97,6 +97,7 @@ laputa-app/
│ │ ├── useCommandRegistry.ts # Command palette registry
│ │ ├── useAppCommands.ts # App-level commands
│ │ ├── useAppKeyboard.ts # Keyboard shortcuts
│ │ ├── appCommandCatalog.ts # Shortcut ownership + command metadata
│ │ ├── appCommandDispatcher.ts # Shared shortcut/menu command IDs + dispatch
│ │ ├── useSettings.ts # App settings
│ │ ├── useOnboarding.ts # First-launch flow
@@ -281,7 +282,7 @@ 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. Renderer shortcuts (`useAppKeyboard`) and native menu events (`useMenuEvents`) both route through `appCommandDispatcher.ts`, so shortcut behavior is owned by shared command IDs instead of duplicate handler paths.
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut ownership now lives in `appCommandCatalog.ts`: renderer shortcuts (`useAppKeyboard`) resolve commands from that catalog, native menu events (`useMenuEvents`) emit the same command IDs, and `appCommandDispatcher.ts` owns execution rather than duplicate shortcut facts.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
@@ -340,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, map it to the canonical command ID in `appCommandDispatcher.ts` and register ownership in `useAppKeyboard.ts`
3. If it has a keyboard shortcut, register it in `appCommandCatalog.ts` with the canonical command ID, ownership (`renderer` vs `native-menu`), and modifier rule
4. If its enabled state depends on runtime selection (active note, deleted preview, Git status, etc.), thread that flag through `useMenuEvents.ts` and `update_menu_state` so the native menu enables/disables correctly
### Modify styling

View File

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

View File

@@ -105,4 +105,5 @@ proposed → active → superseded
| [0047](0047-regex-mode-for-view-filter-conditions.md) | Regex mode for view filter conditions | active |
| [0048](0048-relative-date-expressions-in-view-filters.md) | Relative date expressions in view filter conditions | active |
| [0049](0049-per-note-icon-property.md) | Per-note icon property (_icon on individual notes) | active |
| [0050](0050-deterministic-shortcut-command-routing.md) | Deterministic shortcut command routing | active |
| [0050](0050-deterministic-shortcut-command-routing.md) | Deterministic shortcut command routing | superseded → [0051](0051-shared-shortcut-manifest-for-testable-routing.md) |
| [0051](0051-shared-shortcut-manifest-for-testable-routing.md) | Shared shortcut manifest for testable routing | active |

View File

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

View File

@@ -2,8 +2,11 @@ import { describe, expect, it, vi } from 'vitest'
import {
APP_COMMAND_IDS,
dispatchAppCommand,
findShortcutCommandId,
findShortcutCommandIdForEvent,
isAppCommandId,
isNativeMenuCommandId,
isNativeMenuShortcutCommand,
type AppCommandHandlers,
} from './appCommandDispatcher'
@@ -60,6 +63,49 @@ describe('appCommandDispatcher', () => {
expect(isNativeMenuCommandId(APP_COMMAND_IDS.noteToggleFavorite)).toBe(false)
})
it('matches shortcut ownership from the shared catalog', () => {
expect(isNativeMenuShortcutCommand(APP_COMMAND_IDS.fileNewNote)).toBe(true)
expect(isNativeMenuShortcutCommand(APP_COMMAND_IDS.noteToggleFavorite)).toBe(false)
})
it('finds raw editor and AI shortcuts from the shared catalog', () => {
expect(findShortcutCommandId('command-or-ctrl', '\\')).toBe(APP_COMMAND_IDS.editToggleRawEditor)
expect(findShortcutCommandId('command-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat)
})
it('resolves event modifiers through the shared shortcut catalog', () => {
expect(
findShortcutCommandIdForEvent({
key: '¬',
code: 'KeyL',
altKey: false,
ctrlKey: false,
metaKey: true,
shiftKey: true,
}),
).toBe(APP_COMMAND_IDS.viewToggleAiChat)
expect(
findShortcutCommandIdForEvent({
key: 'I',
code: 'KeyI',
altKey: false,
ctrlKey: false,
metaKey: true,
shiftKey: true,
}),
).toBe(APP_COMMAND_IDS.viewToggleProperties)
expect(
findShortcutCommandIdForEvent({
key: 'l',
code: 'KeyL',
altKey: false,
ctrlKey: true,
metaKey: false,
shiftKey: true,
}),
).toBeNull()
})
it('dispatches create note through the shared command path', () => {
const handlers = makeHandlers()
expect(dispatchAppCommand(APP_COMMAND_IDS.fileNewNote, handlers)).toBe(true)

View File

@@ -1,119 +1,23 @@
import type { MutableRefObject } from 'react'
import type { SidebarFilter } from '../types'
import {
APP_COMMAND_DEFINITIONS,
type AppCommandId,
type AppCommandDefinition,
} from './appCommandCatalog'
import type { ViewMode } from './useViewMode'
export const APP_COMMAND_EVENT_NAME = 'laputa:dispatch-command'
export const APP_MENU_EVENT_NAME = 'laputa:dispatch-menu-command'
export const APP_COMMAND_IDS = {
appSettings: 'app-settings',
appCheckForUpdates: 'app-check-for-updates',
fileNewNote: 'file-new-note',
fileNewType: 'file-new-type',
fileDailyNote: 'file-daily-note',
fileQuickOpen: 'file-quick-open',
fileSave: 'file-save',
editFindInVault: 'edit-find-in-vault',
editToggleRawEditor: 'edit-toggle-raw-editor',
editToggleDiff: 'edit-toggle-diff',
viewEditorOnly: 'view-editor-only',
viewEditorList: 'view-editor-list',
viewAll: 'view-all',
viewToggleProperties: 'view-toggle-properties',
viewToggleAiChat: 'view-toggle-ai-chat',
viewToggleBacklinks: 'view-toggle-backlinks',
viewCommandPalette: 'view-command-palette',
viewZoomIn: 'view-zoom-in',
viewZoomOut: 'view-zoom-out',
viewZoomReset: 'view-zoom-reset',
viewGoBack: 'view-go-back',
viewGoForward: 'view-go-forward',
goAllNotes: 'go-all-notes',
goArchived: 'go-archived',
goChanges: 'go-changes',
goInbox: 'go-inbox',
noteToggleOrganized: 'note-toggle-organized',
noteToggleFavorite: 'note-toggle-favorite',
noteArchive: 'note-archive',
noteDelete: 'note-delete',
noteOpenInNewWindow: 'note-open-in-new-window',
noteRestoreDeleted: 'note-restore-deleted',
vaultOpen: 'vault-open',
vaultRemove: 'vault-remove',
vaultRestoreGettingStarted: 'vault-restore-getting-started',
vaultCommitPush: 'vault-commit-push',
vaultPull: 'vault-pull',
vaultResolveConflicts: 'vault-resolve-conflicts',
vaultViewChanges: 'vault-view-changes',
vaultInstallMcp: 'vault-install-mcp',
vaultReload: 'vault-reload',
vaultRepair: 'vault-repair',
} as const
export type AppCommandId = (typeof APP_COMMAND_IDS)[keyof typeof APP_COMMAND_IDS]
const VIEW_MODE_COMMANDS: Partial<Record<AppCommandId, ViewMode>> = {
[APP_COMMAND_IDS.viewEditorOnly]: 'editor-only',
[APP_COMMAND_IDS.viewEditorList]: 'editor-list',
[APP_COMMAND_IDS.viewAll]: 'all',
}
const FILTER_COMMANDS: Partial<Record<AppCommandId, SidebarFilter>> = {
[APP_COMMAND_IDS.goAllNotes]: 'all',
[APP_COMMAND_IDS.goArchived]: 'archived',
[APP_COMMAND_IDS.goChanges]: 'changes',
[APP_COMMAND_IDS.goInbox]: 'inbox',
}
const APP_COMMAND_SET = new Set<string>(Object.values(APP_COMMAND_IDS))
const NATIVE_MENU_COMMAND_IDS = [
APP_COMMAND_IDS.appSettings,
APP_COMMAND_IDS.appCheckForUpdates,
APP_COMMAND_IDS.fileNewNote,
APP_COMMAND_IDS.fileNewType,
APP_COMMAND_IDS.fileDailyNote,
APP_COMMAND_IDS.fileQuickOpen,
APP_COMMAND_IDS.fileSave,
APP_COMMAND_IDS.editFindInVault,
APP_COMMAND_IDS.editToggleRawEditor,
APP_COMMAND_IDS.editToggleDiff,
APP_COMMAND_IDS.viewEditorOnly,
APP_COMMAND_IDS.viewEditorList,
APP_COMMAND_IDS.viewAll,
APP_COMMAND_IDS.viewToggleProperties,
APP_COMMAND_IDS.viewToggleAiChat,
APP_COMMAND_IDS.viewToggleBacklinks,
APP_COMMAND_IDS.viewCommandPalette,
APP_COMMAND_IDS.viewZoomIn,
APP_COMMAND_IDS.viewZoomOut,
APP_COMMAND_IDS.viewZoomReset,
APP_COMMAND_IDS.viewGoBack,
APP_COMMAND_IDS.viewGoForward,
APP_COMMAND_IDS.goAllNotes,
APP_COMMAND_IDS.goArchived,
APP_COMMAND_IDS.goChanges,
APP_COMMAND_IDS.goInbox,
APP_COMMAND_IDS.noteToggleOrganized,
APP_COMMAND_IDS.noteArchive,
APP_COMMAND_IDS.noteDelete,
APP_COMMAND_IDS.noteOpenInNewWindow,
APP_COMMAND_IDS.noteRestoreDeleted,
APP_COMMAND_IDS.vaultOpen,
APP_COMMAND_IDS.vaultRemove,
APP_COMMAND_IDS.vaultRestoreGettingStarted,
APP_COMMAND_IDS.vaultCommitPush,
APP_COMMAND_IDS.vaultPull,
APP_COMMAND_IDS.vaultResolveConflicts,
APP_COMMAND_IDS.vaultViewChanges,
APP_COMMAND_IDS.vaultInstallMcp,
APP_COMMAND_IDS.vaultReload,
APP_COMMAND_IDS.vaultRepair,
] as const
const NATIVE_MENU_COMMAND_SET = new Set<string>(NATIVE_MENU_COMMAND_IDS)
export type NativeMenuCommandId = (typeof NATIVE_MENU_COMMAND_IDS)[number]
export {
APP_COMMAND_IDS,
findShortcutCommandId,
findShortcutCommandIdForEvent,
isAppCommandId,
isNativeMenuCommandId,
isNativeMenuShortcutCommand,
} from './appCommandCatalog'
export type { AppCommandDefinition, AppCommandId, AppCommandShortcutCombo } from './appCommandCatalog'
export interface AppCommandHandlers {
onSetViewMode: (mode: ViewMode) => void
@@ -155,138 +59,117 @@ export interface AppCommandHandlers {
activeTabPathRef: MutableRefObject<string | null>
}
type SimpleHandlerKey = keyof Pick<
AppCommandHandlers,
| 'onOpenSettings'
| 'onCheckForUpdates'
| 'onCreateNote'
| 'onCreateType'
| 'onOpenDailyNote'
| 'onQuickOpen'
| 'onSave'
| 'onSearch'
| 'onToggleRawEditor'
| 'onToggleDiff'
| 'onToggleInspector'
| 'onToggleAIChat'
| 'onCommandPalette'
| 'onZoomIn'
| 'onZoomOut'
| 'onZoomReset'
| 'onGoBack'
| 'onGoForward'
| 'onOpenVault'
| 'onRemoveActiveVault'
| 'onRestoreGettingStarted'
| 'onCommitPush'
| 'onPull'
| 'onResolveConflicts'
| 'onViewChanges'
| 'onInstallMcp'
| 'onReloadVault'
| 'onRepairVault'
| 'onOpenInNewWindow'
| 'onRestoreDeletedNote'
>
type ActiveTabHandlerKey = keyof Pick<
AppCommandHandlers,
'onToggleOrganized' | 'onToggleFavorite' | 'onArchiveNote' | 'onDeleteNote'
>
const SIMPLE_HANDLER_EXECUTORS: Record<SimpleHandlerKey, (handlers: AppCommandHandlers) => void> = {
onOpenSettings: (handlers) => handlers.onOpenSettings(),
onCheckForUpdates: (handlers) => handlers.onCheckForUpdates?.(),
onCreateNote: (handlers) => handlers.onCreateNote(),
onCreateType: (handlers) => handlers.onCreateType?.(),
onOpenDailyNote: (handlers) => handlers.onOpenDailyNote(),
onQuickOpen: (handlers) => handlers.onQuickOpen(),
onSave: (handlers) => handlers.onSave(),
onSearch: (handlers) => handlers.onSearch(),
onToggleRawEditor: (handlers) => handlers.onToggleRawEditor?.(),
onToggleDiff: (handlers) => handlers.onToggleDiff?.(),
onToggleInspector: (handlers) => handlers.onToggleInspector(),
onToggleAIChat: (handlers) => handlers.onToggleAIChat?.(),
onCommandPalette: (handlers) => handlers.onCommandPalette(),
onZoomIn: (handlers) => handlers.onZoomIn(),
onZoomOut: (handlers) => handlers.onZoomOut(),
onZoomReset: (handlers) => handlers.onZoomReset(),
onGoBack: (handlers) => handlers.onGoBack?.(),
onGoForward: (handlers) => handlers.onGoForward?.(),
onOpenVault: (handlers) => handlers.onOpenVault?.(),
onRemoveActiveVault: (handlers) => handlers.onRemoveActiveVault?.(),
onRestoreGettingStarted: (handlers) => handlers.onRestoreGettingStarted?.(),
onCommitPush: (handlers) => handlers.onCommitPush?.(),
onPull: (handlers) => handlers.onPull?.(),
onResolveConflicts: (handlers) => handlers.onResolveConflicts?.(),
onViewChanges: (handlers) => handlers.onViewChanges?.(),
onInstallMcp: (handlers) => handlers.onInstallMcp?.(),
onReloadVault: (handlers) => handlers.onReloadVault?.(),
onRepairVault: (handlers) => handlers.onRepairVault?.(),
onOpenInNewWindow: (handlers) => handlers.onOpenInNewWindow?.(),
onRestoreDeletedNote: (handlers) => handlers.onRestoreDeletedNote?.(),
}
const ACTIVE_TAB_HANDLER_EXECUTORS: Record<ActiveTabHandlerKey, (handlers: AppCommandHandlers, path: string) => void> = {
onToggleOrganized: (handlers, path) => handlers.onToggleOrganized?.(path),
onToggleFavorite: (handlers, path) => handlers.onToggleFavorite?.(path),
onArchiveNote: (handlers, path) => handlers.onArchiveNote(path),
onDeleteNote: (handlers, path) => handlers.onDeleteNote(path),
}
function dispatchActiveTabCommand(
pathRef: MutableRefObject<string | null>,
onPath: (path: string) => void,
handler: (path: string) => void,
): boolean {
const path = pathRef.current
if (!path) return false
onPath(path)
handler(path)
return true
}
export function isAppCommandId(value: string): value is AppCommandId {
return APP_COMMAND_SET.has(value)
}
export function isNativeMenuCommandId(value: string): value is NativeMenuCommandId {
return NATIVE_MENU_COMMAND_SET.has(value)
function dispatchDefinition(
definition: AppCommandDefinition,
handlers: AppCommandHandlers,
): boolean {
switch (definition.route.kind) {
case 'view-mode':
handlers.onSetViewMode(definition.route.value)
return true
case 'filter':
handlers.onSelectFilter?.(definition.route.value)
return true
case 'handler':
SIMPLE_HANDLER_EXECUTORS[definition.route.handler as SimpleHandlerKey](handlers)
return true
case 'active-tab-handler':
return dispatchActiveTabCommand(
handlers.activeTabPathRef,
(path) => ACTIVE_TAB_HANDLER_EXECUTORS[definition.route.handler as ActiveTabHandlerKey](handlers, path),
)
}
}
export function dispatchAppCommand(id: AppCommandId, handlers: AppCommandHandlers): boolean {
const viewMode = VIEW_MODE_COMMANDS[id]
if (viewMode) {
handlers.onSetViewMode(viewMode)
return true
}
const filter = FILTER_COMMANDS[id]
if (filter) {
handlers.onSelectFilter?.(filter)
return true
}
switch (id) {
case APP_COMMAND_IDS.appSettings:
handlers.onOpenSettings()
return true
case APP_COMMAND_IDS.appCheckForUpdates:
handlers.onCheckForUpdates?.()
return true
case APP_COMMAND_IDS.fileNewNote:
handlers.onCreateNote()
return true
case APP_COMMAND_IDS.fileNewType:
handlers.onCreateType?.()
return true
case APP_COMMAND_IDS.fileDailyNote:
handlers.onOpenDailyNote()
return true
case APP_COMMAND_IDS.fileQuickOpen:
handlers.onQuickOpen()
return true
case APP_COMMAND_IDS.fileSave:
handlers.onSave()
return true
case APP_COMMAND_IDS.editFindInVault:
handlers.onSearch()
return true
case APP_COMMAND_IDS.editToggleRawEditor:
handlers.onToggleRawEditor?.()
return true
case APP_COMMAND_IDS.editToggleDiff:
handlers.onToggleDiff?.()
return true
case APP_COMMAND_IDS.viewToggleProperties:
case APP_COMMAND_IDS.viewToggleBacklinks:
handlers.onToggleInspector()
return true
case APP_COMMAND_IDS.viewToggleAiChat:
handlers.onToggleAIChat?.()
return true
case APP_COMMAND_IDS.viewCommandPalette:
handlers.onCommandPalette()
return true
case APP_COMMAND_IDS.viewZoomIn:
handlers.onZoomIn()
return true
case APP_COMMAND_IDS.viewZoomOut:
handlers.onZoomOut()
return true
case APP_COMMAND_IDS.viewZoomReset:
handlers.onZoomReset()
return true
case APP_COMMAND_IDS.viewGoBack:
handlers.onGoBack?.()
return true
case APP_COMMAND_IDS.viewGoForward:
handlers.onGoForward?.()
return true
case APP_COMMAND_IDS.noteToggleOrganized:
return dispatchActiveTabCommand(handlers.activeTabPathRef, (path) => handlers.onToggleOrganized?.(path))
case APP_COMMAND_IDS.noteToggleFavorite:
return dispatchActiveTabCommand(handlers.activeTabPathRef, (path) => handlers.onToggleFavorite?.(path))
case APP_COMMAND_IDS.noteArchive:
return dispatchActiveTabCommand(handlers.activeTabPathRef, handlers.onArchiveNote)
case APP_COMMAND_IDS.noteDelete:
return dispatchActiveTabCommand(handlers.activeTabPathRef, handlers.onDeleteNote)
case APP_COMMAND_IDS.noteOpenInNewWindow:
handlers.onOpenInNewWindow?.()
return true
case APP_COMMAND_IDS.noteRestoreDeleted:
handlers.onRestoreDeletedNote?.()
return true
case APP_COMMAND_IDS.vaultOpen:
handlers.onOpenVault?.()
return true
case APP_COMMAND_IDS.vaultRemove:
handlers.onRemoveActiveVault?.()
return true
case APP_COMMAND_IDS.vaultRestoreGettingStarted:
handlers.onRestoreGettingStarted?.()
return true
case APP_COMMAND_IDS.vaultCommitPush:
handlers.onCommitPush?.()
return true
case APP_COMMAND_IDS.vaultPull:
handlers.onPull?.()
return true
case APP_COMMAND_IDS.vaultResolveConflicts:
handlers.onResolveConflicts?.()
return true
case APP_COMMAND_IDS.vaultViewChanges:
handlers.onViewChanges?.()
return true
case APP_COMMAND_IDS.vaultInstallMcp:
handlers.onInstallMcp?.()
return true
case APP_COMMAND_IDS.vaultReload:
handlers.onReloadVault?.()
return true
case APP_COMMAND_IDS.vaultRepair:
handlers.onRepairVault?.()
return true
}
return false
return dispatchDefinition(APP_COMMAND_DEFINITIONS[id], handlers)
}

View File

@@ -1,10 +1,10 @@
import type { ViewMode } from './useViewMode'
import { trackEvent } from '../lib/telemetry'
import { isTauri } from '../mock-tauri'
import {
APP_COMMAND_IDS,
dispatchAppCommand,
type AppCommandId,
findShortcutCommandIdForEvent,
isNativeMenuShortcutCommand,
type AppCommandHandlers,
} from './appCommandDispatcher'
@@ -34,20 +34,7 @@ export type KeyboardActions = Pick<
| 'activeTabPathRef'
>
type ShortcutMap = Record<string, AppCommandId>
type NativeMenuCombo = 'command' | 'command-shift'
const TEXT_EDITING_KEYS = new Set(['Backspace', 'Delete'])
const TAURI_NATIVE_MENU_KEYS: Record<NativeMenuCombo, Set<string>> = {
command: new Set([',', '1', '2', '3', 'n', 'j', 'p', 's', 'k', '=', '+', '-', '0', '[', ']', '\\', 'e', 'Backspace', 'Delete']),
'command-shift': new Set(['f', 'i', 'o', 'l']),
}
const VIEW_MODE_KEYS: Record<string, ViewMode> = {
'1': 'editor-only',
'2': 'editor-list',
'3': 'all',
}
function isTextInputFocused(): boolean {
const active = document.activeElement
@@ -56,107 +43,15 @@ function isTextInputFocused(): boolean {
return active.isContentEditable || active.closest('[contenteditable="true"]') !== null
}
function isCommandOrCtrlOnly(e: KeyboardEvent): boolean {
return (e.metaKey || e.ctrlKey) && e.altKey === false
}
export function handleAppKeyboardEvent(actions: KeyboardActions, event: KeyboardEvent) {
const commandId = findShortcutCommandIdForEvent(event)
if (commandId === null) return
if (TEXT_EDITING_KEYS.has(event.key) && isTextInputFocused()) return
if (isTauri() && isNativeMenuShortcutCommand(commandId)) return
function isCommandOrCtrlShiftOnly(e: KeyboardEvent): boolean {
return isCommandOrCtrlOnly(e) && e.shiftKey
}
function isCommandShiftOnly(e: KeyboardEvent): boolean {
return e.metaKey && e.ctrlKey === false && e.altKey === false && e.shiftKey
}
function nativeMenuComboForEvent(e: KeyboardEvent): NativeMenuCombo | null {
if (isCommandShiftOnly(e)) return 'command-shift'
if (isCommandOrCtrlOnly(e) && e.shiftKey === false) return 'command'
return null
}
function shouldDeferToNativeMenu(e: KeyboardEvent): boolean {
if (!isTauri()) return false
const combo = nativeMenuComboForEvent(e)
if (combo === null) return false
const normalizedKey = combo === 'command-shift' ? e.key.toLowerCase() : e.key
return TAURI_NATIVE_MENU_KEYS[combo].has(normalizedKey)
}
export function createCommandKeyMap(): ShortcutMap {
return {
k: APP_COMMAND_IDS.viewCommandPalette,
p: APP_COMMAND_IDS.fileQuickOpen,
n: APP_COMMAND_IDS.fileNewNote,
j: APP_COMMAND_IDS.fileDailyNote,
s: APP_COMMAND_IDS.fileSave,
',': APP_COMMAND_IDS.appSettings,
d: APP_COMMAND_IDS.noteToggleFavorite,
e: APP_COMMAND_IDS.noteToggleOrganized,
Backspace: APP_COMMAND_IDS.noteDelete,
Delete: APP_COMMAND_IDS.noteDelete,
'[': APP_COMMAND_IDS.viewGoBack,
']': APP_COMMAND_IDS.viewGoForward,
'=': APP_COMMAND_IDS.viewZoomIn,
'+': APP_COMMAND_IDS.viewZoomIn,
'-': APP_COMMAND_IDS.viewZoomOut,
'0': APP_COMMAND_IDS.viewZoomReset,
'\\': APP_COMMAND_IDS.editToggleRawEditor,
}
}
export function createShiftCommandKeyMap(): ShortcutMap {
return {
f: APP_COMMAND_IDS.editFindInVault,
i: APP_COMMAND_IDS.viewToggleProperties,
o: APP_COMMAND_IDS.noteOpenInNewWindow,
}
}
export function handleViewModeKey(e: KeyboardEvent, onSetViewMode: (mode: ViewMode) => void): boolean {
if (isCommandOrCtrlOnly(e) === false || e.shiftKey) return false
const mode = VIEW_MODE_KEYS[e.key]
if (mode === undefined) return false
e.preventDefault()
onSetViewMode(mode)
return true
}
export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap, actions: KeyboardActions): boolean {
if (isCommandOrCtrlOnly(e) === false || e.shiftKey) return false
const commandId = keyMap[e.key]
if (commandId === undefined) return false
if (TEXT_EDITING_KEYS.has(e.key) && isTextInputFocused()) return false
e.preventDefault()
dispatchAppCommand(commandId, actions)
return true
}
export function handleAiPanelKey(e: KeyboardEvent, actions: KeyboardActions): boolean {
const matchesAiPanelShortcut = e.code === 'KeyL' || e.key.toLowerCase() === 'l'
if (isCommandShiftOnly(e) === false || matchesAiPanelShortcut === false || actions.onToggleAIChat === undefined) return false
e.preventDefault()
dispatchAppCommand(APP_COMMAND_IDS.viewToggleAiChat, actions)
return true
}
export function handleShiftCommandKey(e: KeyboardEvent, keyMap: ShortcutMap, actions: KeyboardActions): boolean {
if (isCommandOrCtrlShiftOnly(e) === false) return false
const commandId = keyMap[e.key.toLowerCase()]
if (commandId === undefined) return false
e.preventDefault()
event.preventDefault()
if (commandId === APP_COMMAND_IDS.editFindInVault) {
trackEvent('search_used')
}
dispatchAppCommand(commandId, actions)
return true
}
export function handleAppKeyboardEvent(actions: KeyboardActions, event: KeyboardEvent) {
if (shouldDeferToNativeMenu(event)) return
if (handleAiPanelKey(event, actions)) return
const shiftKeyMap = createShiftCommandKeyMap()
if (handleShiftCommandKey(event, shiftKeyMap, actions)) return
if (handleViewModeKey(event, actions.onSetViewMode)) return
const cmdKeyMap = createCommandKeyMap()
handleCommandKey(event, cmdKeyMap, actions)
}

View File

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

View File

@@ -42,6 +42,18 @@ test.describe('keyboard command routing', () => {
await expect(page.getByTitle('Properties (⌘⇧I)')).toBeVisible({ timeout: 5_000 })
})
test('native menu trigger toggles the raw editor through the shared command path', async ({ page }) => {
await openFixtureVault(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 })
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
})
test('native menu trigger toggles the AI panel through the shared command path', async ({ page }) => {
await openFixtureVault(page, tempVaultDir)
await page.getByText('Alpha Project', { exact: true }).first().click()