feat: new shortcuts hook with registrations (#44954)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

- Brand new hook APIs for registering shortcuts using tanstack hotkeys
- Support for command menu injection when shortcut is added

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

* **New Features**
* Centralized keyboard shortcuts system with per‑shortcut registration
and per‑user enable/disable preferences stored locally
  * Added a "Copy results as Markdown" shortcut (Mod+Shift+M)
* Shortcuts can be surfaced in the Command Menu with a visual shortcut
badge for discoverability

* **Documentation**
* Legacy keyboard shortcut hooks marked as deprecated and documentation
updated to point to the new shortcut API
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ali Waseem authored and GitHub committed 2026-04-17 08:20:36 -06:00
1 parent 8ef6181ef7
commit 3ed436de74
11 files changed
+341 -6

No files matched your search

@@ -7,6 +7,10 @@ function includes(array: string[], element: string) {
/**
* Hook for listening on key events.
*
* @deprecated Use `useShortcut` from `state/shortcuts/useShortcut` instead.
* The new hook reads from a central registry, respects user preferences, and
* can surface shortcuts in the Cmd+P command menu.
*
* @param {Object|Map} keyMap Key names mapped to event handlers. If a key name exists, its
* default behavior will be suppressed.
* @param {Array} whitelistNodes If target element is in the whitelist nodes array, will not
@@ -7,4 +7,5 @@ export const COMMAND_MENU_SECTIONS = {
SQL: 'SQL Editor',
DATABASE: 'Database',
INTEGRATIONS: 'Integrations',
SHORTCUTS: 'Shortcuts',
} as const
+12 -2
View File
@@ -1,14 +1,24 @@
import { useEffect } from 'react'
import { useLatest } from 'react-use'
// [Joshen] Refactor: Remove dependencies, and just make this into a single definition
/**
* @deprecated Use `useShortcut` from `state/shortcuts/useShortcut` instead.
* It reads from a central shortcut registry (`SHORTCUT_DEFINITIONS`) and
* integrates with the user's enable/disable preferences + the Cmd+P command menu.
*
* Migration:
* 1. Add an entry to `state/shortcuts/registry.ts` with a unique ID, label, and sequence.
* 2. Replace `useHotKey(cb, 'k', { shift: true })` with
* `useShortcut(SHORTCUT_IDS.YOUR_ID, cb)`.
*/
function useHotKey(
callback: (e: KeyboardEvent) => void,
key: string,
options?: { enabled?: boolean; shift?: boolean }
): void
/**
* @deprecated The `dependencies` parameter is deprecated. Use the overload without dependencies instead.
* @deprecated Use `useShortcut` from `state/shortcuts/useShortcut` instead.
* The `dependencies` parameter is also deprecated in this legacy hook.
*/
function useHotKey(
callback: (e: KeyboardEvent) => void,
+1
View File
@@ -67,6 +67,7 @@
"@supabase/shared-types": "0.1.88",
"@supabase/sql-to-rest": "^0.1.6",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-hotkeys": "^0.9.1",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0",
"@tanstack/react-table": "^8.21.3",
+46
View File
@@ -0,0 +1,46 @@
import { ShortcutDefinition } from './types'
/**
* The canonical list of shortcut IDs. Add new shortcuts here first, then
* register them in `SHORTCUT_DEFINITIONS` below.
*
* ID convention: `"<surface>.<action>"` in kebab-case, e.g. `"results.copy-markdown"`.
* The `<surface>` groups related shortcuts (sql-editor, table-editor, results, etc).
*/
export const SHORTCUT_IDS = {
RESULTS_COPY_MARKDOWN: 'results.copy-markdown',
} as const
/**
* Union of all valid shortcut IDs. Use this as the `id` parameter type on any
* hook or util that takes a shortcut reference.
*/
export type ShortcutId = (typeof SHORTCUT_IDS)[keyof typeof SHORTCUT_IDS]
/**
* The shortcut registry — every shortcut the app knows about, keyed by
* `ShortcutId`. The `Record` type ensures this map stays exhaustive: adding a
* new entry to `SHORTCUT_IDS` without a matching definition here is a type error.
*
* See `ShortcutDefinition` for the shape of each entry.
*
* @example
* // Add a new shortcut:
* // 1. Add to SHORTCUT_IDS:
* // SQL_EDITOR_RUN: 'sql-editor.run'
* // 2. Add to SHORTCUT_DEFINITIONS:
* // [SHORTCUT_IDS.SQL_EDITOR_RUN]: {
* // id: SHORTCUT_IDS.SQL_EDITOR_RUN,
* // label: 'Run query',
* // sequence: ['Mod+Enter'],
* // }
* // 3. Use in a component:
* // useShortcut(SHORTCUT_IDS.SQL_EDITOR_RUN, runQuery)
*/
export const SHORTCUT_DEFINITIONS: Record<ShortcutId, ShortcutDefinition> = {
[SHORTCUT_IDS.RESULTS_COPY_MARKDOWN]: {
id: SHORTCUT_IDS.RESULTS_COPY_MARKDOWN,
label: 'Copy results as Markdown',
sequence: ['Mod+Shift+M'],
},
}
+51
View File
@@ -0,0 +1,51 @@
import { LOCAL_STORAGE_KEYS } from 'common'
import { useCallback } from 'react'
import type { ShortcutId } from './registry'
import { DisabledShortcuts } from './types'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
const STORAGE_KEY = LOCAL_STORAGE_KEYS.SHORTCUT_STORAGE_KEY
const DEFAULT_DISABLED: DisabledShortcuts = {}
export function useShortcutPreferences() {
const [disabled, setDisabled] = useLocalStorageQuery<DisabledShortcuts>(
STORAGE_KEY,
DEFAULT_DISABLED
)
const setShortcutEnabled = useCallback(
(id: ShortcutId, enabled: boolean) => {
setDisabled((prev) => {
if (enabled) {
const { [id]: _removed, ...rest } = prev
return rest
}
return { ...prev, [id]: true }
})
},
[setDisabled]
)
const resetShortcut = useCallback(
(id: ShortcutId) => {
setDisabled((prev) => {
const { [id]: _removed, ...rest } = prev
return rest
})
},
[setDisabled]
)
const resetAllShortcuts = useCallback(() => {
setDisabled(DEFAULT_DISABLED)
}, [setDisabled])
return {
disabled,
setShortcutEnabled,
resetShortcut,
resetAllShortcuts,
}
}
+80
View File
@@ -0,0 +1,80 @@
import { HotkeySequence } from '@tanstack/react-hotkeys'
export type DisabledShortcuts = Record<string, boolean>
/**
* Runtime options for a shortcut. Used in two places:
*
* 1. On a `ShortcutDefinition` in the registry — acts as the default options
* whenever the shortcut is mounted via `useShortcut`.
* 2. As the third argument to `useShortcut(id, callback, options)` — lets the
* call site override the registry defaults for a specific mount.
*
* Caller options take priority over registry defaults, which take priority over
* the hard-coded fallbacks listed on each field below.
*/
export interface ShortcutOptions {
/**
* Whether the shortcut is live. Defaults to `true`.
*
* Conjunctive with the user's global enable/disable preference: if the user
* has disabled a shortcut in Account → Preferences, passing `enabled: true`
* here will NOT re-enable it. Use this to gate the shortcut on local
* conditions (e.g. `enabled: hasUnsavedChanges`).
*/
enabled?: boolean
/**
* Maximum time in milliseconds between consecutive keys in a multi-step
* sequence. Defaults to `undefined`, which falls through to TanStack's
* library default (1000ms).
*
* Only meaningful for multi-step sequences like `['G', 'G']`. Single-step
* shortcuts ignore this.
*/
timeout?: number
/**
* When `true`, the shortcut also appears as an entry in the Cmd+P command
* menu (under the "Shortcuts" section) for as long as the hook is mounted.
* The entry's label comes from `ShortcutDefinition.label` and the keybind
* is rendered from `ShortcutDefinition.sequence`.
*
* Defaults to `false` — opt-in per call site so Cmd+P doesn't fill up with
* context-specific shortcuts that only make sense in certain views.
*/
registerInCommandMenu?: boolean
}
/**
* A single entry in the shortcut registry. Every shortcut the app uses must
* have a matching definition in `SHORTCUT_DEFINITIONS`.
*
* The registry is the single source of truth for:
* - the keybind (`sequence`) — used by the hotkey listener AND the Cmd+P badge
* - the human-readable `label` — shown in Cmd+P and in the preferences UI
* - per-shortcut default `options` — overridable per call site
*/
export interface ShortcutDefinition {
/** Stable unique identifier. Must match the key used in `SHORTCUT_IDS`. */
id: string
/** Human-readable label shown in the command menu and preferences UI. */
label: string
/**
* Keybind as a TanStack Hotkeys sequence — an array of one or more hotkey
* strings. Single-step: `['Mod+Shift+M']`. Multi-step (chord): `['G', 'G']`.
*
* Use `Mod` for the platform modifier (⌘ on macOS, Ctrl elsewhere). Supports
* `Shift`, `Alt`, `Ctrl` (literal), named keys (`Enter`, `Escape`, arrows),
* and single character keys.
*/
sequence: HotkeySequence
/**
* Default runtime options applied when the shortcut is mounted. Each field
* is overridable by the caller of `useShortcut`.
*/
options?: ShortcutOptions
}
@@ -0,0 +1,20 @@
import type { ShortcutId } from './registry'
import { useShortcutPreferences } from './state'
/**
* Reactive check for whether a shortcut is currently enabled for the user.
*
* Subscribes the calling component to the shortcut preferences query so it
* re-renders when the user toggles this shortcut in Account → Preferences.
*
* For one-off, non-reactive checks outside render (event handlers, module-level
* code), use the plain `isShortcutEnabled(id)` function from `./state` instead.
*
* @example
* const isEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE)
* return <Tooltip>{isEnabled && <KeyboardShortcut keys={['Meta', 'I']} />}</Tooltip>
*/
export function useIsShortcutEnabled(id: ShortcutId): boolean {
const { disabled } = useShortcutPreferences()
return !disabled[id]
}
@@ -0,0 +1,94 @@
import { useHotkeySequence } from '@tanstack/react-hotkeys'
import { Fragment, useCallback } from 'react'
import { KeyboardShortcut } from 'ui'
import { useRegisterCommands } from 'ui-patterns/CommandMenu'
import { SHORTCUT_DEFINITIONS, type ShortcutId } from './registry'
import type { ShortcutOptions } from './types'
import { useIsShortcutEnabled } from './useIsShortcutEnabled'
import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils'
import useLatest from '@/hooks/misc/useLatest'
const hotkeyToKeys = (hotkey: string): string[] =>
hotkey.split('+').map((part) => (part === 'Mod' ? 'Meta' : part))
/**
* 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
useHotkeySequence(def.sequence, callback, { enabled, timeout })
// Handle overrides for command menu
const enabledInCommandMenu = enabled && (options?.registerInCommandMenu ?? false)
const depsInCommandMenu = [enabled, def.label]
const callbackRef = useLatest(callback)
const stableAction = useCallback(() => callbackRef.current(), [callbackRef])
useRegisterCommands(
COMMAND_MENU_SECTIONS.SHORTCUTS,
[
{
id,
name: def.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,
}
)
}
@@ -80,6 +80,9 @@ export const LOCAL_STORAGE_KEYS = {
// api keys view switcher for new and legacy api keys
API_KEYS_VIEW: (ref: string) => `supabase-api-keys-view-${ref}`,
// Shortcut preferences
SHORTCUT_STORAGE_KEY: 'supabase-shortcut-preferences',
LAST_VISITED_ORGANIZATION: 'last-visited-organization',
// user impersonation selector previous searches
+29 -4
View File
@@ -933,7 +933,7 @@ importers:
version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@sentry/nextjs':
specifier: 'catalog:'
version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2))
version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2))
'@std/path':
specifier: npm:@jsr/std__path@^1.0.8
version: '@jsr/std__path@1.0.8'
@@ -967,6 +967,9 @@ importers:
'@supabase/supabase-js':
specifier: 'catalog:'
version: 2.103.2
'@tanstack/react-hotkeys':
specifier: ^0.9.1
version: 0.9.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-query':
specifier: ^5.0.0
version: 5.83.0(react@18.3.1)
@@ -1086,7 +1089,7 @@ importers:
version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
nuqs:
specifier: 2.7.1
version: 2.7.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 2.7.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
openai:
specifier: ^4.104.0
version: 4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76)
@@ -8945,12 +8948,23 @@ packages:
resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==}
engines: {node: '>=20.19'}
'@tanstack/hotkeys@0.7.1':
resolution: {integrity: sha512-YHVO1z6wnvUCu7bg870Kv5k2D+FIuIOSIcbN0dAmTTsJ3mLMDLwcTVx0qVaq+SZp1B514JJTqGVstvUp85yIpQ==}
engines: {node: '>=18'}
'@tanstack/query-core@5.83.0':
resolution: {integrity: sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==}
'@tanstack/query-devtools@5.90.1':
resolution: {integrity: sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==}
'@tanstack/react-hotkeys@0.9.1':
resolution: {integrity: sha512-/qdQUUVkYAHAWRGdFXqFgWpW/S+a6OzkvxWNWKLLDHQODJlO6EPBPa073CglaafBfzig58RK07T09ET+NnZhpg==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/react-query-devtools@5.90.2':
resolution: {integrity: sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==}
peerDependencies:
@@ -25761,7 +25775,7 @@ snapshots:
- supports-color
- webpack
'@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2))':
'@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.38.0
@@ -26519,10 +26533,21 @@ snapshots:
'@tanstack/history@1.161.6': {}
'@tanstack/hotkeys@0.7.1':
dependencies:
'@tanstack/store': 0.9.3
'@tanstack/query-core@5.83.0': {}
'@tanstack/query-devtools@5.90.1': {}
'@tanstack/react-hotkeys@0.9.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/hotkeys': 0.7.1
'@tanstack/react-store': 0.9.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/react-query-devtools@5.90.2(@tanstack/react-query@5.83.0(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/query-devtools': 5.90.1
@@ -34199,7 +34224,7 @@ snapshots:
mitt: 3.0.1
next: 15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
nuqs@2.7.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
nuqs@2.7.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
dependencies:
'@standard-schema/spec': 1.0.0
react: 18.3.1