Files
tolaria/src/hooks/appCommandDispatcher.ts
2026-05-07 20:54:32 +02:00

322 lines
10 KiB
TypeScript

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'
import type { NoteListMultiSelectionCommands } from '../components/note-list/multiSelectionCommands'
export const APP_COMMAND_EVENT_NAME = 'laputa:dispatch-command'
export {
APP_COMMAND_IDS,
findShortcutCommandId,
findShortcutCommandIdForEvent,
isAppCommandId,
isNativeMenuCommandId,
} from './appCommandCatalog'
export type { AppCommandDefinition, AppCommandId, AppCommandShortcutCombo } from './appCommandCatalog'
export type AppCommandDispatchSource =
| 'direct'
| 'renderer-keyboard'
| 'native-menu'
| 'app-event'
type SuppressedShortcutSource = Extract<AppCommandDispatchSource, 'renderer-keyboard'>
export interface AppCommandHandlers {
onSetViewMode: (mode: ViewMode) => void
onCreateNote: () => void
onCreateType?: () => void
onQuickOpen: () => void
onSave: () => void
onOpenSettings: () => void
onToggleInspector: () => void
onCommandPalette: () => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
onToggleOrganized?: (path: string) => void
onToggleFavorite?: (path: string) => void
onArchiveNote: (path: string) => void
onDeleteNote: (path: string) => void
onFindInNote?: () => void
onReplaceInNote?: () => void
onPastePlainText: () => void
onSearch: () => void
onToggleRawEditor?: () => void
onToggleDiff?: () => void
onToggleAIChat?: () => void
onToggleTableOfContents?: () => void
onGoBack?: () => void
onGoForward?: () => void
onCheckForUpdates?: () => void
onSelectFilter?: (filter: SidebarFilter) => void
onOpenVault?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
onAddRemote?: () => void
onCommitPush?: () => void
onPull?: () => void
onResolveConflicts?: () => void
onViewChanges?: () => void
onInstallMcp?: () => void
onOpenInNewWindow?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onRestoreDeletedNote?: () => void
activeTabPathRef: MutableRefObject<string | null>
multiSelectionCommandRef?: MutableRefObject<NoteListMultiSelectionCommands | null>
}
type SimpleHandlerKey = keyof Pick<
AppCommandHandlers,
| 'onOpenSettings'
| 'onCheckForUpdates'
| 'onCreateNote'
| 'onCreateType'
| 'onQuickOpen'
| 'onSave'
| 'onFindInNote'
| 'onReplaceInNote'
| 'onPastePlainText'
| 'onSearch'
| 'onToggleRawEditor'
| 'onToggleDiff'
| 'onToggleInspector'
| 'onToggleAIChat'
| 'onToggleTableOfContents'
| 'onCommandPalette'
| 'onZoomIn'
| 'onZoomOut'
| 'onZoomReset'
| 'onGoBack'
| 'onGoForward'
| 'onOpenVault'
| 'onRemoveActiveVault'
| 'onRestoreGettingStarted'
| 'onAddRemote'
| 'onCommitPush'
| 'onPull'
| 'onResolveConflicts'
| 'onViewChanges'
| 'onInstallMcp'
| 'onReloadVault'
| 'onRepairVault'
| 'onOpenInNewWindow'
| 'onRestoreDeletedNote'
>
type ActiveTabHandlerKey = keyof Pick<
AppCommandHandlers,
'onToggleOrganized' | 'onToggleFavorite' | 'onArchiveNote' | 'onDeleteNote'
>
type SimpleHandlerExecutor = (handlers: AppCommandHandlers) => void
type ActiveTabHandlerExecutor = (handlers: AppCommandHandlers, path: string) => void
const SIMPLE_HANDLER_EXECUTORS: readonly [SimpleHandlerKey, SimpleHandlerExecutor][] = [
['onOpenSettings', (handlers) => handlers.onOpenSettings()],
['onCheckForUpdates', (handlers) => handlers.onCheckForUpdates?.()],
['onCreateNote', (handlers) => handlers.onCreateNote()],
['onCreateType', (handlers) => handlers.onCreateType?.()],
['onQuickOpen', (handlers) => handlers.onQuickOpen()],
['onSave', (handlers) => handlers.onSave()],
['onFindInNote', (handlers) => handlers.onFindInNote?.()],
['onReplaceInNote', (handlers) => handlers.onReplaceInNote?.()],
['onPastePlainText', (handlers) => handlers.onPastePlainText()],
['onSearch', (handlers) => handlers.onSearch()],
['onToggleRawEditor', (handlers) => handlers.onToggleRawEditor?.()],
['onToggleDiff', (handlers) => handlers.onToggleDiff?.()],
['onToggleInspector', (handlers) => handlers.onToggleInspector()],
['onToggleAIChat', (handlers) => handlers.onToggleAIChat?.()],
['onToggleTableOfContents', (handlers) => handlers.onToggleTableOfContents?.()],
['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?.()],
['onAddRemote', (handlers) => handlers.onAddRemote?.()],
['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: readonly [ActiveTabHandlerKey, ActiveTabHandlerExecutor][] = [
['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 runSimpleHandler(handler: SimpleHandlerKey, handlers: AppCommandHandlers): void {
const executor = SIMPLE_HANDLER_EXECUTORS.find(([key]) => key === handler)?.[1]
executor?.(handlers)
}
function runActiveTabHandler(handler: ActiveTabHandlerKey, handlers: AppCommandHandlers, path: string): void {
const executor = ACTIVE_TAB_HANDLER_EXECUTORS.find(([key]) => key === handler)?.[1]
executor?.(handlers, path)
}
const SHORTCUT_ECHO_DEDUPE_WINDOW_MS = 150
let lastCommandDispatch:
| {
id: AppCommandId
source: AppCommandDispatchSource
timestamp: number
}
| null = null
let lastSuppressedShortcutCommand:
| {
id: AppCommandId
source: SuppressedShortcutSource
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 shouldSuppressShortcutEchoAfterKeyboardYield(
id: AppCommandId,
source: AppCommandDispatchSource,
currentTimestamp: number,
): boolean {
if (source !== 'native-menu') return false
if (!lastSuppressedShortcutCommand || lastSuppressedShortcutCommand.id !== id) return false
return currentTimestamp - lastSuppressedShortcutCommand.timestamp <= SHORTCUT_ECHO_DEDUPE_WINDOW_MS
}
function dispatchActiveTabCommand(
pathRef: MutableRefObject<string | null>,
handler: (path: string) => void,
): boolean {
const path = pathRef.current
if (!path) return false
handler(path)
return true
}
function dispatchMultiSelectionCommand(
selectionRef: MutableRefObject<NoteListMultiSelectionCommands | null> | undefined,
handler: ActiveTabHandlerKey,
): boolean | null {
const selection = selectionRef?.current
if (!selection || selection.selectedPaths.length <= 1) return null
if (handler === 'onDeleteNote') {
selection.deleteSelected?.()
return !!selection.deleteSelected
}
if (handler === 'onToggleOrganized') {
selection.organizeSelected?.()
return !!selection.organizeSelected
}
return false
}
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': {
runSimpleHandler(definition.route.handler as SimpleHandlerKey, handlers)
return true
}
case 'active-tab-handler': {
const handler = definition.route.handler
const multiSelectionResult = dispatchMultiSelectionCommand(
handlers.multiSelectionCommandRef,
handler as ActiveTabHandlerKey,
)
if (multiSelectionResult !== null) {
return multiSelectionResult
}
return dispatchActiveTabCommand(
handlers.activeTabPathRef,
(path) => runActiveTabHandler(handler as ActiveTabHandlerKey, handlers, path),
)
}
}
}
export function dispatchAppCommand(id: AppCommandId, handlers: AppCommandHandlers): boolean {
return executeAppCommand(id, handlers, 'direct')
}
export function executeAppCommand(
id: AppCommandId,
handlers: AppCommandHandlers,
source: AppCommandDispatchSource,
): boolean {
const timestamp = now()
if (shouldSuppressShortcutEchoAfterKeyboardYield(id, source, timestamp)) {
return false
}
if (shouldSuppressDuplicateCommand(id, source, timestamp)) {
return false
}
const definition = Reflect.get(APP_COMMAND_DEFINITIONS, id) as AppCommandDefinition
const dispatched = dispatchDefinition(definition, handlers)
if (dispatched) {
lastCommandDispatch = { id, source, timestamp }
}
return dispatched
}
export function recordSuppressedShortcutCommand(
id: AppCommandId,
source: SuppressedShortcutSource = 'renderer-keyboard',
): void {
lastSuppressedShortcutCommand = { id, source, timestamp: now() }
}
export function resetAppCommandDispatchStateForTests(): void {
lastCommandDispatch = null
lastSuppressedShortcutCommand = null
}