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 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 multiSelectionCommandRef?: MutableRefObject } 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, handler: (path: string) => void, ): boolean { const path = pathRef.current if (!path) return false handler(path) return true } function dispatchMultiSelectionCommand( selectionRef: MutableRefObject | 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 }