diff --git a/apps/studio/components/layouts/DefaultLayout.tsx b/apps/studio/components/layouts/DefaultLayout.tsx index b761a4b98c1..9c7aecfa94d 100644 --- a/apps/studio/components/layouts/DefaultLayout.tsx +++ b/apps/studio/components/layouts/DefaultLayout.tsx @@ -14,6 +14,7 @@ import { LayoutSidebarProvider } from './ProjectLayout/LayoutSidebar/LayoutSideb import { ProjectContextProvider } from './ProjectLayout/ProjectContext' import { AppBannerWrapper } from '@/components/interfaces/App/AppBannerWrapper' import { Sidebar } from '@/components/interfaces/Sidebar' +import { GlobalShortcuts } from '@/components/ui/GlobalShortcuts/GlobalShortcuts' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useCheckLatestDeploy } from '@/hooks/use-check-latest-deploy' import { IS_PLATFORM } from '@/lib/constants' @@ -125,6 +126,7 @@ export const DefaultLayout = ({ + diff --git a/apps/studio/components/ui/GlobalShortcuts/GlobalShortcuts.tsx b/apps/studio/components/ui/GlobalShortcuts/GlobalShortcuts.tsx new file mode 100644 index 00000000000..4554cd12a13 --- /dev/null +++ b/apps/studio/components/ui/GlobalShortcuts/GlobalShortcuts.tsx @@ -0,0 +1,56 @@ +import { useParams } from 'common' +import { useRouter } from 'next/router' +import { useCallback, useState } from 'react' + +import { ShortcutsReferenceDialog } from './ShortcutsReferenceDialog' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useShortcut } from '@/state/shortcuts/useShortcut' + +/** + * Mounts the app-wide keyboard shortcuts that aren't tied to a specific + * component — navigation chords (G → T/E/S/D/A) and the shortcut reference + * dialog (press ?). Place this inside a layout that stays mounted across + * route changes (e.g. DefaultLayout). + */ +export function GlobalShortcuts() { + const router = useRouter() + const { ref } = useParams() + const [referenceOpen, setReferenceOpen] = useState(false) + + const goTo = useCallback( + (path: string) => () => { + if (!ref) return + router.push(`/project/${ref}${path}`) + }, + [ref, router] + ) + + const navEnabled = !!ref + + useShortcut(SHORTCUT_IDS.NAV_TABLE_EDITOR, goTo('/editor'), { + enabled: navEnabled, + registerInCommandMenu: true, + }) + useShortcut(SHORTCUT_IDS.NAV_SQL_EDITOR, goTo('/sql'), { + enabled: navEnabled, + registerInCommandMenu: true, + }) + useShortcut(SHORTCUT_IDS.NAV_STORAGE, goTo('/storage/files'), { + enabled: navEnabled, + registerInCommandMenu: true, + }) + useShortcut(SHORTCUT_IDS.NAV_DATABASE, goTo('/database/tables'), { + enabled: navEnabled, + registerInCommandMenu: true, + }) + useShortcut(SHORTCUT_IDS.NAV_AUTH, goTo('/auth/users'), { + enabled: navEnabled, + registerInCommandMenu: true, + }) + + useShortcut(SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE, () => setReferenceOpen(true), { + registerInCommandMenu: true, + }) + + return +} diff --git a/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceDialog.tsx b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceDialog.tsx new file mode 100644 index 00000000000..e599ed98910 --- /dev/null +++ b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceDialog.tsx @@ -0,0 +1,84 @@ +import { Fragment } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogSection, + DialogTitle, + KeyboardShortcut, +} from 'ui' + +import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' +import { SHORTCUT_DEFINITIONS } from '@/state/shortcuts/registry' +import type { ShortcutDefinition } from '@/state/shortcuts/types' + +interface ShortcutsReferenceDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +const GROUP_LABELS: Record = { + nav: 'Navigate', + results: 'Results', + shortcuts: 'Help', +} + +const groupDefinitions = (): Array<{ group: string; defs: ShortcutDefinition[] }> => { + const grouped = Object.values(SHORTCUT_DEFINITIONS).reduce>( + (acc, def) => { + const prefix = def.id.split('.')[0] + acc[prefix] = acc[prefix] ?? [] + acc[prefix].push(def) + return acc + }, + {} + ) + return Object.entries(grouped).map(([group, defs]) => ({ group, defs })) +} + +export function ShortcutsReferenceDialog({ open, onOpenChange }: ShortcutsReferenceDialogProps) { + const groups = groupDefinitions() + + return ( + + + + Keyboard shortcuts + + Every shortcut registered in the dashboard. Press ? anytime to reopen this. + + + + {groups.map(({ group, defs }) => ( +
+

+ {GROUP_LABELS[group] ?? group} +

+
    + {defs.map((def) => ( +
  • + {def.label} +
    + {def.sequence.map((step, i) => ( + + {i > 0 && ( + then + )} + + + ))} +
    +
  • + ))} +
+
+ ))} +
+
+
+ ) +} diff --git a/apps/studio/state/shortcuts/formatShortcut.ts b/apps/studio/state/shortcuts/formatShortcut.ts new file mode 100644 index 00000000000..1fa02073881 --- /dev/null +++ b/apps/studio/state/shortcuts/formatShortcut.ts @@ -0,0 +1,8 @@ +/** + * Convert a TanStack Hotkeys hotkey string (e.g. `"Mod+Shift+M"`) into the + * key-array format consumed by the `` component from `ui` + * (e.g. `["Meta", "Shift", "M"]`). `KeyboardShortcut` resolves `Meta` to ⌘ on + * macOS or `Ctrl` elsewhere, so this mapping is platform-safe. + */ +export const hotkeyToKeys = (hotkey: string): string[] => + hotkey.split('+').map((part) => (part === 'Mod' ? 'Meta' : part)) diff --git a/apps/studio/state/shortcuts/registry.ts b/apps/studio/state/shortcuts/registry.ts index be08a32d51c..a3d327e8a5e 100644 --- a/apps/studio/state/shortcuts/registry.ts +++ b/apps/studio/state/shortcuts/registry.ts @@ -9,6 +9,14 @@ import { ShortcutDefinition } from './types' */ export const SHORTCUT_IDS = { RESULTS_COPY_MARKDOWN: 'results.copy-markdown', + + NAV_TABLE_EDITOR: 'nav.table-editor', + NAV_SQL_EDITOR: 'nav.sql-editor', + NAV_STORAGE: 'nav.storage', + NAV_DATABASE: 'nav.database', + NAV_AUTH: 'nav.auth', + + SHORTCUTS_OPEN_REFERENCE: 'shortcuts.open-reference', } as const /** @@ -23,19 +31,6 @@ export type ShortcutId = (typeof SHORTCUT_IDS)[keyof typeof SHORTCUT_IDS] * 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 = { [SHORTCUT_IDS.RESULTS_COPY_MARKDOWN]: { @@ -43,4 +38,36 @@ export const SHORTCUT_DEFINITIONS: Record = { label: 'Copy results as Markdown', sequence: ['Mod+Shift+M'], }, + + [SHORTCUT_IDS.NAV_TABLE_EDITOR]: { + id: SHORTCUT_IDS.NAV_TABLE_EDITOR, + label: 'Go to Table Editor', + sequence: ['G', 'T'], + }, + [SHORTCUT_IDS.NAV_SQL_EDITOR]: { + id: SHORTCUT_IDS.NAV_SQL_EDITOR, + label: 'Go to SQL Editor', + sequence: ['G', 'E'], + }, + [SHORTCUT_IDS.NAV_STORAGE]: { + id: SHORTCUT_IDS.NAV_STORAGE, + label: 'Go to Storage', + sequence: ['G', 'S'], + }, + [SHORTCUT_IDS.NAV_DATABASE]: { + id: SHORTCUT_IDS.NAV_DATABASE, + label: 'Go to Database', + sequence: ['G', 'D'], + }, + [SHORTCUT_IDS.NAV_AUTH]: { + id: SHORTCUT_IDS.NAV_AUTH, + label: 'Go to Authentication', + sequence: ['G', 'A'], + }, + + [SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE]: { + id: SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE, + label: 'Show all keyboard shortcuts', + sequence: ['Shift+A'], + }, }