diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/QuickActions.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/QuickActions.tsx
index a37d4996b03..eff4d2849bf 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/QuickActions.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/QuickActions.tsx
@@ -1,5 +1,5 @@
import { Kbd } from 'components/ui/DataTable/primitives/Kbd'
-import { GripVertical, Pencil, PlusIcon, Save, Star, X } from 'lucide-react'
+import { GripVertical, Pencil, PlusIcon, Star, X } from 'lucide-react'
import {
Button,
cn,
@@ -23,6 +23,7 @@ import {
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
+import { useEffect, useState } from 'react'
const KbdSlot = ({ option }: { option: QuickActionOption }) => (
@@ -128,7 +129,7 @@ const ReorderableList = ({
{/* Selected Actions */}
- Favourite Actions
+ Your Quick Actions
action.id)}
@@ -171,6 +172,7 @@ const ReorderableList = ({
}
const QuickActions = () => {
+ const [open, setOpen] = useState(false)
const {
allActions,
selectedActions,
@@ -178,18 +180,16 @@ const QuickActions = () => {
editQuickActions,
setEditQuickActions,
saveSelectedActions,
+ resetSelectedActions,
+ keyboardShortcuts,
} = useQuickActionOptions()
- const handleReorder = (newOrder: QuickActionOption[]) => {
- setSelectedActions(newOrder)
- }
+ const handleReorder = (newOrder: QuickActionOption[]) => setSelectedActions(newOrder)
const handleToggleAction = (action: QuickActionOption, isSelected: boolean) => {
if (isSelected) {
- // Add action to the end of selected actions
setSelectedActions([...selectedActions, action])
} else {
- // Remove action from selected actions
setSelectedActions(selectedActions.filter((a) => a.id !== action.id))
}
}
@@ -199,8 +199,27 @@ const QuickActions = () => {
setEditQuickActions(false)
}
+ const reset = () => {
+ setEditQuickActions(false)
+ resetSelectedActions()
+ keyboardShortcuts.reset()
+ }
+
+ useEffect(() => {
+ if (!open) {
+ reset()
+ }
+ }, [open])
+
+ // Clear keyboard shortcuts when editing
+ useEffect(() => {
+ if (editQuickActions) {
+ keyboardShortcuts.reset()
+ }
+ }, [editQuickActions, keyboardShortcuts])
+
return (
-
+
@@ -208,13 +227,13 @@ const QuickActions = () => {
- Create
+ {!open && Create}
{editQuickActions ? (
{
/>
) : (
-
Create...
+
+ Create...
+
+
+
+
+ Manage quick actions
+
+
{selectedActions.map((option) => (
)}
-
- {
- e.preventDefault()
- if (editQuickActions) {
- handleSave()
- } else {
- setEditQuickActions(true)
- }
- }}
- className="text-center cursor-pointer flex justify-center items-center gap-2 w-full text-foreground-light hover:text-foreground"
- >
- {editQuickActions ? (
- <>
- Save
- >
- ) : (
- <>
- Manage
- >
- )}
-
+ {editQuickActions && (
+
+
+
+
+ )}
)
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/quick-actions.utils.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/quick-actions.utils.tsx
index d8c694ba6df..b42f5f393e8 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/quick-actions.utils.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/quick-actions.utils.tsx
@@ -1,9 +1,11 @@
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import {
DatabaseBackup,
DatabaseZap,
+ FunctionSquare,
GitBranch,
Lock,
+ Network,
TextSearch,
Type,
User2,
@@ -14,7 +16,7 @@ import {
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useParams } from 'common'
import { useRouter } from 'next/router'
-import { Database, EdgeFunctions, Reports, Storage, TableEditor } from 'icons'
+import { EdgeFunctions, Reports, Storage, TableEditor } from 'icons'
export interface QuickActionOption {
id: string
@@ -26,6 +28,188 @@ export interface QuickActionOption {
badge?: boolean
}
+// Custom hook for keyboard shortcut detection
+export const useKeyboardShortcuts = (actions: QuickActionOption[], enabled: boolean = true) => {
+ const [pressedKeys, setPressedKeys] = useState>(new Set())
+ const [sequence, setSequence] = useState([])
+ const timeoutRef = useRef(null)
+
+ const normalizeKey = (key: string): string => {
+ // Normalize key names for consistent matching
+ const keyMap: Record = {
+ Control: 'ctrl',
+ Meta: 'cmd',
+ Alt: 'alt',
+ Shift: 'shift',
+ ' ': 'space',
+ Enter: 'enter',
+ Escape: 'escape',
+ Backspace: 'backspace',
+ Tab: 'tab',
+ ArrowUp: 'up',
+ ArrowDown: 'down',
+ ArrowLeft: 'left',
+ ArrowRight: 'right',
+ }
+ return keyMap[key] || key.toLowerCase()
+ }
+
+ const isFormElement = (element: Element): boolean => {
+ const formElements = ['input', 'textarea', 'select', 'button']
+ const isFormElement = formElements.includes(element.tagName.toLowerCase())
+ const isContentEditable = element.getAttribute('contenteditable') === 'true'
+ const isCodeEditor =
+ element.closest('[data-lexical-editor]') ||
+ element.closest('.monaco-editor') ||
+ element.closest('[data-testid="sql-editor"]') ||
+ element.closest('.cm-editor')
+
+ return isFormElement || isContentEditable || !!isCodeEditor
+ }
+
+ const checkSimultaneousMatch = useCallback(
+ (keys: Set) => {
+ return actions.find((action) => {
+ if (!action.kbd || action.kbd.length === 0) return false
+
+ const actionKeys = new Set(action.kbd.map((k) => normalizeKey(k)))
+ const pressedKeysSet = new Set(Array.from(keys).map((k) => normalizeKey(k)))
+
+ // Check if all action keys are pressed
+ return (
+ actionKeys.size === pressedKeysSet.size &&
+ Array.from(actionKeys).every((key) => pressedKeysSet.has(key))
+ )
+ })
+ },
+ [actions]
+ )
+
+ const checkSequentialMatch = useCallback(
+ (seq: string[]) => {
+ return actions.find((action) => {
+ if (!action.kbd || action.kbd.length === 0) return false
+
+ const actionKeys = action.kbd.map((k) => normalizeKey(k))
+ const normalizedSeq = seq.map((k) => normalizeKey(k))
+
+ // Check if sequence matches action keys
+ return (
+ actionKeys.length === normalizedSeq.length &&
+ actionKeys.every((key, index) => key === normalizedSeq[index])
+ )
+ })
+ },
+ [actions]
+ )
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ // Don't listen if disabled or if user is typing in a form field
+ if (!enabled || isFormElement(event.target as Element)) {
+ return
+ }
+
+ const key = normalizeKey(event.key)
+
+ // Ignore modifier keys when checking sequences
+ if (['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
+ return
+ }
+
+ // Add to pressed keys for simultaneous detection
+ setPressedKeys((prev) => new Set([...prev, key]))
+
+ // Add to sequence for sequential detection
+ setSequence((prev) => [...prev, key])
+
+ // Clear existing timeout
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ }
+
+ // Set new timeout for sequence reset
+ timeoutRef.current = setTimeout(() => {
+ setSequence([])
+ }, 2000)
+ },
+ [enabled]
+ )
+
+ const handleKeyUp = useCallback(
+ (event: KeyboardEvent) => {
+ if (!enabled) return
+
+ const key = normalizeKey(event.key)
+ setPressedKeys((prev) => {
+ const newSet = new Set(prev)
+ newSet.delete(key)
+ return newSet
+ })
+ },
+ [enabled]
+ )
+
+ // Check for matches on key press
+ useEffect(() => {
+ if (!enabled) return
+
+ // Check simultaneous match
+ const simultaneousMatch = checkSimultaneousMatch(pressedKeys)
+ if (simultaneousMatch?.onClick) {
+ simultaneousMatch.onClick()
+ setPressedKeys(new Set())
+ return
+ }
+
+ // Check sequential match
+ const sequentialMatch = checkSequentialMatch(sequence)
+ if (sequentialMatch?.onClick) {
+ sequentialMatch.onClick()
+ setSequence([])
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ timeoutRef.current = null
+ }
+ }
+ }, [pressedKeys, sequence, checkSimultaneousMatch, checkSequentialMatch, enabled])
+
+ useEffect(() => {
+ if (!enabled) return
+
+ document.addEventListener('keydown', handleKeyDown)
+ document.addEventListener('keyup', handleKeyUp)
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown)
+ document.removeEventListener('keyup', handleKeyUp)
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ }
+ }
+ }, [handleKeyDown, handleKeyUp, enabled])
+
+ // Return functions to control the hook
+ return {
+ clearSequence: () => {
+ setSequence([])
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ timeoutRef.current = null
+ }
+ },
+ clearPressedKeys: () => setPressedKeys(new Set()),
+ reset: () => {
+ setSequence([])
+ setPressedKeys(new Set())
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ timeoutRef.current = null
+ }
+ },
+ }
+}
+
export const useQuickActionOptions: () => {
allActions: QuickActionOption[]
selectedActions: QuickActionOption[]
@@ -33,6 +217,12 @@ export const useQuickActionOptions: () => {
setEditQuickActions: (value: boolean) => void
setSelectedActions: (value: QuickActionOption[]) => void
saveSelectedActions: () => void
+ resetSelectedActions: () => void
+ keyboardShortcuts: {
+ clearSequence: () => void
+ clearPressedKeys: () => void
+ reset: () => void
+ }
} = () => {
const router = useRouter()
const { ref } = useParams()
@@ -40,13 +230,15 @@ export const useQuickActionOptions: () => {
const [selectedActions, setSelectedActions] = useState([])
const [editQuickActions, setEditQuickActions] = useState(false)
+ // Initialize keyboard shortcuts
+ const keyboardShortcuts = useKeyboardShortcuts(selectedActions, !editQuickActions)
+
const handleNewTable = useCallback(() => {
router.push(`/project/${ref}/editor`)
snap.onAddTable()
}, [])
- // Load selected actions from localStorage on mount
- useEffect(() => {
+ const resetSelectedActions = useCallback(() => {
const savedActions = localStorage.getItem('quick-actions-selected')
if (savedActions) {
try {
@@ -72,6 +264,11 @@ export const useQuickActionOptions: () => {
}
}, [])
+ // Load selected actions from localStorage on mount
+ useEffect(() => {
+ resetSelectedActions()
+ }, [])
+
const saveSelectedActions = useCallback(() => {
const actionIds = selectedActions.map((action) => action.id)
localStorage.setItem('quick-actions-selected', JSON.stringify(actionIds))
@@ -94,10 +291,17 @@ export const useQuickActionOptions: () => {
onClick: handleNewTable,
kbd: ['c', 't'],
},
+ {
+ id: 'database-schema',
+ label: 'Database Schema',
+ icon: Network,
+ onClick: () => null,
+ kbd: ['c', 'd', 'f'],
+ },
{
id: 'database-function',
label: 'Database Function',
- icon: Database,
+ icon: FunctionSquare,
onClick: () => null,
kbd: ['c', 'd', 'f'],
},
@@ -144,6 +348,13 @@ export const useQuickActionOptions: () => {
onClick: () => null,
kbd: ['c', 'd', 'w'],
},
+ {
+ id: 'database-sql-snippet',
+ label: 'SQL Snippet',
+ icon: Webhook,
+ onClick: () => router.push(`/project/${ref ?? '_'}/sql/new`),
+ kbd: ['c', 'd', 's'],
+ },
{
id: 'database-enumerated-type',
label: 'Enumerated Type',
@@ -230,5 +441,7 @@ export const useQuickActionOptions: () => {
editQuickActions,
setEditQuickActions,
saveSelectedActions,
+ resetSelectedActions,
+ keyboardShortcuts,
}
}