diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index b97f2fd143c..0219209f0c1 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -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) { + 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 ? ( + + {content} + + ) : ( + {content} + ) + return ( - {route.link && !route.disabled ? ( - - {content} - + {hasShortcut ? ( + router.push(route.link!)} + side="right" + delayDuration={shortcutPopoverDelay} + > + {button} + ) : ( - {content} + button )} ) @@ -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: , link: `/project/${ref}`, linkElement: , + shortcutId: SHORTCUT_IDS.NAV_HOME, }} /> {toolRoutes.map((route, i) => ( @@ -291,7 +312,7 @@ const ProjectLinks = () => { - {otherRoutes.map((route, i) => { + {otherRoutes.map((route) => { if (route.key === 'advisors') { return (
@@ -354,24 +375,28 @@ const OrganizationLinks = () => { href: `/org/${organizationSlug}`, key: 'projects', icon: , + shortcutId: SHORTCUT_IDS.NAV_ORG_PROJECTS, }, { label: 'Team', href: `/org/${organizationSlug}/team`, key: 'team', icon: , + shortcutId: SHORTCUT_IDS.NAV_ORG_TEAM, }, { label: 'Integrations', href: `/org/${organizationSlug}/integrations`, key: 'integrations', icon: , + shortcutId: SHORTCUT_IDS.NAV_ORG_INTEGRATIONS, }, { label: 'Usage', href: `/org/${organizationSlug}/usage`, key: 'usage', icon: , + shortcutId: SHORTCUT_IDS.NAV_ORG_USAGE, }, ...(showBilling ? [ @@ -380,6 +405,7 @@ const OrganizationLinks = () => { href: `/org/${organizationSlug}/billing`, key: 'billing', icon: , + shortcutId: SHORTCUT_IDS.NAV_ORG_BILLING, }, ] : []), @@ -388,6 +414,7 @@ const OrganizationLinks = () => { href: `/org/${organizationSlug}/general`, key: 'settings', icon: , + shortcutId: SHORTCUT_IDS.NAV_ORG_SETTINGS, }, ] @@ -412,6 +439,7 @@ const OrganizationLinks = () => { key: item.label, icon: item.icon, disabled: disableAccessMfa, + shortcutId: item.shortcutId, }} /> ))} diff --git a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx index 5925d256e55..63725b7570d 100644 --- a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx @@ -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: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/editor`), linkElement: , + shortcutId: SHORTCUT_IDS.NAV_TABLE_EDITOR, }, { key: 'sql', @@ -59,6 +61,7 @@ export const generateToolRoutes = (ref?: string, project?: Project): Route[] => disabled: !isProjectActive, icon: , 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: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/storage/files`), + shortcutId: SHORTCUT_IDS.NAV_STORAGE, }, ] : []), @@ -126,6 +132,7 @@ export const generateProductRoutes = ( disabled: false, icon: , link: ref && `/project/${ref}/functions`, + shortcutId: SHORTCUT_IDS.NAV_FUNCTIONS, }, ] : []), @@ -137,6 +144,7 @@ export const generateProductRoutes = ( disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/realtime/inspector`), + shortcutId: SHORTCUT_IDS.NAV_REALTIME, }, ] : []), @@ -160,6 +168,7 @@ export const generateOtherRoutes = ( disabled: !isProjectActive, icon: , 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: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/observability`), + shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY, }, ] : []), @@ -179,6 +189,7 @@ export const generateOtherRoutes = ( disabled: false, icon: , 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: , 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, }, ] } diff --git a/apps/studio/components/ui/Shortcut.tsx b/apps/studio/components/ui/Shortcut.tsx new file mode 100644 index 00000000000..62d4ce2853e --- /dev/null +++ b/apps/studio/components/ui/Shortcut.tsx @@ -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 + * + * + * + * + * @example + * // Gate the hotkey on local state; tooltip still renders: + * + * + * + */ +export const Shortcut = ({ + id, + onTrigger, + children, + options, + side, + align, + sideOffset, + delayDuration, + label, +}: ShortcutProps) => { + useShortcut(id, onTrigger, options) + + return ( + + {children} + + ) +} diff --git a/apps/studio/components/ui/ShortcutTooltip.tsx b/apps/studio/components/ui/ShortcutTooltip.tsx new file mode 100644 index 00000000000..13bd66072d6 --- /dev/null +++ b/apps/studio/components/ui/ShortcutTooltip.tsx @@ -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: `"