EditorPanel.tsx688 lines · main
| 1 | import type { Monaco } from '@monaco-editor/react' |
| 2 | import { acceptUntrustedSql, safeSql, untrustedSql } from '@supabase/pg-meta' |
| 3 | import { useQueryClient } from '@tanstack/react-query' |
| 4 | import { useDebounce } from '@uidotdev/usehooks' |
| 5 | import { useParams } from 'common' |
| 6 | import { |
| 7 | AlertCircle, |
| 8 | Book, |
| 9 | CheckCircle2, |
| 10 | FolderOpen, |
| 11 | Loader2, |
| 12 | Maximize2, |
| 13 | PlusIcon, |
| 14 | X, |
| 15 | } from 'lucide-react' |
| 16 | import type { editor as MonacoEditor } from 'monaco-editor' |
| 17 | import { useRouter } from 'next/router' |
| 18 | import { useEffect, useRef, useState } from 'react' |
| 19 | import { |
| 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' |
| 37 | import { Admonition } from 'ui-patterns/admonition' |
| 38 | import { CodeBlock } from 'ui-patterns/CodeBlock' |
| 39 | |
| 40 | import { containsUnknownFunction, isReadOnlySelect } from '../AIAssistantPanel/AIAssistant.utils' |
| 41 | import { AIEditor } from '../AIEditor' |
| 42 | import { ButtonTooltip } from '../ButtonTooltip' |
| 43 | import { SqlWarningAdmonition } from '../SqlWarningAdmonition' |
| 44 | import { formatSqlError } from './EditorPanel.utils' |
| 45 | import { SaveSnippetDialog } from './SaveSnippetDialog' |
| 46 | import { isExplainQuery } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils' |
| 47 | import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants' |
| 48 | import { |
| 49 | createSqlSnippetSkeletonV2, |
| 50 | suffixWithLimit, |
| 51 | } from '@/components/interfaces/SQLEditor/SQLEditor.utils' |
| 52 | import { useAddDefinitions } from '@/components/interfaces/SQLEditor/useAddDefinitions' |
| 53 | import Results from '@/components/interfaces/SQLEditor/UtilityPanel/Results' |
| 54 | import { SqlRunButton } from '@/components/interfaces/SQLEditor/UtilityPanel/RunButton' |
| 55 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 56 | import { useContentIdQuery } from '@/data/content/content-id-query' |
| 57 | import { useContentQuery, type Content } from '@/data/content/content-query' |
| 58 | import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation' |
| 59 | import { contentKeys } from '@/data/content/keys' |
| 60 | import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' |
| 61 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 62 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 63 | import { BASE_PATH } from '@/lib/constants' |
| 64 | import { useProfile } from '@/lib/profile' |
| 65 | import { editorPanelState, useEditorPanelStateSnapshot } from '@/state/editor-panel-state' |
| 66 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 67 | import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' |
| 68 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 69 | import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' |
| 70 | |
| 71 | export 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 | |
| 688 | export default EditorPanel |