From e8df67d5d5291e05ea56596aee0f2b7fa152929b Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 17 Apr 2026 10:02:56 -0600 Subject: [PATCH] chore: migrate shortcuts to new hooks API (#44955) ## 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? Cleanup shortcuts with new hooks ## Summary by CodeRabbit * **Refactor** * Centralized keyboard shortcut system for consistent shortcut behavior across the app and moved preference toggles to a unified registry. * **New Features** * Added explicit shortcuts for Command Menu, AI Assistant, Inline Editor, and result copy/download actions. * Hotkey preferences UI now renders dynamically from the centralized shortcut list. * **Tests** * Test helpers updated to include the command menu provider for accurate shortcut behavior in tests. --- .../Account/Preferences/HotkeySettings.tsx | 120 ++---------------- .../Account/Preferences/HotkeyToggle.tsx | 68 +++++----- .../App/CommandMenu/StudioCommandProvider.tsx | 9 +- .../interfaces/SQLEditor/MonacoEditor.tsx | 12 +- .../layouts/AppLayout/AssistantButton.tsx | 10 +- .../layouts/AppLayout/InlineEditorButton.tsx | 9 +- .../Navigation/LayoutHeader/LayoutHeader.tsx | 7 +- .../LayoutSidebar/LayoutSidebarProvider.tsx | 22 +++- .../components/ui/DownloadResultsButton.tsx | 62 +++------ .../components/ui/EditorPanel/EditorPanel.tsx | 15 +-- apps/studio/state/shortcuts/formatShortcut.ts | 8 ++ apps/studio/state/shortcuts/registry.ts | 36 ++++++ apps/studio/state/sidebar-manager-state.tsx | 25 ---- apps/studio/tests/helpers.tsx | 5 +- apps/studio/tests/lib/custom-render.tsx | 5 +- packages/common/constants/local-storage.ts | 10 -- 16 files changed, 154 insertions(+), 269 deletions(-) create mode 100644 apps/studio/state/shortcuts/formatShortcut.ts diff --git a/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx b/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx index 3e2e69524cc..9eedb7b4809 100644 --- a/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx @@ -1,7 +1,4 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import { LOCAL_STORAGE_KEYS } from 'common' -import { useForm } from 'react-hook-form' -import { Card, Form_Shadcn_ } from 'ui' +import { Card } from 'ui' import { PageSection, PageSectionContent, @@ -10,65 +7,13 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' -import * as z from 'zod' import { HotkeyToggle } from './HotkeyToggle' -import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { SHORTCUT_DEFINITIONS } from '@/state/shortcuts/registry' -const HotkeySchema = z.object({ - commandMenuEnabled: z.boolean(), - aiAssistantEnabled: z.boolean(), - inlineEditorEnabled: z.boolean(), - copyMarkdownEnabled: z.boolean(), - copyJsonEnabled: z.boolean(), - copyCsvEnabled: z.boolean(), - downloadCsvEnabled: z.boolean(), -}) +const SHORTCUT_ORDER = Object.values(SHORTCUT_DEFINITIONS) export const HotkeySettings = () => { - const [inlineEditorEnabled, setInlineEditorEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.EDITOR_PANEL), - true - ) - const [commandMenuEnabled, setCommandMenuEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_COMMAND_MENU, - true - ) - const [aiAssistantEnabled, setAiAssistantEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT), - true - ) - const [copyMarkdownEnabled, setCopyMarkdownEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_COPY_MARKDOWN, - true - ) - const [copyJsonEnabled, setCopyJsonEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_COPY_JSON, - true - ) - const [copyCsvEnabled, setCopyCsvEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_COPY_CSV, - true - ) - const [downloadCsvEnabled, setDownloadCsvEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_DOWNLOAD_CSV, - true - ) - - const form = useForm>({ - resolver: zodResolver(HotkeySchema), - values: { - commandMenuEnabled: commandMenuEnabled ?? true, - aiAssistantEnabled: aiAssistantEnabled ?? true, - inlineEditorEnabled: inlineEditorEnabled ?? true, - copyMarkdownEnabled: copyMarkdownEnabled ?? true, - copyJsonEnabled: copyJsonEnabled ?? true, - copyCsvEnabled: copyCsvEnabled ?? true, - downloadCsvEnabled: downloadCsvEnabled ?? true, - }, - }) - return ( @@ -80,60 +25,15 @@ export const HotkeySettings = () => { - - + + {SHORTCUT_ORDER.map((definition, index) => ( - - - - - - - - + ))} + ) diff --git a/apps/studio/components/interfaces/Account/Preferences/HotkeyToggle.tsx b/apps/studio/components/interfaces/Account/Preferences/HotkeyToggle.tsx index a5a7766c748..0a46e6315f5 100644 --- a/apps/studio/components/interfaces/Account/Preferences/HotkeyToggle.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/HotkeyToggle.tsx @@ -1,46 +1,40 @@ -import type { FieldValues, Path, UseFormReturn } from 'react-hook-form' -import { CardContent, FormControl_Shadcn_, FormField_Shadcn_, KeyboardShortcut, Switch } from 'ui' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { Fragment } from 'react' +import { CardContent, KeyboardShortcut, Switch } from 'ui' -interface HotkeyToggleProps { - form: UseFormReturn - name: Path - keys: string[] - label: string - onToggle: (value: boolean) => void +import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' +import type { ShortcutId } from '@/state/shortcuts/registry' +import { useShortcutPreferences } from '@/state/shortcuts/state' +import type { ShortcutDefinition } from '@/state/shortcuts/types' +import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' + +interface HotkeyToggleProps { + definition: ShortcutDefinition isLast?: boolean } -export function HotkeyToggle({ - form, - name, - keys, - label, - onToggle, - isLast, -}: HotkeyToggleProps) { +export function HotkeyToggle({ definition, isLast }: HotkeyToggleProps) { + const enabled = useIsShortcutEnabled(definition.id as ShortcutId) + const { setShortcutEnabled } = useShortcutPreferences() + return ( - ( - -
- - - { - field.onChange(value) - onToggle(value) - }} - /> - -
-
- )} - /> +
+ +
+
+ {definition.sequence.map((step, i) => ( + + {i > 0 && then} + + + ))} +
+ setShortcutEnabled(definition.id as ShortcutId, checked)} + /> +
+
) } diff --git a/apps/studio/components/interfaces/App/CommandMenu/StudioCommandProvider.tsx b/apps/studio/components/interfaces/App/CommandMenu/StudioCommandProvider.tsx index 89c978fd6f0..c794e23b3f3 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/StudioCommandProvider.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/StudioCommandProvider.tsx @@ -1,16 +1,13 @@ -import { LOCAL_STORAGE_KEYS } from 'common' import type { PropsWithChildren } from 'react' import { CommandProvider } from 'ui-patterns/CommandMenu' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useStudioCommandMenuTelemetry } from '@/hooks/misc/useStudioCommandMenuTelemetry' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' export function StudioCommandProvider({ children }: PropsWithChildren) { const { onTelemetry } = useStudioCommandMenuTelemetry() - const [commandMenuHotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_COMMAND_MENU, - true - ) + const commandMenuHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN) return ( ( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT), - true - ) + const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE) // [Joshen] Lodash debounce doesn't seem to be working here, so opting to use useDebounce const [value, setValue] = useState('') @@ -80,6 +79,9 @@ const MonacoEditor = ({ const executeQueryRef = useRef(executeQuery) executeQueryRef.current = executeQuery + const aiHotkeyEnabledRef = useRef(isAIAssistantHotkeyEnabled) + aiHotkeyEnabledRef.current = isAIAssistantHotkeyEnabled + const handleEditorOnMount: OnMount = async (editor, monaco) => { editorRef.current = editor monacoRef.current = monaco @@ -134,7 +136,7 @@ const MonacoEditor = ({ label: 'Toggle AI Assistant', keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI], run: () => { - if (isAIAssistantHotkeyEnabled) { + if (aiHotkeyEnabledRef.current) { toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) } }, diff --git a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx index 56c7bb5f454..bb70352b8df 100644 --- a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx @@ -1,18 +1,14 @@ -import { LOCAL_STORAGE_KEYS } from 'common' import { AiIconAnimation, cn, KeyboardShortcut } from 'ui' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' export const AssistantButton = () => { const { activeSidebar, toggleSidebar } = useSidebarManagerSnapshot() - const [isAIAssistantHotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT), - true - ) + const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE) const isOpen = activeSidebar?.id === SIDEBAR_KEYS.AI_ASSISTANT diff --git a/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx b/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx index 302667d33ab..ab02ed41a82 100644 --- a/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx +++ b/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx @@ -1,17 +1,14 @@ -import { LOCAL_STORAGE_KEYS } from 'common' import { SqlEditor } from 'icons' import { cn, KeyboardShortcut } from 'ui' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' const InlineEditorKeyboardTooltip = () => { - const [hotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.EDITOR_PANEL), - true - ) + const hotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.INLINE_EDITOR_TOGGLE) return hotkeyEnabled ? : null } diff --git a/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx index b79a51dc1f2..f5f4501297d 100644 --- a/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useParams } from 'common' import dayjs from 'dayjs' import { AnimatePresence, motion } from 'framer-motion' import { ChevronLeft } from 'lucide-react' @@ -28,10 +28,11 @@ import { ProjectDropdown } from '@/components/layouts/AppLayout/ProjectDropdown' import { HelpButton } from '@/components/ui/HelpPanel/HelpButton' import { getResourcesExceededLimitsOrg } from '@/components/ui/OveragesBanner/OveragesBanner.utils' import { useOrgUsageQuery } from '@/data/usage/org-usage-query' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM } from '@/lib/constants' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' const LayoutHeaderDivider = ({ className, ...props }: React.HTMLProps) => ( @@ -71,7 +72,7 @@ export const LayoutHeader = ({ const { data: selectedOrganization } = useSelectedOrganizationQuery() const showFloatingMobileToolbar = useIsFloatingMobileToolbarEnabled() - const [commandMenuEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COMMAND_MENU, true) + const commandMenuEnabled = useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN) const isAccountPage = router.pathname.startsWith('/account') diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx index c454079e01e..690c1bbf121 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx @@ -10,7 +10,13 @@ import useLatest from '@/hooks/misc/useLatest' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { useRegisterSidebar, useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useShortcut } from '@/state/shortcuts/useShortcut' +import { + sidebarManagerState, + useRegisterSidebar, + useSidebarManagerSnapshot, +} from '@/state/sidebar-manager-state' const AdvisorPanel = dynamic(() => import('@/components/ui/AdvisorPanel/AdvisorPanel').then((m) => m.AdvisorPanel) @@ -48,9 +54,9 @@ export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => { const sidebarURLParamRef = useLatest(sidebarURLParam) const sidebarLocalStorageRef = useLatest(sidebarLocalStorage) - useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, 'i', !!project) - useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, 'e', !!project) - useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => , {}, undefined, true) + useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, !!project) + useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, !!project) + useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => , {}, true) useRegisterSidebar( SIDEBAR_KEYS.HELP_PANEL, () => ( @@ -65,10 +71,16 @@ export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => { /> ), {}, - undefined, true ) + useShortcut(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE, () => + sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + ) + useShortcut(SHORTCUT_IDS.INLINE_EDITOR_TOGGLE, () => + sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.EDITOR_PANEL) + ) + useEffect(() => { if (!!project) { if (activeSidebar) { diff --git a/apps/studio/components/ui/DownloadResultsButton.tsx b/apps/studio/components/ui/DownloadResultsButton.tsx index 72ce02cdd49..3277e12f04c 100644 --- a/apps/studio/components/ui/DownloadResultsButton.tsx +++ b/apps/studio/components/ui/DownloadResultsButton.tsx @@ -1,4 +1,4 @@ -import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' +import { IS_PLATFORM, useParams } from 'common' import saveAs from 'file-saver' import { ChevronDown, Copy, Download, Settings } from 'lucide-react' import Link from 'next/link' @@ -20,8 +20,8 @@ import { convertResultsToJSON, convertResultsToMarkdown, } from '@/components/interfaces/SQLEditor/UtilityPanel/Results.utils' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -import { useHotKey } from '@/hooks/ui/useHotKey' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useShortcut } from '@/state/shortcuts/useShortcut' interface DownloadResultsButtonProps { iconOnly?: boolean @@ -51,11 +51,6 @@ export const DownloadResultsButton = ({ const { ref } = useParams() const pathname = usePathname() const isLogs = pathname?.includes?.('/logs') ?? false - const [copyMarkdownEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COPY_MARKDOWN, true) - const [copyJsonEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COPY_JSON, true) - const [copyCsvEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COPY_CSV, true) - const [downloadCsvEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_DOWNLOAD_CSV, true) - const isEmpty = useMemo(() => results.length === 0, [results]) const downloadAsCSV = () => { @@ -107,41 +102,22 @@ export const DownloadResultsButton = ({ }) } - useHotKey( - (e) => { - e.preventDefault() - copyAsMarkdown() - }, - 'm', - { enabled: copyMarkdownEnabled ?? isEmpty, shift: true } - ) - - useHotKey( - (e) => { - e.preventDefault() - copyAsJSON() - }, - 'j', - { enabled: copyJsonEnabled ?? isEmpty, shift: true } - ) - - useHotKey( - (e) => { - e.preventDefault() - copyAsCSV() - }, - 'c', - { enabled: copyCsvEnabled ?? isEmpty, shift: true } - ) - - useHotKey( - (e) => { - e.preventDefault() - downloadAsCSV() - }, - 'd', - { enabled: downloadCsvEnabled ?? isEmpty, shift: true } - ) + useShortcut(SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, copyAsMarkdown, { + enabled: !isEmpty, + registerInCommandMenu: true, + }) + useShortcut(SHORTCUT_IDS.RESULTS_COPY_JSON, copyAsJSON, { + enabled: !isEmpty, + registerInCommandMenu: true, + }) + useShortcut(SHORTCUT_IDS.RESULTS_COPY_CSV, copyAsCSV, { + enabled: !isEmpty, + registerInCommandMenu: true, + }) + useShortcut(SHORTCUT_IDS.RESULTS_DOWNLOAD_CSV, downloadAsCSV, { + enabled: !isEmpty, + registerInCommandMenu: true, + }) return ( diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index 21f1c6d1473..90d2a9c7bb2 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -1,7 +1,7 @@ import type { Monaco } from '@monaco-editor/react' import { useQueryClient } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useParams } from 'common' import { AlertCircle, Book, @@ -57,12 +57,13 @@ import { useContentQuery, type Content } from '@/data/content/content-query' import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation' import { contentKeys } from '@/data/content/keys' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { BASE_PATH } from '@/lib/constants' import { useProfile } from '@/lib/profile' import { editorPanelState, useEditorPanelStateSnapshot } from '@/state/editor-panel-state' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' @@ -107,14 +108,8 @@ export const EditorPanel = () => { setActiveSnippet({ ...activeSnippet, name: newName }) setIsEditingTitle(false) } - const [isInlineEditorHotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.EDITOR_PANEL), - true - ) - const [isAIAssistantHotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT), - true - ) + const isInlineEditorHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.INLINE_EDITOR_TOGGLE) + const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE) const currentValue = value || '' 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..542c01c5117 100644 --- a/apps/studio/state/shortcuts/registry.ts +++ b/apps/studio/state/shortcuts/registry.ts @@ -8,7 +8,13 @@ import { ShortcutDefinition } from './types' * The `` groups related shortcuts (sql-editor, table-editor, results, etc). */ export const SHORTCUT_IDS = { + COMMAND_MENU_OPEN: 'command-menu.open', + AI_ASSISTANT_TOGGLE: 'ai-assistant.toggle', + INLINE_EDITOR_TOGGLE: 'inline-editor.toggle', RESULTS_COPY_MARKDOWN: 'results.copy-markdown', + RESULTS_COPY_JSON: 'results.copy-json', + RESULTS_COPY_CSV: 'results.copy-csv', + RESULTS_DOWNLOAD_CSV: 'results.download-csv', } as const /** @@ -38,9 +44,39 @@ export type ShortcutId = (typeof SHORTCUT_IDS)[keyof typeof SHORTCUT_IDS] * // useShortcut(SHORTCUT_IDS.SQL_EDITOR_RUN, runQuery) */ export const SHORTCUT_DEFINITIONS: Record = { + [SHORTCUT_IDS.COMMAND_MENU_OPEN]: { + id: SHORTCUT_IDS.COMMAND_MENU_OPEN, + label: 'Open command menu', + sequence: ['Mod+K'], + }, + [SHORTCUT_IDS.AI_ASSISTANT_TOGGLE]: { + id: SHORTCUT_IDS.AI_ASSISTANT_TOGGLE, + label: 'Toggle AI Assistant panel', + sequence: ['Mod+I'], + }, + [SHORTCUT_IDS.INLINE_EDITOR_TOGGLE]: { + id: SHORTCUT_IDS.INLINE_EDITOR_TOGGLE, + label: 'Toggle inline SQL editor', + sequence: ['Mod+E'], + }, [SHORTCUT_IDS.RESULTS_COPY_MARKDOWN]: { id: SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, label: 'Copy results as Markdown', sequence: ['Mod+Shift+M'], }, + [SHORTCUT_IDS.RESULTS_COPY_JSON]: { + id: SHORTCUT_IDS.RESULTS_COPY_JSON, + label: 'Copy results as JSON', + sequence: ['Mod+Shift+J'], + }, + [SHORTCUT_IDS.RESULTS_COPY_CSV]: { + id: SHORTCUT_IDS.RESULTS_COPY_CSV, + label: 'Copy results as CSV', + sequence: ['Mod+Shift+C'], + }, + [SHORTCUT_IDS.RESULTS_DOWNLOAD_CSV]: { + id: SHORTCUT_IDS.RESULTS_DOWNLOAD_CSV, + label: 'Download results as CSV', + sequence: ['Mod+Shift+D'], + }, } diff --git a/apps/studio/state/sidebar-manager-state.tsx b/apps/studio/state/sidebar-manager-state.tsx index cbcc78e593c..c5ced2c442a 100644 --- a/apps/studio/state/sidebar-manager-state.tsx +++ b/apps/studio/state/sidebar-manager-state.tsx @@ -1,9 +1,7 @@ -import { LOCAL_STORAGE_KEYS } from 'common/constants' import { ReactNode, useEffect } from 'react' import { proxy, snapshot, useSnapshot } from 'valtio' import useLatest from '@/hooks/misc/useLatest' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' type SidebarHandlers = { onOpen?: () => void @@ -167,14 +165,8 @@ export const useRegisterSidebar = ( id: string, component: () => ReactNode, handlers: SidebarHandlers = {}, - hotKey?: string, enabled?: boolean ) => { - const [isSidebarHotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(id), - true - ) - const componentRef = useLatest(component) const handlersRef = useLatest(handlers) @@ -187,21 +179,4 @@ export const useRegisterSidebar = ( sidebarManagerState.unregisterSidebar(id) } }, [id, enabled]) - - useEffect(() => { - if (!hotKey) return - - function hotKeyHandler(e: KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.key === hotKey && !e.altKey && !e.shiftKey) { - sidebarManagerState.toggleSidebar(id) - } - } - - if (isSidebarHotkeyEnabled) { - window.addEventListener('keydown', hotKeyHandler) - return () => { - window.removeEventListener('keydown', hotKeyHandler) - } - } - }, [id, hotKey, isSidebarHotkeyEnabled]) } diff --git a/apps/studio/tests/helpers.tsx b/apps/studio/tests/helpers.tsx index 9a071749a94..6f32e9ab9f1 100644 --- a/apps/studio/tests/helpers.tsx +++ b/apps/studio/tests/helpers.tsx @@ -3,6 +3,7 @@ import { fireEvent, getByText, render as originalRender, screen } from '@testing import type React from 'react' import { useState } from 'react' import { TooltipProvider } from 'ui' +import { CommandProvider } from 'ui-patterns/CommandMenu' import { ProjectInfoInfinite } from '@/data/projects/projects-infinite-query' import type { Organization } from '@/types' @@ -106,7 +107,9 @@ const ReactQueryTestConfig: React.FC = ({ children }) = return ( - {children} + + {children} + ) } diff --git a/apps/studio/tests/lib/custom-render.tsx b/apps/studio/tests/lib/custom-render.tsx index e8dd5f50841..2a85d2e1541 100644 --- a/apps/studio/tests/lib/custom-render.tsx +++ b/apps/studio/tests/lib/custom-render.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, renderHook, type RenderOptions } from '@testing-library/react' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { TooltipProvider } from 'ui' +import { CommandProvider } from 'ui-patterns/CommandMenu' // End of third-party imports @@ -33,7 +34,9 @@ const CustomWrapper = ({ const content = ( - {children} + + {children} + ) diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 2f37b3dc6be..97ce0162cec 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -89,18 +89,8 @@ export const LOCAL_STORAGE_KEYS = { USER_IMPERSONATION_SELECTOR_PREVIOUS_SEARCHES: (ref: string) => `user-impersonation-selector-previous-searches-${ref}`, - HOTKEY_COMMAND_MENU: 'supabase-dashboard-hotkey-command-menu', - LAST_OPENED_SIDE_BAR: (ref: string) => `last-opened-sidebar-${ref}`, - // Project sidebar hotkeys - HOTKEY_SIDEBAR: (sidebarId: string) => `supabase-dashboard-hotkey-sidebar-${sidebarId}`, - - HOTKEY_COPY_MARKDOWN: 'supabase-dashboard-hotkey-copy-markdown', - HOTKEY_COPY_JSON: 'supabase-dashboard-hotkey-copy-json', - HOTKEY_COPY_CSV: 'supabase-dashboard-hotkey-copy-csv', - HOTKEY_DOWNLOAD_CSV: 'supabase-dashboard-hotkey-download-csv', - // Index Advisor notice dismissed INDEX_ADVISOR_NOTICE_DISMISSED: (ref: string) => `index-advisor-notice-dismissed-${ref}`,