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

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