feat(studio): add navigation shortcuts with hover tooltips (#45127)

## Summary
- Adds `<Shortcut>` component: binds a registered shortcut + renders a
Linear-style hover tooltip (label + keybind pills) in one declaration
- Adds `<ShortcutTooltip>` as the lower-level primitive for cases where
binding lives elsewhere
- Registers 13 G-chord navigation shortcuts (`G H` / `G T` / `G S` / `G
D` / `G A` / `G B` / `G F` / `G R` / `G V` / `G O` / `G L` / `G I` / `G
,`)
- Wires shortcuts into `SideBarNavLink` — binding + dynamic-delay
tooltip (0ms collapsed, 1000ms expanded) replaces the old label-only
collapsed tooltip

Closes
[FE-3048](https://linear.app/supabase/issue/FE-3048/create-navigation-shortcuts).

## Test plan
- [x] Hover each main sidebar nav item with sidebar expanded — tooltip
appears after delay with label + shortcut pills
- [x] Collapse sidebar — tooltip appears instantly on hover
- [x] Press `G` then a registered letter anywhere outside text inputs —
navigates to that screen
- [x] Press `G` inside a text input — no navigation fires
- [x] Disabled nav items (project inactive) — no shortcut fires, no
tooltip anomaly
- [x] Feature-gated routes (auth/storage/realtime off) — shortcut is
gone with the route

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

* **New Features**
* Global keyboard shortcuts added for navigating major app sections
(home, editors, database, auth, storage, functions, realtime, advisors,
observability, logs, integrations, settings).
* Navigation items display their keybinds in hover tooltips and can be
triggered via those shortcuts.
* Tooltip timing adapts to sidebar state (immediate when collapsed,
delayed when expanded).
* Shortcut-aware tooltip display now shows multi-step key sequences
clearly.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ali Waseem authored and GitHub committed 2026-04-23 08:06:03 -06:00
1 parent fca7c86aec
commit 28a2c25e0e
7 files changed
+339 -11

No files matched your search

+39 -11
View File
@@ -27,6 +27,7 @@ import {
useSidebar,
} from 'ui'
import { Shortcut } from '../ui/Shortcut'
import { Route } from '../ui/ui.types'
import {
useIsPlatformWebhooksEnabled,
@@ -47,6 +48,7 @@ import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useAppStateSnapshot } from '@/state/app-state'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
export const ICON_SIZE = 32
export const ICON_STROKE_WIDTH = 1.5
@@ -167,14 +169,23 @@ export function SideBarNavLink({
active?: boolean
onClick?: () => void
} & ComponentPropsWithoutRef<typeof SidebarMenuButton>) {
const router = useRouter()
const { state: sidebarState } = useSidebar()
const [sidebarBehaviour] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR,
DEFAULT_SIDEBAR_BEHAVIOR
)
const isActiveLink = !!(route.link && !route.disabled)
const hasShortcut = !!(route.shortcutId && isActiveLink)
// Collapsed: show immediately (replaces the old label-only tooltip
// that used to surface the name of an icon-only item). Expanded:
// slight delay so the tooltip doesn't flash while skimming the nav.
const shortcutPopoverDelay = sidebarState === 'collapsed' ? 0 : 1000
const buttonProps = {
disabled: route.disabled,
tooltip: sidebarBehaviour === 'closed' ? route.label : '',
isActive: active,
className: cn('text-sm', sidebarBehaviour === 'open' ? '!px-2' : ''),
size: 'default' as const,
@@ -190,14 +201,27 @@ export function SideBarNavLink({
</>
)
const button = isActiveLink ? (
<SidebarMenuButton {...buttonProps} asChild>
<Link href={route.link!}>{content}</Link>
</SidebarMenuButton>
) : (
<SidebarMenuButton {...buttonProps}>{content}</SidebarMenuButton>
)
return (
<SidebarMenuItem>
{route.link && !route.disabled ? (
<SidebarMenuButton {...buttonProps} asChild>
<Link href={route.link}>{content}</Link>
</SidebarMenuButton>
{hasShortcut ? (
<Shortcut
id={route.shortcutId!}
onTrigger={() => router.push(route.link!)}
side="right"
delayDuration={shortcutPopoverDelay}
>
{button}
</Shortcut>
) : (
<SidebarMenuButton {...buttonProps}>{content}</SidebarMenuButton>
button
)}
</SidebarMenuItem>
)
@@ -218,13 +242,9 @@ const ProjectLinks = () => {
const router = useRouter()
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const snap = useAppStateSnapshot()
const { securityLints, errorLints } = useLints()
const showReports = useIsFeatureEnabled('reports:all')
const { mutate: sendEvent } = useSendEventMutation()
const platformWebhooksEnabled = useIsPlatformWebhooksEnabled()
const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
const activeRoute = router.pathname.split('/')[3]
@@ -269,6 +289,7 @@ const ProjectLinks = () => {
icon: <Home size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: `/project/${ref}`,
linkElement: <ProjectIndexPageLink projectRef={ref} />,
shortcutId: SHORTCUT_IDS.NAV_HOME,
}}
/>
{toolRoutes.map((route, i) => (
@@ -291,7 +312,7 @@ const ProjectLinks = () => {
</SidebarGroup>
<Separator className="w-[calc(100%-1rem)] mx-auto" />
<SidebarGroup className="gap-0.5">
{otherRoutes.map((route, i) => {
{otherRoutes.map((route) => {
if (route.key === 'advisors') {
return (
<div className="relative" key={route.key}>
@@ -354,24 +375,28 @@ const OrganizationLinks = () => {
href: `/org/${organizationSlug}`,
key: 'projects',
icon: <Boxes size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
shortcutId: SHORTCUT_IDS.NAV_ORG_PROJECTS,
},
{
label: 'Team',
href: `/org/${organizationSlug}/team`,
key: 'team',
icon: <Users size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
shortcutId: SHORTCUT_IDS.NAV_ORG_TEAM,
},
{
label: 'Integrations',
href: `/org/${organizationSlug}/integrations`,
key: 'integrations',
icon: <Blocks size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
shortcutId: SHORTCUT_IDS.NAV_ORG_INTEGRATIONS,
},
{
label: 'Usage',
href: `/org/${organizationSlug}/usage`,
key: 'usage',
icon: <ChartArea size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
shortcutId: SHORTCUT_IDS.NAV_ORG_USAGE,
},
...(showBilling
? [
@@ -380,6 +405,7 @@ const OrganizationLinks = () => {
href: `/org/${organizationSlug}/billing`,
key: 'billing',
icon: <Receipt size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
shortcutId: SHORTCUT_IDS.NAV_ORG_BILLING,
},
]
: []),
@@ -388,6 +414,7 @@ const OrganizationLinks = () => {
href: `/org/${organizationSlug}/general`,
key: 'settings',
icon: <Settings size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
shortcutId: SHORTCUT_IDS.NAV_ORG_SETTINGS,
},
]
@@ -412,6 +439,7 @@ const OrganizationLinks = () => {
key: item.label,
icon: item.icon,
disabled: disableAccessMfa,
shortcutId: item.shortcutId,
}}
/>
))}
@@ -6,6 +6,7 @@ import type { Route } from '@/components/ui/ui.types'
import { EditorIndexPageLink } from '@/data/prefetchers/project.$ref.editor'
import type { Project } from '@/data/projects/project-detail-query'
import { IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
interface RouteContext {
ref?: string
@@ -52,6 +53,7 @@ export const generateToolRoutes = (ref?: string, project?: Project): Route[] =>
icon: <TableEditor size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/editor`),
linkElement: <EditorIndexPageLink projectRef={ref} />,
shortcutId: SHORTCUT_IDS.NAV_TABLE_EDITOR,
},
{
key: 'sql',
@@ -59,6 +61,7 @@ export const generateToolRoutes = (ref?: string, project?: Project): Route[] =>
disabled: !isProjectActive,
icon: <SqlEditor size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/sql`),
shortcutId: SHORTCUT_IDS.NAV_SQL_EDITOR,
},
]
}
@@ -89,6 +92,7 @@ export const generateProductRoutes = (
: isProjectActive
? `/project/${ref}/database/schemas`
: `/project/${ref}/database/backups/scheduled`),
shortcutId: SHORTCUT_IDS.NAV_DATABASE,
},
...(authEnabled
? [
@@ -104,6 +108,7 @@ export const generateProductRoutes = (
: authOverviewPageEnabled
? `/project/${ref}/auth/overview`
: `/project/${ref}/auth/users`),
shortcutId: SHORTCUT_IDS.NAV_AUTH,
},
]
: []),
@@ -115,6 +120,7 @@ export const generateProductRoutes = (
disabled: !isProjectActive,
icon: <Storage size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/storage/files`),
shortcutId: SHORTCUT_IDS.NAV_STORAGE,
},
]
: []),
@@ -126,6 +132,7 @@ export const generateProductRoutes = (
disabled: false,
icon: <EdgeFunctions size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && `/project/${ref}/functions`,
shortcutId: SHORTCUT_IDS.NAV_FUNCTIONS,
},
]
: []),
@@ -137,6 +144,7 @@ export const generateProductRoutes = (
disabled: !isProjectActive,
icon: <Realtime size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/realtime/inspector`),
shortcutId: SHORTCUT_IDS.NAV_REALTIME,
},
]
: []),
@@ -160,6 +168,7 @@ export const generateOtherRoutes = (
disabled: !isProjectActive,
icon: <Lightbulb size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/advisors/security`),
shortcutId: SHORTCUT_IDS.NAV_ADVISORS,
},
// Observability is only available on the platform, not for self-hosted/CLI
...(isPlatform && reportsEnabled
@@ -170,6 +179,7 @@ export const generateOtherRoutes = (
disabled: !isProjectActive,
icon: <Telescope size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/observability`),
shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY,
},
]
: []),
@@ -179,6 +189,7 @@ export const generateOtherRoutes = (
disabled: false,
icon: <List size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (unifiedLogsEnabled ? `/project/${ref}/logs` : `/project/${ref}/logs/explorer`),
shortcutId: SHORTCUT_IDS.NAV_LOGS,
},
{
key: 'integrations',
@@ -186,6 +197,7 @@ export const generateOtherRoutes = (
disabled: !isProjectActive,
icon: <Blocks size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/integrations`),
shortcutId: SHORTCUT_IDS.NAV_INTEGRATIONS,
},
]
}
@@ -202,6 +214,7 @@ export const generateSettingsRoutes = (ref?: string, features?: SettingsFeatures
ref &&
(isPlatform ? `/project/${ref}/settings/general` : `/project/${ref}/settings/log-drains`),
disabled: false,
shortcutId: SHORTCUT_IDS.NAV_SETTINGS,
},
]
}
+77
View File
@@ -0,0 +1,77 @@
import { TooltipContentProps } from '@ui/components/shadcn/ui/tooltip'
import type { ReactNode } from 'react'
import { ShortcutTooltip } from './ShortcutTooltip'
import type { ShortcutId } from '@/state/shortcuts/registry'
import type { ShortcutOptions } from '@/state/shortcuts/types'
import { useShortcut } from '@/state/shortcuts/useShortcut'
interface ShortcutProps {
/** Registered shortcut id — drives both the hotkey binding and the tooltip. */
id: ShortcutId
/** Fires on the hotkey. Usually the same handler wired to the child's `onClick`. */
onTrigger: () => void
/** Element to bind the shortcut to and wrap in the tooltip. */
children: ReactNode
/** Per-mount overrides for the shortcut — see `ShortcutOptions`. */
options?: ShortcutOptions
side?: TooltipContentProps['side']
align?: TooltipContentProps['align']
sideOffset?: number
delayDuration?: number
/**
* Override the label from the registry. Use when the wrapped element's
* action is a narrower/contextual variant of the registered shortcut.
*/
label?: string
}
/**
* Bind a registered shortcut to an element AND show its keybind on hover,
* Linear-style. Single source of truth: one `id` drives both the hotkey
* listener and the tooltip, so they can't drift.
*
* The wrapped child stays fully interactive — Radix `asChild` passes clicks,
* focus, and refs through untouched.
*
* @example
* <Shortcut id={SHORTCUT_IDS.RESULTS_COPY_MARKDOWN} onTrigger={handleCopy}>
* <Button onClick={handleCopy}>Copy</Button>
* </Shortcut>
*
* @example
* // Gate the hotkey on local state; tooltip still renders:
* <Shortcut
* id={SHORTCUT_IDS.ACTION_BAR_SAVE}
* onTrigger={handleSave}
* options={{ enabled: hasUnsavedChanges }}
* >
* <Button onClick={handleSave} disabled={!hasUnsavedChanges}>Save</Button>
* </Shortcut>
*/
export const Shortcut = ({
id,
onTrigger,
children,
options,
side,
align,
sideOffset,
delayDuration,
label,
}: ShortcutProps) => {
useShortcut(id, onTrigger, options)
return (
<ShortcutTooltip
shortcutId={id}
side={side}
align={align}
sideOffset={sideOffset}
delayDuration={delayDuration}
label={label}
>
{children}
</ShortcutTooltip>
)
}
@@ -0,0 +1,67 @@
import { TooltipContentProps } from '@ui/components/shadcn/ui/tooltip'
import { Fragment, type ReactNode } from 'react'
import { KeyboardShortcut, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut'
import { SHORTCUT_DEFINITIONS, type ShortcutId } from '@/state/shortcuts/registry'
interface ShortcutTooltipProps {
shortcutId: ShortcutId
children: ReactNode
side?: TooltipContentProps['side']
align?: TooltipContentProps['align']
sideOffset?: number
delayDuration?: number
/**
* Override the label from the registry. Use when the wrapped element's
* action is a narrower/contextual variant of the registered shortcut.
*/
label?: string
}
/**
* Wraps any element to show its bound keyboard shortcut on hover/focus, in the
* style of Linear's shortcut tooltips: `"<label> <key> [then <key>]"`.
*
* Uses Radix's `asChild` trigger, so the wrapped element remains fully
* interactive — clicks, focus, and event handlers pass through untouched.
*
* @example
* <ShortcutTooltip shortcutId={SHORTCUT_IDS.RESULTS_COPY_MARKDOWN}>
* <Button onClick={handleCopy}>Copy</Button>
* </ShortcutTooltip>
*/
export const ShortcutTooltip = ({
shortcutId,
children,
side,
align,
sideOffset,
delayDuration,
label: labelOverride,
}: ShortcutTooltipProps) => {
const def = SHORTCUT_DEFINITIONS[shortcutId]
const label = labelOverride ?? def.label
return (
<Tooltip delayDuration={delayDuration}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side={side}
align={align}
sideOffset={sideOffset}
className="flex items-center gap-2"
>
<span>{label}</span>
<span 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>
))}
</span>
</TooltipContent>
</Tooltip>
)
}
+8
View File
@@ -1,5 +1,7 @@
import { ReactNode } from 'react'
import type { ShortcutId } from '@/state/shortcuts/registry'
export interface Route {
key: string
label: string
@@ -8,4 +10,10 @@ export interface Route {
disabled?: boolean
linkElement?: ReactNode
items?: any | Route[]
/**
* Binds a registered keyboard shortcut to this route when set. The sidebar
* entry shows the keybind on hover and jumps to `link` when the shortcut
* fires. Ignored if `link` is not set.
*/
shortcutId?: ShortcutId
}
+133
View File
@@ -29,6 +29,25 @@ export const SHORTCUT_IDS = {
OPERATION_QUEUE_TOGGLE: 'operation-queue.toggle',
OPERATION_QUEUE_UNDO: 'operation-queue.undo',
UNIFIED_LOGS_RESET_FOCUS: 'unified-logs.reset-focus',
NAV_HOME: 'nav.home',
NAV_TABLE_EDITOR: 'nav.table-editor',
NAV_SQL_EDITOR: 'nav.sql-editor',
NAV_DATABASE: 'nav.database',
NAV_AUTH: 'nav.auth',
NAV_STORAGE: 'nav.storage',
NAV_FUNCTIONS: 'nav.functions',
NAV_REALTIME: 'nav.realtime',
NAV_ADVISORS: 'nav.advisors',
NAV_OBSERVABILITY: 'nav.observability',
NAV_LOGS: 'nav.logs',
NAV_INTEGRATIONS: 'nav.integrations',
NAV_SETTINGS: 'nav.settings',
NAV_ORG_PROJECTS: 'nav.org-projects',
NAV_ORG_TEAM: 'nav.org-team',
NAV_ORG_INTEGRATIONS: 'nav.org-integrations',
NAV_ORG_USAGE: 'nav.org-usage',
NAV_ORG_BILLING: 'nav.org-billing',
NAV_ORG_SETTINGS: 'nav.org-settings',
} as const
/**
@@ -181,4 +200,118 @@ export const SHORTCUT_DEFINITIONS: Record<ShortcutId, ShortcutDefinition> = {
sequence: ['Mod+.'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_HOME]: {
id: SHORTCUT_IDS.NAV_HOME,
label: 'Go to Project Overview',
sequence: ['G', 'H'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_TABLE_EDITOR]: {
id: SHORTCUT_IDS.NAV_TABLE_EDITOR,
label: 'Go to Table Editor',
sequence: ['G', 'T'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_SQL_EDITOR]: {
id: SHORTCUT_IDS.NAV_SQL_EDITOR,
label: 'Go to SQL Editor',
sequence: ['G', 'S'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_DATABASE]: {
id: SHORTCUT_IDS.NAV_DATABASE,
label: 'Go to Database',
sequence: ['G', 'D'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_AUTH]: {
id: SHORTCUT_IDS.NAV_AUTH,
label: 'Go to Authentication',
sequence: ['G', 'A'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_STORAGE]: {
id: SHORTCUT_IDS.NAV_STORAGE,
label: 'Go to Storage',
sequence: ['G', 'B'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_FUNCTIONS]: {
id: SHORTCUT_IDS.NAV_FUNCTIONS,
label: 'Go to Edge Functions',
sequence: ['G', 'F'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_REALTIME]: {
id: SHORTCUT_IDS.NAV_REALTIME,
label: 'Go to Realtime',
sequence: ['G', 'R'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_ADVISORS]: {
id: SHORTCUT_IDS.NAV_ADVISORS,
label: 'Go to Advisors',
sequence: ['G', 'V'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_OBSERVABILITY]: {
id: SHORTCUT_IDS.NAV_OBSERVABILITY,
label: 'Go to Observability',
sequence: ['G', 'U'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_LOGS]: {
id: SHORTCUT_IDS.NAV_LOGS,
label: 'Go to Logs',
sequence: ['G', 'L'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_INTEGRATIONS]: {
id: SHORTCUT_IDS.NAV_INTEGRATIONS,
label: 'Go to Integrations',
sequence: ['G', 'I'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_SETTINGS]: {
id: SHORTCUT_IDS.NAV_SETTINGS,
label: 'Go to Project Settings',
sequence: ['G', ','],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_ORG_PROJECTS]: {
id: SHORTCUT_IDS.NAV_ORG_PROJECTS,
label: 'Go to Projects',
sequence: ['G', 'P'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_ORG_TEAM]: {
id: SHORTCUT_IDS.NAV_ORG_TEAM,
label: 'Go to Team',
sequence: ['G', 'M'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_ORG_INTEGRATIONS]: {
id: SHORTCUT_IDS.NAV_ORG_INTEGRATIONS,
label: 'Go to Organization Integrations',
sequence: ['G', 'I'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_ORG_USAGE]: {
id: SHORTCUT_IDS.NAV_ORG_USAGE,
label: 'Go to Usage',
sequence: ['G', 'U'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_ORG_BILLING]: {
id: SHORTCUT_IDS.NAV_ORG_BILLING,
label: 'Go to Billing',
sequence: ['G', 'B'],
showInSettings: false,
},
[SHORTCUT_IDS.NAV_ORG_SETTINGS]: {
id: SHORTCUT_IDS.NAV_ORG_SETTINGS,
label: 'Go to Organization Settings',
sequence: ['G', 'O'],
showInSettings: false,
},
}
@@ -40,4 +40,6 @@ const TooltipContent = React.forwardRef<
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export type TooltipContentProps = React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, TooltipPortal }