Files
supabase/apps/studio/state/shortcuts/useShortcut.tsx
Ali Waseem 722fe85c16 feat(studio): keyboard shortcuts for integrations (#46348)
## Summary

Adds keyboard shortcuts to the Integrations Marketplace landing and
per-integration detail pages. Introduces a `useDynamicShortcut` hook
since per-integration tab counts/labels can't be pre-declared in the
static registry.

## Shortcuts

| Page | Keys | Action |
|---|---|---|
| Marketplace landing | `Shift+F` | Focus the integrations search input
|
| Marketplace landing | `F` then `C` | Clear search +
category/type/source filters |
| Marketplace landing search | `Esc` | Clear value (1st press), blur
(2nd press) |
| Integration detail | `1`–`9` | Jump to the Nth tab (label adapts per
integration, e.g. "Go to Queues", "Go to Jobs") |

Linear: [FE-3416](https://linear.app/supabase/issue/FE-3416)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Use number keys 1–9 to jump to integration detail tabs.
* Marketplace search shortcuts: focus/select the search field and reset
filters via keyboard; Escape now clears the search input.
* Shortcuts now appear in the command menu under a dedicated
integrations navigation group.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46348?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-26 14:33:19 +00:00

124 lines
4.9 KiB
TypeScript

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<ShortcutHotkeyMeta>(
() => ({ 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: () => (
<div className="flex items-center gap-1">
{def.sequence.map((step, i) => (
<Fragment key={i}>
{i > 0 && <span className="text-foreground-lighter text-[11px]">then</span>}
<KeyboardShortcut keys={hotkeyToKeys(step)} />
</Fragment>
))}
</div>
),
},
],
{
enabled: enabledInCommandMenu,
deps: depsInCommandMenu,
orderCommands: orderShortcutCommands,
sectionMeta: { priority: 1 },
}
)
}