EditorPanel.tsx688 lines · main
1import type { Monaco } from '@monaco-editor/react'
2import { acceptUntrustedSql, safeSql, untrustedSql } from '@supabase/pg-meta'
3import { useQueryClient } from '@tanstack/react-query'
4import { useDebounce } from '@uidotdev/usehooks'
5import { useParams } from 'common'
6import {
7 AlertCircle,
8 Book,
9 CheckCircle2,
10 FolderOpen,
11 Loader2,
12 Maximize2,
13 PlusIcon,
14 X,
15} from 'lucide-react'
16import type { editor as MonacoEditor } from 'monaco-editor'
17import { useRouter } from 'next/router'
18import { useEffect, useRef, useState } from 'react'
19import {
20 Button,
21 cn,
22 Command,
23 CommandEmpty,
24 CommandGroup,
25 CommandInput,
26 CommandItem,
27 CommandList,
28 HoverCard,
29 HoverCardContent,
30 HoverCardTrigger,
31 KeyboardShortcut,
32 Popover,
33 PopoverContent,
34 PopoverTrigger,
35 SQL_ICON,
36} from 'ui'
37import { Admonition } from 'ui-patterns/admonition'
38import { CodeBlock } from 'ui-patterns/CodeBlock'
39
40import { containsUnknownFunction, isReadOnlySelect } from '../AIAssistantPanel/AIAssistant.utils'
41import { AIEditor } from '../AIEditor'
42import { ButtonTooltip } from '../ButtonTooltip'
43import { SqlWarningAdmonition } from '../SqlWarningAdmonition'
44import { formatSqlError } from './EditorPanel.utils'
45import { SaveSnippetDialog } from './SaveSnippetDialog'
46import { isExplainQuery } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils'
47import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
48import {
49 createSqlSnippetSkeletonV2,
50 suffixWithLimit,
51} from '@/components/interfaces/SQLEditor/SQLEditor.utils'
52import { useAddDefinitions } from '@/components/interfaces/SQLEditor/useAddDefinitions'
53import Results from '@/components/interfaces/SQLEditor/UtilityPanel/Results'
54import { SqlRunButton } from '@/components/interfaces/SQLEditor/UtilityPanel/RunButton'
55import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
56import { useContentIdQuery } from '@/data/content/content-id-query'
57import { useContentQuery, type Content } from '@/data/content/content-query'
58import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation'
59import { contentKeys } from '@/data/content/keys'
60import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
61import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
62import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
63import { BASE_PATH } from '@/lib/constants'
64import { useProfile } from '@/lib/profile'
65import { editorPanelState, useEditorPanelStateSnapshot } from '@/state/editor-panel-state'
66import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
67import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled'
68import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
69import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
70
71export const EditorPanel = () => {
72 const {
73 value,
74 templates,
75 results,
76 error,
77 initialPrompt,
78 onChange,
79 setValue,
80 setTemplates,
81 setResults,
82 setError,
83 activeSnippetId,
84 pendingReset,
85 } = useEditorPanelStateSnapshot()
86 const { profile } = useProfile()
87 const { closeSidebar } = useSidebarManagerSnapshot()
88 const sqlEditorSnap = useSqlEditorV2StateSnapshot()
89 const queryClient = useQueryClient()
90
91 const [activeSnippet, setActiveSnippet] = useState<Extract<Content, { type: 'sql' }> | null>(null)
92 const [isEditingTitle, setIsEditingTitle] = useState(false)
93 const [titleInput, setTitleInput] = useState('')
94 const titleInputRef = useRef<HTMLInputElement>(null)
95 const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null)
96 const shouldRefocusAfterRunRef = useRef(false)
97 const [monaco, setMonaco] = useState<Monaco | null>(null)
98
99 useAddDefinitions('', monaco)
100
101 const label = activeSnippet?.name ?? 'SQL Editor'
102
103 const commitRename = () => {
104 const newName = titleInput.trim()
105 if (!newName || !activeSnippet) {
106 setIsEditingTitle(false)
107 return
108 }
109 setActiveSnippet({ ...activeSnippet, name: newName })
110 setIsEditingTitle(false)
111 }
112 const isInlineEditorHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.INLINE_EDITOR_TOGGLE)
113 const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE)
114
115 const currentValue = value || safeSql``
116
117 const { ref } = useParams()
118 const router = useRouter()
119 const { data: project } = useSelectedProjectQuery()
120 const { data: org } = useSelectedOrganizationQuery()
121
122 const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>()
123 const [showResults, setShowResults] = useState(true)
124 const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false)
125 const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle')
126 const saveStatusTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
127 const originalSnippetRef = useRef<{ sql: string; name: string } | null>(null)
128
129 const refocusEditor = () => {
130 requestAnimationFrame(() => {
131 setTimeout(() => editorRef.current?.focus(), 0)
132 })
133 }
134
135 const clearPendingRunRefocus = () => {
136 shouldRefocusAfterRunRef.current = false
137 }
138
139 const refocusEditorAfterRunIfNeeded = () => {
140 if (!shouldRefocusAfterRunRef.current) return
141
142 shouldRefocusAfterRunRef.current = false
143 refocusEditor()
144 }
145
146 const showSaveSuccess = () => {
147 setSaveStatus('success')
148 if (saveStatusTimerRef.current) {
149 clearTimeout(saveStatusTimerRef.current)
150 }
151 saveStatusTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000)
152 }
153 const [isTemplatesOpen, setIsTemplatesOpen] = useState(false)
154 const [isSnippetsOpen, setIsSnippetsOpen] = useState(false)
155 const [snippetSearch, setSnippetSearch] = useState('')
156 const debouncedSnippetSearch = useDebounce(snippetSearch, 300)
157
158 const { data: snippetsData, isLoading: isLoadingSnippets } = useContentQuery(
159 { projectRef: ref, type: 'sql', name: debouncedSnippetSearch || undefined },
160 { enabled: isSnippetsOpen }
161 )
162
163 const { data: snippetById } = useContentIdQuery(
164 { projectRef: ref, id: activeSnippetId ?? undefined },
165 { enabled: !!activeSnippetId }
166 )
167
168 useEffect(() => {
169 if (!pendingReset) return
170 setActiveSnippet(null)
171 setIsEditingTitle(false)
172 originalSnippetRef.current = null
173 editorPanelState.pendingReset = false
174 }, [pendingReset, setActiveSnippet, setIsEditingTitle])
175
176 useEffect(() => {
177 if (!snippetById || !activeSnippetId) return
178 const sqlSnippet = snippetById as unknown as Extract<Content, { type: 'sql' }>
179 const sql = sqlSnippet.content.unchecked_sql ?? safeSql``
180 setValue(sql)
181 setActiveSnippet(sqlSnippet)
182 originalSnippetRef.current = { sql, name: sqlSnippet.name }
183 editorPanelState.setActiveSnippetId(null)
184 }, [snippetById, activeSnippetId, setValue, setActiveSnippet])
185
186 const { header: errorHeader, lines: errorContent } = error
187 ? formatSqlError(error)
188 : { header: undefined, lines: [] as string[] }
189
190 const { mutate: upsertContent, isPending: isUpserting } = useContentUpsertMutation({
191 onSuccess: (_, vars) => {
192 if (vars.payload.id && ref) {
193 queryClient.invalidateQueries({ queryKey: contentKeys.resource(ref, vars.payload.id) })
194 }
195 originalSnippetRef.current = { sql: currentValue, name: vars.payload.name }
196 showSaveSuccess()
197 },
198 onError: () => setSaveStatus('error'),
199 })
200
201 const { mutate: executeSql, isPending: isExecuting } = useExecuteSqlMutation({
202 onSuccess: async (res) => {
203 setResults(res.result)
204 setError(undefined)
205 refocusEditorAfterRunIfNeeded()
206 },
207 onError: (mutationError) => {
208 setError(mutationError)
209 setResults([])
210 refocusEditorAfterRunIfNeeded()
211 },
212 })
213
214 const onExecuteSql = (skipValidation = false) => {
215 setError(undefined)
216 setShowWarning(undefined)
217 setResults(undefined)
218
219 if (currentValue.length === 0) {
220 clearPendingRunRefocus()
221 return
222 }
223
224 if (!skipValidation) {
225 const isReadOnlySelectSQL = isReadOnlySelect(currentValue)
226 if (!isReadOnlySelectSQL) {
227 const hasUnknownFunctions = containsUnknownFunction(currentValue)
228 setShowWarning(hasUnknownFunctions ? 'hasUnknownFunctions' : 'hasWriteOperation')
229 return
230 }
231 }
232
233 executeSql({
234 sql: suffixWithLimit(acceptUntrustedSql(currentValue), 100),
235 projectRef: project?.ref,
236 connectionString: project?.connectionString,
237 isStatementTimeoutDisabled: true,
238 handleError: (executeError) => {
239 throw executeError
240 },
241 contextualInvalidation: true,
242 })
243 }
244
245 // Check if this is an EXPLAIN query result
246 const isValidExplainQuery = isExplainQuery(results ?? [])
247
248 const handleChange = (value: string) => {
249 setValue(untrustedSql(value))
250 onChange?.(untrustedSql(value))
251 }
252
253 const onSelectTemplate = (content: string) => {
254 handleChange(content)
255 setIsTemplatesOpen(false)
256 }
257
258 const onExecuteSqlFromButton = () => {
259 shouldRefocusAfterRunRef.current = true
260 onExecuteSql()
261 refocusEditor()
262 }
263
264 const handleClosePanel = () => {
265 clearPendingRunRefocus()
266 closeSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
267 setTemplates([])
268 setError(undefined)
269 setShowWarning(undefined)
270 setShowResults(true)
271 setActiveSnippet(null)
272 setIsEditingTitle(false)
273 editorPanelState.setActiveSnippetId(null)
274 }
275
276 return (
277 <div className="flex h-full flex-col bg-background">
278 <div className="border-b border-b-muted flex items-center justify-between gap-x-4 pl-4 pr-3 h-(--header-height)">
279 {isEditingTitle ? (
280 <input
281 ref={titleInputRef}
282 value={titleInput}
283 onChange={(e) => setTitleInput(e.target.value)}
284 onBlur={commitRename}
285 onKeyDown={(e) => {
286 if (e.key === 'Enter') commitRename()
287 if (e.key === 'Escape') setIsEditingTitle(false)
288 }}
289 className="text-xs bg-transparent border-b border-foreground-lighter outline-hidden w-48 py-0.5"
290 autoFocus
291 />
292 ) : (
293 <div
294 className={cn('text-xs', activeSnippet && 'cursor-pointer hover:text-foreground')}
295 onClick={() => {
296 if (!activeSnippet) return
297 setTitleInput(activeSnippet.name)
298 setIsEditingTitle(true)
299 }}
300 >
301 {label}
302 </div>
303 )}
304 <div className="flex items-center gap-2">
305 {activeSnippet && (
306 <ButtonTooltip
307 size="tiny"
308 type="text"
309 className="w-7 h-7 p-0"
310 icon={<PlusIcon size={14} />}
311 tooltip={{ content: { side: 'bottom', text: 'New snippet' } }}
312 onClick={() => editorPanelState.openAsNew()}
313 />
314 )}
315 <Popover open={isSnippetsOpen} onOpenChange={setIsSnippetsOpen}>
316 <PopoverTrigger asChild>
317 <ButtonTooltip
318 size="tiny"
319 type="text"
320 role="combobox"
321 aria-expanded={isSnippetsOpen}
322 className="w-7 h-7 p-0"
323 icon={<FolderOpen size={14} />}
324 tooltip={{
325 content: {
326 side: 'bottom',
327 text: 'Open snippet',
328 },
329 }}
330 ></ButtonTooltip>
331 </PopoverTrigger>
332 <PopoverContent align="end" className="w-[300px] p-0">
333 <Command shouldFilter={false}>
334 <CommandInput
335 placeholder="Search snippets..."
336 value={snippetSearch}
337 onValueChange={setSnippetSearch}
338 />
339 <CommandList>
340 {isLoadingSnippets ? (
341 <div className="py-6 text-center text-sm text-foreground-light">
342 Loading snippets...
343 </div>
344 ) : (
345 <CommandEmpty>No snippets found.</CommandEmpty>
346 )}
347 <CommandGroup>
348 {(snippetsData?.content ?? []).map((snippet) => (
349 <CommandItem
350 key={snippet.id}
351 value={snippet.id}
352 className="cursor-pointer"
353 onSelect={() => {
354 if (snippet.id) editorPanelState.setActiveSnippetId(snippet.id)
355 setIsSnippetsOpen(false)
356 setSnippetSearch('')
357 }}
358 >
359 {snippet.name}
360 </CommandItem>
361 ))}
362 </CommandGroup>
363 </CommandList>
364 </Command>
365 </PopoverContent>
366 </Popover>
367 {templates.length > 0 && (
368 <Popover open={isTemplatesOpen} onOpenChange={setIsTemplatesOpen}>
369 <PopoverTrigger asChild>
370 <Button
371 size="tiny"
372 type="default"
373 role="combobox"
374 className="mr-2"
375 aria-expanded={isTemplatesOpen}
376 icon={<Book size={14} />}
377 >
378 Templates
379 </Button>
380 </PopoverTrigger>
381 <PopoverContent align="end" className="w-[300px] p-0">
382 <Command>
383 <CommandInput placeholder="Search templates..." />
384 <CommandList>
385 <CommandEmpty>No templates found.</CommandEmpty>
386 <CommandGroup>
387 {templates.map((template) => (
388 <HoverCard key={template.name}>
389 <HoverCardTrigger asChild>
390 <CommandItem
391 value={template.name}
392 onSelect={() => onSelectTemplate(template.content)}
393 className="cursor-pointer"
394 >
395 <div className="flex items-center gap-3">
396 <SQL_ICON
397 size={16}
398 className={cn(
399 'w-5 h-5 flex-0 mr-2 transition-colors fill-foreground-muted'
400 )}
401 />
402 <div className="flex-1">
403 <h4 className="text-foreground flex-1">{template.name}</h4>
404 <p className="text-xs text-foreground-light">
405 {template.description}
406 </p>
407 </div>
408 </div>
409 </CommandItem>
410 </HoverCardTrigger>
411 <HoverCardContent side="left" className="w-[500px] p-0">
412 <CodeBlock
413 language="sql"
414 className="language-sql border-none"
415 hideLineNumbers
416 value={template.content}
417 />
418 </HoverCardContent>
419 </HoverCard>
420 ))}
421 </CommandGroup>
422 </CommandList>
423 </Command>
424 </PopoverContent>
425 </Popover>
426 )}
427 <ButtonTooltip
428 type="text"
429 className="w-7 h-7 p-0"
430 icon={<Maximize2 strokeWidth={1.5} />}
431 tooltip={{
432 content: {
433 side: 'bottom',
434 text: 'Expand to SQL editor',
435 },
436 }}
437 onClick={() => {
438 if (!ref) return console.error('Project ref is required')
439
440 if (!project) {
441 console.error('Project is required')
442 return
443 }
444 if (!profile) {
445 console.error('Profile is required')
446 return
447 }
448
449 const snippet = createSqlSnippetSkeletonV2({
450 name: generateSnippetTitle(),
451 sql: currentValue,
452 owner_id: profile.id,
453 project_id: project.id,
454 })
455
456 sqlEditorSnap.addSnippet({ projectRef: ref, snippet })
457 sqlEditorSnap.addNeedsSaving(snippet.id)
458
459 router.push(`/project/${ref}/sql/${snippet.id}`)
460 handleClosePanel()
461 }}
462 />
463
464 <ButtonTooltip
465 type="text"
466 className="w-7 h-7 p-0"
467 onClick={handleClosePanel}
468 icon={<X strokeWidth={1.5} />}
469 tooltip={{
470 content: {
471 side: 'bottom',
472 text: (
473 <div className="flex items-center gap-4">
474 <span>Close Editor</span>
475 {isInlineEditorHotkeyEnabled && <KeyboardShortcut keys={['Meta', 'e']} />}
476 </div>
477 ),
478 },
479 }}
480 />
481 </div>
482 </div>
483
484 <div className="flex-1 overflow-hidden flex flex-col h-full">
485 <div className="flex-1 min-h-0 relative [&_.monaco-editor]:!bg [&_.monaco-editor_.margin]:!bg [&_.monaco-editor_.monaco-editor-background]:!bg">
486 <AIEditor
487 autoFocus
488 language="pgsql"
489 value={currentValue}
490 onChange={handleChange}
491 onMount={(editor, m) => {
492 editorRef.current = editor
493 setMonaco(m)
494 }}
495 aiEndpoint={`${BASE_PATH}/api/ai/code/complete`}
496 aiMetadata={{
497 projectRef: project?.ref,
498 connectionString: project?.connectionString,
499 orgSlug: org?.slug,
500 language: 'sql',
501 }}
502 initialPrompt={initialPrompt}
503 options={{
504 tabSize: 2,
505 fontSize: 13,
506 minimap: { enabled: false },
507 wordWrap: 'on',
508 lineNumbers: 'on',
509 folding: false,
510 padding: { top: 16 },
511 lineNumbersMinChars: 3,
512 }}
513 executeQuery={onExecuteSql}
514 onClose={handleClosePanel}
515 closeShortcutEnabled={isInlineEditorHotkeyEnabled}
516 openAIAssistantShortcutEnabled={isAIAssistantHotkeyEnabled}
517 />
518 </div>
519
520 {error !== undefined && (
521 <div className="shrink-0">
522 <Admonition
523 type="warning"
524 className="rounded-none border-x-0 border-b-0 [&>div>div>pre]:text-sm [&>div]:flex [&>div]:flex-col [&>div]:gap-y-2"
525 title={errorHeader || 'Error running SQL query'}
526 description={
527 <div>
528 {errorContent.length > 0 ? (
529 errorContent.map((errorText: string, i: number) => (
530 <pre key={`err-${i}`} className="font-mono text-xs whitespace-pre-wrap">
531 {errorText}
532 </pre>
533 ))
534 ) : (
535 <p className="font-mono text-xs">{error?.error}</p>
536 )}
537 </div>
538 }
539 />
540 </div>
541 )}
542
543 {showWarning && (
544 <SqlWarningAdmonition
545 className="border-t"
546 warningType={showWarning}
547 onCancel={() => {
548 clearPendingRunRefocus()
549 setShowWarning(undefined)
550 refocusEditor()
551 }}
552 onConfirm={() => {
553 shouldRefocusAfterRunRef.current = true
554 setShowWarning(undefined)
555 onExecuteSql(true)
556 refocusEditor()
557 }}
558 />
559 )}
560
561 {results !== undefined && results.length > 0 && (
562 <div
563 className={cn(
564 `shrink-0 flex flex-col`,
565 isValidExplainQuery ? 'max-h-[600px]' : 'max-h-72',
566 showResults && 'h-full'
567 )}
568 >
569 {showResults && (
570 <div className="border-t flex-1 overflow-hidden">
571 <Results rows={results} />
572 </div>
573 )}
574 <div className="text-xs text-foreground-light border-t py-2 px-5 flex items-center justify-between">
575 <span className="font-mono">
576 {results.length} rows{results.length >= 100 && ` (Limited to only 100 rows)`}
577 </span>
578 <Button
579 size="tiny"
580 type="default"
581 className="ml-2"
582 onClick={() => setShowResults((prev) => !prev)}
583 >
584 {showResults ? 'Hide Results' : 'Show Results'}
585 </Button>
586 </div>
587 </div>
588 )}
589 {results !== undefined && results.length === 0 && !error && (
590 <div className="shrink-0">
591 <p className="text-xs text-foreground-light font-mono py-2 px-5">
592 Success. No rows returned.
593 </p>
594 </div>
595 )}
596
597 <div className="relative shrink-0 flex items-center gap-2 justify-end px-5 py-4 w-full border-t">
598 {(isUpserting || saveStatus !== 'idle') && (
599 <div
600 className={cn(
601 'absolute left-0 flex items-center gap-2 px-5 py-3 text-xs',
602 saveStatus === 'success' && 'text-brand-600',
603 saveStatus === 'error' && 'text-warning',
604 saveStatus === 'idle' && 'text-foreground-light'
605 )}
606 >
607 {isUpserting && <Loader2 size={13} className="animate-spin" />}
608 {saveStatus === 'success' && <CheckCircle2 size={13} />}
609 {saveStatus === 'error' && <AlertCircle size={13} />}
610 <span>
611 {isUpserting
612 ? 'Saving...'
613 : saveStatus === 'success'
614 ? 'Snippet updated'
615 : 'Failed to save snippet'}
616 </span>
617 </div>
618 )}
619 <Button
620 type="default"
621 size="tiny"
622 disabled={
623 !currentValue ||
624 isExecuting ||
625 isUpserting ||
626 (!!activeSnippet &&
627 currentValue === originalSnippetRef.current?.sql &&
628 activeSnippet.name === originalSnippetRef.current?.name)
629 }
630 onClick={() => {
631 if (!ref || !profile || !project) return
632
633 if (activeSnippet) {
634 setSaveStatus('idle')
635 upsertContent({
636 projectRef: ref,
637 payload: {
638 id: activeSnippet.id,
639 type: 'sql',
640 name: activeSnippet.name,
641 description: activeSnippet.description ?? '',
642 visibility: activeSnippet.visibility ?? 'user',
643 project_id: project.id,
644 owner_id: profile.id,
645 content: {
646 ...activeSnippet.content,
647 sql: currentValue,
648 },
649 },
650 })
651 } else {
652 setIsSaveDialogOpen(true)
653 }
654 }}
655 >
656 {activeSnippet ? 'Update snippet' : 'Save as snippet'}
657 </Button>
658 <SqlRunButton
659 isDisabled={isExecuting}
660 isExecuting={isExecuting}
661 onClick={onExecuteSqlFromButton}
662 />
663 </div>
664 </div>
665 <SaveSnippetDialog
666 open={isSaveDialogOpen}
667 sql={currentValue}
668 onOpenChange={setIsSaveDialogOpen}
669 onSave={(name) => {
670 if (!ref || !profile || !project) return
671 const snippet = createSqlSnippetSkeletonV2({
672 name,
673 sql: currentValue,
674 owner_id: profile.id,
675 project_id: project.id,
676 })
677 sqlEditorSnap.addSnippet({ projectRef: ref, snippet })
678 sqlEditorSnap.addNeedsSaving(snippet.id)
679 setActiveSnippet(snippet as unknown as Extract<Content, { type: 'sql' }>)
680 originalSnippetRef.current = { sql: currentValue, name }
681 showSaveSuccess()
682 }}
683 />
684 </div>
685 )
686}
687
688export default EditorPanel