Files
supabase/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
Gildas Garcia 243e079a2c chore: remove _Shadcn_ suffix from Command components (#46153)
## Problem

The `_Shadcn_` suffix isn't needed anymore on `Command` components

## Solution

- Remove the `_Shadcn_` suffix
- Simplify UI package exports
- Apply prettier

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

## Summary by CodeRabbit

* **Refactor**
* Simplified command component imports and exports across the UI library
by removing internal naming aliases and adopting direct component
references. Updated the public UI package barrel export to use wildcard
re-exports for cleaner API surface.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46153?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-20 15:45:32 +02:00

689 lines
24 KiB
TypeScript

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