mirror of
https://github.com/supabase/supabase.git
synced 2026-06-01 10:21:10 +08:00
## 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 --> [](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 -->
689 lines
24 KiB
TypeScript
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
|