import { useHotkeySequence } from '@tanstack/react-hotkeys' import { Fragment, useCallback, useMemo } from 'react' import { KeyboardShortcut } from 'ui' import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' import { hotkeyToKeys } from './formatShortcut' import { SHORTCUT_DEFINITIONS, type ShortcutId } from './registry' import type { ShortcutHotkeyMeta, ShortcutOptions } from './types' import { useIsShortcutEnabled } from './useIsShortcutEnabled' import { orderShortcutCommands } from './utils' import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils' import useLatest from '@/hooks/misc/useLatest' /** * Subscribe to a registered keyboard shortcut. * * Looks up the shortcut's `sequence` and `label` from `SHORTCUT_DEFINITIONS`, * wires up a global hotkey listener via `@tanstack/react-hotkeys`, and * (optionally) registers the shortcut as an entry in the Cmd+P command menu * under the "Shortcuts" section for as long as the hook is mounted. * * Option resolution priority (highest first): * 1. `options` passed to this hook * 2. `def.options` from the registry entry * 3. Hard-coded fallbacks (`enabled: true`, `timeout: undefined`, `registerInCommandMenu: false`) * * `enabled` is ANDed with the user's global enable/disable preference — if the * user has disabled the shortcut in Preferences, it won't fire even if the * caller or registry say `enabled: true`. * * @param id The registered shortcut to bind to. See `SHORTCUT_IDS`. * @param callback Runs when the sequence matches. Always calls the latest * reference — no stale closure issues. * @param options Per-mount overrides. See `ShortcutOptions`. * * @example * useShortcut(SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, handleCopy) * * @example * // Surface in Cmd+P while this component is mounted: * useShortcut(SHORTCUT_IDS.SQL_EDITOR_RUN, runQuery, { * registerInCommandMenu: true, * }) * * @example * // Gate on local state — disables hotkey AND hides Cmd+P entry when false: * useShortcut(SHORTCUT_IDS.SAVE, handleSave, { * enabled: hasUnsavedChanges, * registerInCommandMenu: true, * }) */ export function useShortcut(id: ShortcutId, callback: () => void, options?: ShortcutOptions) { const def = SHORTCUT_DEFINITIONS[id] // Handle override for the shortcut const globallyEnabled = useIsShortcutEnabled(id) const callerEnabled = options?.enabled ?? def.options?.enabled ?? true const enabled = globallyEnabled && callerEnabled const timeout = options?.timeout ?? def.options?.timeout ?? undefined const ignoreInputs = options?.ignoreInputs ?? def.options?.ignoreInputs const registerInCommandMenu = options?.registerInCommandMenu ?? def.options?.registerInCommandMenu ?? false const label = options?.label ?? def.label const conflictBehavior = options?.conflictBehavior ?? def.options?.conflictBehavior // Stable identity so we don't churn the registration store on every render. // setOptions in @tanstack/hotkeys notifies subscribers each call, which // would cascade to every component using useHotkeyRegistrations(). const meta = useMemo( () => ({ id, name: label, referenceGroup: def.referenceGroup }), [def.referenceGroup, id, label] ) // Only include `ignoreInputs` when set. The library resolves it to a concrete // boolean at register time (false for Meta/Ctrl/Escape, true otherwise), but // its setOptions does an object spread on every re-render — passing // `ignoreInputs: undefined` would overwrite the resolved value and re-enable // the input-focus guard for shortcuts that should always fire. useHotkeySequence(def.sequence, callback, { enabled, timeout, meta, ...(ignoreInputs !== undefined && { ignoreInputs }), ...(conflictBehavior !== undefined && { conflictBehavior }), }) // Handle overrides for command menu const enabledInCommandMenu = enabled && registerInCommandMenu const depsInCommandMenu = [enabled, label] const callbackRef = useLatest(callback) const setCommandMenuOpen = useSetCommandMenuOpen() const stableAction = useCallback(() => { setCommandMenuOpen(false) callbackRef.current() }, [callbackRef, setCommandMenuOpen]) useRegisterCommands( COMMAND_MENU_SECTIONS.SHORTCUTS, [ { id, name: label, action: stableAction, badge: () => (
{def.sequence.map((step, i) => ( {i > 0 && then} ))}
), }, ], { enabled: enabledInCommandMenu, deps: depsInCommandMenu, orderCommands: orderShortcutCommands, sectionMeta: { priority: 1 }, } ) }