EditorPanel.tsx 24 KB

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