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 | null>(null) const [isEditingTitle, setIsEditingTitle] = useState(false) const [titleInput, setTitleInput] = useState('') const titleInputRef = useRef(null) const editorRef = useRef(null) const shouldRefocusAfterRunRef = useRef(false) const [monaco, setMonaco] = useState(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>(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 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 (
{isEditingTitle ? ( 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 /> ) : (
{ if (!activeSnippet) return setTitleInput(activeSnippet.name) setIsEditingTitle(true) }} > {label}
)}
{activeSnippet && ( } tooltip={{ content: { side: 'bottom', text: 'New snippet' } }} onClick={() => editorPanelState.openAsNew()} /> )} } tooltip={{ content: { side: 'bottom', text: 'Open snippet', }, }} > {isLoadingSnippets ? (
Loading snippets...
) : ( No snippets found. )} {(snippetsData?.content ?? []).map((snippet) => ( { if (snippet.id) editorPanelState.setActiveSnippetId(snippet.id) setIsSnippetsOpen(false) setSnippetSearch('') }} > {snippet.name} ))}
{templates.length > 0 && ( No templates found. {templates.map((template) => ( onSelectTemplate(template.content)} className="cursor-pointer" >

{template.name}

{template.description}

))}
)} } 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() }} /> } tooltip={{ content: { side: 'bottom', text: (
Close Editor {isInlineEditorHotkeyEnabled && }
), }, }} />
{ 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} />
{error !== undefined && (
{errorContent.length > 0 ? ( errorContent.map((errorText: string, i: number) => (
                        {errorText}
                      
)) ) : (

{error?.error}

)}
} />
)} {showWarning && ( { clearPendingRunRefocus() setShowWarning(undefined) refocusEditor() }} onConfirm={() => { shouldRefocusAfterRunRef.current = true setShowWarning(undefined) onExecuteSql(true) refocusEditor() }} /> )} {results !== undefined && results.length > 0 && (
{showResults && (
)}
{results.length} rows{results.length >= 100 && ` (Limited to only 100 rows)`}
)} {results !== undefined && results.length === 0 && !error && (

Success. No rows returned.

)}
{(isUpserting || saveStatus !== 'idle') && (
{isUpserting && } {saveStatus === 'success' && } {saveStatus === 'error' && } {isUpserting ? 'Saving...' : saveStatus === 'success' ? 'Snippet updated' : 'Failed to save snippet'}
)}
{ 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) originalSnippetRef.current = { sql: currentValue, name } showSaveSuccess() }} /> ) } export default EditorPanel