| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688 |
- import type { Monaco } from '@monaco-editor/react'
- import { acceptUntrustedSql, safeSql, untrustedSql } from '@supabase/pg-meta'
- import { useQueryClient } from '@tanstack/react-query'
- import { useDebounce } from '@uidotdev/usehooks'
- import { useParams } from 'common'
- import {
- AlertCircle,
- Book,
- CheckCircle2,
- FolderOpen,
- Loader2,
- Maximize2,
- PlusIcon,
- X,
- } from 'lucide-react'
- import type { editor as MonacoEditor } from 'monaco-editor'
- import { useRouter } from 'next/router'
- import { useEffect, useRef, useState } from 'react'
- import {
- Button,
- cn,
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- HoverCard,
- HoverCardContent,
- HoverCardTrigger,
- KeyboardShortcut,
- Popover,
- PopoverContent,
- PopoverTrigger,
- SQL_ICON,
- } from 'ui'
- import { Admonition } from 'ui-patterns/admonition'
- import { CodeBlock } from 'ui-patterns/CodeBlock'
- import { containsUnknownFunction, isReadOnlySelect } from '../AIAssistantPanel/AIAssistant.utils'
- import { AIEditor } from '../AIEditor'
- import { ButtonTooltip } from '../ButtonTooltip'
- import { SqlWarningAdmonition } from '../SqlWarningAdmonition'
- import { formatSqlError } from './EditorPanel.utils'
- import { SaveSnippetDialog } from './SaveSnippetDialog'
- import { isExplainQuery } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils'
- import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
- import {
- createSqlSnippetSkeletonV2,
- suffixWithLimit,
- } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
- import { useAddDefinitions } from '@/components/interfaces/SQLEditor/useAddDefinitions'
- import Results from '@/components/interfaces/SQLEditor/UtilityPanel/Results'
- import { SqlRunButton } from '@/components/interfaces/SQLEditor/UtilityPanel/RunButton'
- import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
- import { useContentIdQuery } from '@/data/content/content-id-query'
- 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 { 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'
- export const EditorPanel = () => {
- const {
- value,
- templates,
- results,
- error,
- initialPrompt,
- onChange,
- setValue,
- setTemplates,
- setResults,
- setError,
- activeSnippetId,
- pendingReset,
- } = useEditorPanelStateSnapshot()
- const { profile } = useProfile()
- const { closeSidebar } = useSidebarManagerSnapshot()
- const sqlEditorSnap = useSqlEditorV2StateSnapshot()
- const queryClient = useQueryClient()
- const [activeSnippet, setActiveSnippet] = useState<Extract<Content, { type: 'sql' }> | null>(null)
- const [isEditingTitle, setIsEditingTitle] = useState(false)
- const [titleInput, setTitleInput] = useState('')
- const titleInputRef = useRef<HTMLInputElement>(null)
- const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null)
- const shouldRefocusAfterRunRef = useRef(false)
- const [monaco, setMonaco] = useState<Monaco | null>(null)
- useAddDefinitions('', monaco)
- const label = activeSnippet?.name ?? 'SQL Editor'
- const commitRename = () => {
- const newName = titleInput.trim()
- if (!newName || !activeSnippet) {
- setIsEditingTitle(false)
- return
- }
- setActiveSnippet({ ...activeSnippet, name: newName })
- setIsEditingTitle(false)
- }
- const isInlineEditorHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.INLINE_EDITOR_TOGGLE)
- const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE)
- const currentValue = value || safeSql``
- const { ref } = useParams()
- const router = useRouter()
- const { data: project } = useSelectedProjectQuery()
- const { data: org } = useSelectedOrganizationQuery()
- const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>()
- const [showResults, setShowResults] = useState(true)
- const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false)
- const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle')
- const saveStatusTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
- const originalSnippetRef = useRef<{ sql: string; name: string } | null>(null)
- const refocusEditor = () => {
- requestAnimationFrame(() => {
- setTimeout(() => editorRef.current?.focus(), 0)
- })
- }
- const clearPendingRunRefocus = () => {
- shouldRefocusAfterRunRef.current = false
- }
- const refocusEditorAfterRunIfNeeded = () => {
- if (!shouldRefocusAfterRunRef.current) return
- shouldRefocusAfterRunRef.current = false
- refocusEditor()
- }
- const showSaveSuccess = () => {
- setSaveStatus('success')
- if (saveStatusTimerRef.current) {
- clearTimeout(saveStatusTimerRef.current)
- }
- saveStatusTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000)
- }
- const [isTemplatesOpen, setIsTemplatesOpen] = useState(false)
- const [isSnippetsOpen, setIsSnippetsOpen] = useState(false)
- const [snippetSearch, setSnippetSearch] = useState('')
- const debouncedSnippetSearch = useDebounce(snippetSearch, 300)
- const { data: snippetsData, isLoading: isLoadingSnippets } = useContentQuery(
- { projectRef: ref, type: 'sql', name: debouncedSnippetSearch || undefined },
- { enabled: isSnippetsOpen }
- )
- const { data: snippetById } = useContentIdQuery(
- { projectRef: ref, id: activeSnippetId ?? undefined },
- { enabled: !!activeSnippetId }
- )
- useEffect(() => {
- if (!pendingReset) return
- setActiveSnippet(null)
- setIsEditingTitle(false)
- originalSnippetRef.current = null
- editorPanelState.pendingReset = false
- }, [pendingReset, setActiveSnippet, setIsEditingTitle])
- useEffect(() => {
- if (!snippetById || !activeSnippetId) return
- const sqlSnippet = snippetById as unknown as Extract<Content, { type: 'sql' }>
- const sql = sqlSnippet.content.unchecked_sql ?? safeSql``
- setValue(sql)
- setActiveSnippet(sqlSnippet)
- originalSnippetRef.current = { sql, name: sqlSnippet.name }
- editorPanelState.setActiveSnippetId(null)
- }, [snippetById, activeSnippetId, setValue, setActiveSnippet])
- const { header: errorHeader, lines: errorContent } = error
- ? formatSqlError(error)
- : { header: undefined, lines: [] as string[] }
- const { mutate: upsertContent, isPending: isUpserting } = useContentUpsertMutation({
- onSuccess: (_, vars) => {
- if (vars.payload.id && ref) {
- queryClient.invalidateQueries({ queryKey: contentKeys.resource(ref, vars.payload.id) })
- }
- originalSnippetRef.current = { sql: currentValue, name: vars.payload.name }
- showSaveSuccess()
- },
- onError: () => setSaveStatus('error'),
- })
- const { mutate: executeSql, isPending: isExecuting } = useExecuteSqlMutation({
- onSuccess: async (res) => {
- setResults(res.result)
- setError(undefined)
- refocusEditorAfterRunIfNeeded()
- },
- onError: (mutationError) => {
- setError(mutationError)
- setResults([])
- refocusEditorAfterRunIfNeeded()
- },
- })
- const onExecuteSql = (skipValidation = false) => {
- setError(undefined)
- setShowWarning(undefined)
- setResults(undefined)
- if (currentValue.length === 0) {
- clearPendingRunRefocus()
- return
- }
- if (!skipValidation) {
- const isReadOnlySelectSQL = isReadOnlySelect(currentValue)
- if (!isReadOnlySelectSQL) {
- const hasUnknownFunctions = containsUnknownFunction(currentValue)
- setShowWarning(hasUnknownFunctions ? 'hasUnknownFunctions' : 'hasWriteOperation')
- return
- }
- }
- executeSql({
- sql: suffixWithLimit(acceptUntrustedSql(currentValue), 100),
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- isStatementTimeoutDisabled: true,
- handleError: (executeError) => {
- throw executeError
- },
- contextualInvalidation: true,
- })
- }
- // Check if this is an EXPLAIN query result
- const isValidExplainQuery = isExplainQuery(results ?? [])
- const handleChange = (value: string) => {
- setValue(untrustedSql(value))
- onChange?.(untrustedSql(value))
- }
- const onSelectTemplate = (content: string) => {
- handleChange(content)
- setIsTemplatesOpen(false)
- }
- const onExecuteSqlFromButton = () => {
- shouldRefocusAfterRunRef.current = true
- onExecuteSql()
- refocusEditor()
- }
- const handleClosePanel = () => {
- clearPendingRunRefocus()
- closeSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
- setTemplates([])
- setError(undefined)
- setShowWarning(undefined)
- setShowResults(true)
- setActiveSnippet(null)
- setIsEditingTitle(false)
- editorPanelState.setActiveSnippetId(null)
- }
- return (
- <div className="flex h-full flex-col bg-background">
- <div className="border-b border-b-muted flex items-center justify-between gap-x-4 pl-4 pr-3 h-(--header-height)">
- {isEditingTitle ? (
- <input
- ref={titleInputRef}
- value={titleInput}
- onChange={(e) => setTitleInput(e.target.value)}
- onBlur={commitRename}
- onKeyDown={(e) => {
- if (e.key === 'Enter') commitRename()
- if (e.key === 'Escape') setIsEditingTitle(false)
- }}
- className="text-xs bg-transparent border-b border-foreground-lighter outline-hidden w-48 py-0.5"
- autoFocus
- />
- ) : (
- <div
- className={cn('text-xs', activeSnippet && 'cursor-pointer hover:text-foreground')}
- onClick={() => {
- if (!activeSnippet) return
- setTitleInput(activeSnippet.name)
- setIsEditingTitle(true)
- }}
- >
- {label}
- </div>
- )}
- <div className="flex items-center gap-2">
- {activeSnippet && (
- <ButtonTooltip
- size="tiny"
- type="text"
- className="w-7 h-7 p-0"
- icon={<PlusIcon size={14} />}
- tooltip={{ content: { side: 'bottom', text: 'New snippet' } }}
- onClick={() => editorPanelState.openAsNew()}
- />
- )}
- <Popover open={isSnippetsOpen} onOpenChange={setIsSnippetsOpen}>
- <PopoverTrigger asChild>
- <ButtonTooltip
- size="tiny"
- type="text"
- role="combobox"
- aria-expanded={isSnippetsOpen}
- className="w-7 h-7 p-0"
- icon={<FolderOpen size={14} />}
- tooltip={{
- content: {
- side: 'bottom',
- text: 'Open snippet',
- },
- }}
- ></ButtonTooltip>
- </PopoverTrigger>
- <PopoverContent align="end" className="w-[300px] p-0">
- <Command shouldFilter={false}>
- <CommandInput
- placeholder="Search snippets..."
- value={snippetSearch}
- onValueChange={setSnippetSearch}
- />
- <CommandList>
- {isLoadingSnippets ? (
- <div className="py-6 text-center text-sm text-foreground-light">
- Loading snippets...
- </div>
- ) : (
- <CommandEmpty>No snippets found.</CommandEmpty>
- )}
- <CommandGroup>
- {(snippetsData?.content ?? []).map((snippet) => (
- <CommandItem
- key={snippet.id}
- value={snippet.id}
- className="cursor-pointer"
- onSelect={() => {
- if (snippet.id) editorPanelState.setActiveSnippetId(snippet.id)
- setIsSnippetsOpen(false)
- setSnippetSearch('')
- }}
- >
- {snippet.name}
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- {templates.length > 0 && (
- <Popover open={isTemplatesOpen} onOpenChange={setIsTemplatesOpen}>
- <PopoverTrigger asChild>
- <Button
- size="tiny"
- type="default"
- role="combobox"
- className="mr-2"
- aria-expanded={isTemplatesOpen}
- icon={<Book size={14} />}
- >
- Templates
- </Button>
- </PopoverTrigger>
- <PopoverContent align="end" className="w-[300px] p-0">
- <Command>
- <CommandInput placeholder="Search templates..." />
- <CommandList>
- <CommandEmpty>No templates found.</CommandEmpty>
- <CommandGroup>
- {templates.map((template) => (
- <HoverCard key={template.name}>
- <HoverCardTrigger asChild>
- <CommandItem
- value={template.name}
- onSelect={() => onSelectTemplate(template.content)}
- className="cursor-pointer"
- >
- <div className="flex items-center gap-3">
- <SQL_ICON
- size={16}
- className={cn(
- 'w-5 h-5 flex-0 mr-2 transition-colors fill-foreground-muted'
- )}
- />
- <div className="flex-1">
- <h4 className="text-foreground flex-1">{template.name}</h4>
- <p className="text-xs text-foreground-light">
- {template.description}
- </p>
- </div>
- </div>
- </CommandItem>
- </HoverCardTrigger>
- <HoverCardContent side="left" className="w-[500px] p-0">
- <CodeBlock
- language="sql"
- className="language-sql border-none"
- hideLineNumbers
- value={template.content}
- />
- </HoverCardContent>
- </HoverCard>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- )}
- <ButtonTooltip
- type="text"
- className="w-7 h-7 p-0"
- icon={<Maximize2 strokeWidth={1.5} />}
- tooltip={{
- content: {
- side: 'bottom',
- text: 'Expand to SQL editor',
- },
- }}
- onClick={() => {
- if (!ref) return console.error('Project ref is required')
- if (!project) {
- console.error('Project is required')
- return
- }
- if (!profile) {
- console.error('Profile is required')
- return
- }
- const snippet = createSqlSnippetSkeletonV2({
- name: generateSnippetTitle(),
- sql: currentValue,
- owner_id: profile.id,
- project_id: project.id,
- })
- sqlEditorSnap.addSnippet({ projectRef: ref, snippet })
- sqlEditorSnap.addNeedsSaving(snippet.id)
- router.push(`/project/${ref}/sql/${snippet.id}`)
- handleClosePanel()
- }}
- />
- <ButtonTooltip
- type="text"
- className="w-7 h-7 p-0"
- onClick={handleClosePanel}
- icon={<X strokeWidth={1.5} />}
- tooltip={{
- content: {
- side: 'bottom',
- text: (
- <div className="flex items-center gap-4">
- <span>Close Editor</span>
- {isInlineEditorHotkeyEnabled && <KeyboardShortcut keys={['Meta', 'e']} />}
- </div>
- ),
- },
- }}
- />
- </div>
- </div>
- <div className="flex-1 overflow-hidden flex flex-col h-full">
- <div className="flex-1 min-h-0 relative [&_.monaco-editor]:!bg [&_.monaco-editor_.margin]:!bg [&_.monaco-editor_.monaco-editor-background]:!bg">
- <AIEditor
- autoFocus
- language="pgsql"
- value={currentValue}
- onChange={handleChange}
- onMount={(editor, m) => {
- editorRef.current = editor
- setMonaco(m)
- }}
- aiEndpoint={`${BASE_PATH}/api/ai/code/complete`}
- aiMetadata={{
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- orgSlug: org?.slug,
- language: 'sql',
- }}
- initialPrompt={initialPrompt}
- options={{
- tabSize: 2,
- fontSize: 13,
- minimap: { enabled: false },
- wordWrap: 'on',
- lineNumbers: 'on',
- folding: false,
- padding: { top: 16 },
- lineNumbersMinChars: 3,
- }}
- executeQuery={onExecuteSql}
- onClose={handleClosePanel}
- closeShortcutEnabled={isInlineEditorHotkeyEnabled}
- openAIAssistantShortcutEnabled={isAIAssistantHotkeyEnabled}
- />
- </div>
- {error !== undefined && (
- <div className="shrink-0">
- <Admonition
- type="warning"
- className="rounded-none border-x-0 border-b-0 [&>div>div>pre]:text-sm [&>div]:flex [&>div]:flex-col [&>div]:gap-y-2"
- title={errorHeader || 'Error running SQL query'}
- description={
- <div>
- {errorContent.length > 0 ? (
- errorContent.map((errorText: string, i: number) => (
- <pre key={`err-${i}`} className="font-mono text-xs whitespace-pre-wrap">
- {errorText}
- </pre>
- ))
- ) : (
- <p className="font-mono text-xs">{error?.error}</p>
- )}
- </div>
- }
- />
- </div>
- )}
- {showWarning && (
- <SqlWarningAdmonition
- className="border-t"
- warningType={showWarning}
- onCancel={() => {
- clearPendingRunRefocus()
- setShowWarning(undefined)
- refocusEditor()
- }}
- onConfirm={() => {
- shouldRefocusAfterRunRef.current = true
- setShowWarning(undefined)
- onExecuteSql(true)
- refocusEditor()
- }}
- />
- )}
- {results !== undefined && results.length > 0 && (
- <div
- className={cn(
- `shrink-0 flex flex-col`,
- isValidExplainQuery ? 'max-h-[600px]' : 'max-h-72',
- showResults && 'h-full'
- )}
- >
- {showResults && (
- <div className="border-t flex-1 overflow-hidden">
- <Results rows={results} />
- </div>
- )}
- <div className="text-xs text-foreground-light border-t py-2 px-5 flex items-center justify-between">
- <span className="font-mono">
- {results.length} rows{results.length >= 100 && ` (Limited to only 100 rows)`}
- </span>
- <Button
- size="tiny"
- type="default"
- className="ml-2"
- onClick={() => setShowResults((prev) => !prev)}
- >
- {showResults ? 'Hide Results' : 'Show Results'}
- </Button>
- </div>
- </div>
- )}
- {results !== undefined && results.length === 0 && !error && (
- <div className="shrink-0">
- <p className="text-xs text-foreground-light font-mono py-2 px-5">
- Success. No rows returned.
- </p>
- </div>
- )}
- <div className="relative shrink-0 flex items-center gap-2 justify-end px-5 py-4 w-full border-t">
- {(isUpserting || saveStatus !== 'idle') && (
- <div
- className={cn(
- 'absolute left-0 flex items-center gap-2 px-5 py-3 text-xs',
- saveStatus === 'success' && 'text-brand-600',
- saveStatus === 'error' && 'text-warning',
- saveStatus === 'idle' && 'text-foreground-light'
- )}
- >
- {isUpserting && <Loader2 size={13} className="animate-spin" />}
- {saveStatus === 'success' && <CheckCircle2 size={13} />}
- {saveStatus === 'error' && <AlertCircle size={13} />}
- <span>
- {isUpserting
- ? 'Saving...'
- : saveStatus === 'success'
- ? 'Snippet updated'
- : 'Failed to save snippet'}
- </span>
- </div>
- )}
- <Button
- type="default"
- size="tiny"
- disabled={
- !currentValue ||
- isExecuting ||
- isUpserting ||
- (!!activeSnippet &&
- currentValue === originalSnippetRef.current?.sql &&
- activeSnippet.name === originalSnippetRef.current?.name)
- }
- onClick={() => {
- if (!ref || !profile || !project) return
- if (activeSnippet) {
- setSaveStatus('idle')
- upsertContent({
- projectRef: ref,
- payload: {
- id: activeSnippet.id,
- type: 'sql',
- name: activeSnippet.name,
- description: activeSnippet.description ?? '',
- visibility: activeSnippet.visibility ?? 'user',
- project_id: project.id,
- owner_id: profile.id,
- content: {
- ...activeSnippet.content,
- sql: currentValue,
- },
- },
- })
- } else {
- setIsSaveDialogOpen(true)
- }
- }}
- >
- {activeSnippet ? 'Update snippet' : 'Save as snippet'}
- </Button>
- <SqlRunButton
- isDisabled={isExecuting}
- isExecuting={isExecuting}
- onClick={onExecuteSqlFromButton}
- />
- </div>
- </div>
- <SaveSnippetDialog
- open={isSaveDialogOpen}
- sql={currentValue}
- onOpenChange={setIsSaveDialogOpen}
- onSave={(name) => {
- if (!ref || !profile || !project) return
- const snippet = createSqlSnippetSkeletonV2({
- name,
- sql: currentValue,
- owner_id: profile.id,
- project_id: project.id,
- })
- sqlEditorSnap.addSnippet({ projectRef: ref, snippet })
- sqlEditorSnap.addNeedsSaving(snippet.id)
- setActiveSnippet(snippet as unknown as Extract<Content, { type: 'sql' }>)
- originalSnippetRef.current = { sql: currentValue, name }
- showSaveSuccess()
- }}
- />
- </div>
- )
- }
- export default EditorPanel
|