Files
supabase/apps/studio/components/ui/DownloadResultsButton.tsx
Ali WaseemandDanny White fa8f49b261 feat(studio): add keyboard shortcuts to the SQL editor (#45335)
## Summary

Adds the first batch of keyboard shortcuts for the SQL editor, following
the registry pattern established for the table editor.

## Shortcuts

| Shortcut | Action | Notes |
| --- | --- | --- |
| `Esc` | Blur the SQL editor | Registered as a Monaco command with a
context-key precondition so inline widgets keep owning `Esc` (suggest,
find, parameter hints, snippet/rename mode, accessibility help, inline
suggestions, and selection cancellation). |
| `Shift+E` | Focus the SQL editor | Pairs with `Esc` for mouse-free
round-trip. |
| `Alt+Shift+F` | Prettify SQL | Now wired through the registry; the
tooltip and dropdown badge in `UtilityActions` read the keybind from the
same source of truth. Works from inside the editor (Monaco action) and
from anywhere on the page (`useShortcut`). |
| `Mod+Shift+Enter` | Run EXPLAIN ANALYZE | Routes results into the
Explain tab. Surfaces in the Monaco context menu next to "Run Query". |
| `Shift+N` | Open a new SQL snippet | Navigates to `/sql/new?skip=true`
to avoid the redirect-to-last-visited effect that fires on plain
`/sql/new`. |

All entries appear in the command menu (`Mod+P`) under "Shortcuts" while
their host components are mounted. None are surfaced in Account →
Preferences → Keyboard shortcuts yet (`showInSettings: false`), matching
how the table editor shortcuts shipped.

## Notes

- The blur shortcut intentionally lives on the Monaco instance rather
than the document-level hotkey listener — the document listener can't
preempt Monaco's own `Esc` handling. Other shortcuts that need to fire
while the editor has focus (run, save, format, explain) are registered
as Monaco actions; everything else uses `useShortcut`.
- Format and explain are double-registered (Monaco action +
`useShortcut`) so they fire whether the editor is focused or not. The
Monaco actions don't read the user's enable/disable preference yet —
same asymmetry as the existing run/save actions.
- `Shift+N` is scoped to the SQL editor route. To make it work globally
we'd register it at a higher layout level.

## Test plan

- [x] Inside editor: `Esc` blurs; suggest/find/parameter hints still
close on `Esc`; multi-cursor selection collapses on first `Esc`, blurs
on second.
- [x] Outside editor: `Shift+E` returns focus to the editor.
- [x] `Alt+Shift+F` formats from inside and outside the editor; tooltip
+ dropdown badge show the correct keybind.
- [x] `Mod+Shift+Enter` runs EXPLAIN ANALYZE and switches to the Explain
tab.
- [x] `Shift+N` opens a fresh snippet without bouncing back to the
previous one.
- [x] All five shortcuts appear in `Mod+P` with the right badges.

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

* **New Features**
* Keyboard shortcuts for SQL editor: format SQL, run EXPLAIN ANALYZE,
focus/blur editor, and open a new SQL snippet.
* Added "Prettify SQL" and "Run EXPLAIN ANALYZE" actions to the editor
context menu with shortcuts.
* Centralized registration of SQL editor shortcuts so they appear across
the app.

* **UX Improvements**
* Escape key blurs editor focus when appropriate to allow easy exit
without disrupting editor widgets.

* **Style**
  * Adjusted success toast capitalization for copied Markdown.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
2026-04-29 06:45:31 -06:00

176 lines
4.9 KiB
TypeScript

import { IS_PLATFORM, useParams } from 'common'
import saveAs from 'file-saver'
import { ChevronDown, Copy, Download, Settings } from 'lucide-react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useMemo } from 'react'
import { toast } from 'sonner'
import {
Button,
copyToClipboard,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
KeyboardShortcut,
} from 'ui'
import {
convertResultsToCSV,
convertResultsToJSON,
convertResultsToMarkdown,
} from '@/components/interfaces/SQLEditor/UtilityPanel/Results.utils'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
interface DownloadResultsButtonProps {
iconOnly?: boolean
type?: 'text' | 'default'
text?: string
align?: 'start' | 'center' | 'end'
results: any[]
fileName: string
onDownloadAsCSV?: () => void
onCopyAsMarkdown?: () => void
onCopyAsJSON?: () => void
onCopyAsCSV?: () => void
}
export const DownloadResultsButton = ({
iconOnly = false,
type = 'default',
text = 'Export',
align = 'start',
results,
fileName,
onDownloadAsCSV,
onCopyAsMarkdown,
onCopyAsJSON,
onCopyAsCSV,
}: DownloadResultsButtonProps) => {
const { ref } = useParams()
const pathname = usePathname()
const isLogs = pathname?.includes?.('/logs') ?? false
const isEmpty = useMemo(() => results.length === 0, [results])
const downloadAsCSV = () => {
const csv = convertResultsToCSV(results)
if (!csv) {
toast('Results are empty')
return
}
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
saveAs(blob, `${fileName}.csv`)
toast.success('Downloading results as CSV')
onDownloadAsCSV?.()
}
const copyAsMarkdown = () => {
const markdownData = convertResultsToMarkdown(results)
if (!markdownData) {
toast('Results are empty')
return
}
copyToClipboard(markdownData, () => {
toast.success('Copied Markdown to clipboard')
onCopyAsMarkdown?.()
})
}
const copyAsJSON = () => {
const jsonData = convertResultsToJSON(results)
if (!jsonData) {
toast('Results are empty')
return
}
copyToClipboard(jsonData, () => {
toast.success('Copied JSON to clipboard')
onCopyAsJSON?.()
})
}
const copyAsCSV = () => {
const csv = convertResultsToCSV(results)
if (!csv) {
toast('Results are empty')
return
}
copyToClipboard(csv, () => {
toast.success('Copied CSV to clipboard')
onCopyAsCSV?.()
})
}
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type={type}
icon={iconOnly ? <Download /> : undefined}
iconRight={iconOnly ? undefined : <ChevronDown />}
disabled={results.length === 0}
className={iconOnly ? 'w-7' : ''}
>
{!iconOnly && text}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-60">
{isLogs && IS_PLATFORM && (
<DropdownMenuItem asChild className="gap-x-2">
<Link href={`/project/${ref}/settings/log-drains`}>
<Settings size={14} />
<p>Add a Log Drain</p>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={copyAsMarkdown} className="gap-x-2">
<Copy size={14} />
<p>Copy as Markdown</p>
<span className="ml-auto">
<KeyboardShortcut keys={['Shift', 'Meta', 'm']} />
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyAsJSON} className="gap-x-2">
<Copy size={14} />
<p>Copy as JSON</p>
<span className="ml-auto">
<KeyboardShortcut keys={['Shift', 'Meta', 'j']} />
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyAsCSV} className="gap-x-2">
<Copy size={14} />
<p>Copy as CSV</p>
<span className="ml-auto">
<KeyboardShortcut keys={['Shift', 'Meta', 'c']} />
</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-x-2" onClick={() => downloadAsCSV()}>
<Download size={14} />
<p>Download CSV</p>
<span className="ml-auto">
<KeyboardShortcut keys={['Shift', 'Meta', 'd']} />
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}