refactor(sql-editor): decompose into controller contexts + presentational components (decompose 6/6) (#47938)

## What

Final step (**6 of 6**) of the `SQLEditor.tsx` decomposition. Splits the
two `ResizablePanel` bodies out of the `SQLEditorContent` monolith into
presentational sub-components:

- **`SQLEditorPane`** — the editor panel: loading state, `DiffEditor` +
diff ask-AI widget, `MonacoEditor` + ask-AI widget. Reads the Monaco
refs (`editorRef`/`monacoRef`/`diffEditorRef`) from `SQLEditorContext`,
and receives the `diff`/`prompt`/`ai` controllers plus reactive state as
props.
- **`SQLEditorResults`** — the results panel: loading state +
`UtilityPanel`.

`SQLEditorContent` is now a composition root: shared-ref provider, hook
composition, and the run-query warning modal.

## Result

`SQLEditor.tsx` goes from the original **1056-line** monolith down to
**259 lines** (≈75% reduction). The remaining size over a bare ~140-line
root is the run-query warning modal, kept inline **deliberately**: its
handlers hold the `acceptUntrustedSql` promotion, which must stay at the
explicit user-action boundary in the root rather than moving into a
presentational pane.

## Behavior-preserving

The moved JSX is byte-identical aside from prop threading. No logic,
effects, dependency arrays, or `eslint-disable`s changed. The two
**render-time ref reads** — the editor placeholder (`!promptState.isOpen
&& !editorRef.current?.getValue()`) and the ask-AI widget gate
(`editorRef.current && promptState.isOpen && !isDiffOpen`) — are
preserved verbatim in `SQLEditorPane`, which re-renders whenever the
`prompt`/`diff` props change, keeping those reads fresh (the guardrail
from the plan).

Verification (all green):
- `SQLEditor.test.tsx` characterization suite — 11/11 pass
- `tsc --noEmit` — no new errors
- eslint — clean (no new ratchet entries)
- prettier — clean

## Stack

Builds on decompose 5 (#47935). This is the last PR in the series — the
decomposition is complete after this merges.


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

* **New Features**
* Added a resizable SQL editor layout with run warnings, query results,
and an Explain view.
* Introduced centralized SQL editor controllers and expanded AI-assisted
prompt/diff workflows.
  * Added keyboard support for running and Explain analysis.
* **Bug Fixes**
  * Restored editor focus after accepting or discarding AI changes.
  * Improved handling and validation of SQL Explain actions.
  * Preserved editor scroll position when switching snippets.
* **Refactor**
* Streamlined the SQLEditor into a composed layout and improved
memoization to reduce unnecessary re-renders.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Charis authored and GitHub committed 2026-07-15 13:10:09 -04:00
1 parent 2279c4e69e
commit 9e80a159b5
11 files changed
+756 -458

No files matched your search

@@ -1,372 +1,11 @@
import { acceptUntrustedSql, untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta'
import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
import { Loader2 } from 'lucide-react'
import dynamic from 'next/dynamic'
import { useCallback, useEffect, useEffectEvent, useState } from 'react'
import { cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
import { RunQueryWarningModal } from './RunQueryWarningModal'
import { appendEnableRLSStatements } from './SQLEditor.utils'
import { SQLEditorProvider, useSQLEditorContext } from './SQLEditorContext'
import { useAddDefinitions } from './useAddDefinitions'
import { useEditorMount } from './useEditorMount'
import { usePrettifyQuery } from './usePrettifyQuery'
import { useSnippetIdentity } from './useSnippetIdentity'
import { useSnippetTitleGenerator } from './useSnippetTitleGenerator'
import { useSqlEditorAi } from './useSqlEditorAi'
import { useSqlEditorExecution } from './useSqlEditorExecution'
import { useSqlEditorExplain } from './useSqlEditorExplain'
import { useSqlEditorShortcuts } from './useSqlEditorShortcuts'
import { UtilityActions } from './UtilityPanel/UtilityActions'
import { UtilityPanel } from './UtilityPanel/UtilityPanel'
import ResizableAIWidget from '@/components/ui/AIEditor/ResizableAIWidget'
import { isValidConnString } from '@/data/fetchers'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { detectOS } from '@/lib/helpers'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
import {
getSqlEditorV2StateSnapshot,
useSqlEditorV2StateSnapshot,
} from '@/state/sql-editor/sql-editor-state'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
// Load the monaco editor client-side only (does not behave well server-side)
const MonacoEditor = dynamic(
() => import('./MonacoEditor').then(({ MonacoEditor }) => MonacoEditor),
{ ssr: false }
)
const DiffEditor = dynamic(
() => import('../../ui/DiffEditor').then(({ DiffEditor }) => DiffEditor),
{ ssr: false }
)
const SQLEditorContent = () => {
const {
editorRef,
monacoRef,
diffEditorRef,
scrollTopRef,
refocusEditor,
clearPendingRunRefocus,
markRefocusAfterRun,
getEditorSql: getEditorSqlFromEditor,
} = useSQLEditorContext()
const os = detectOS()
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const tabs = useTabsStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const databaseSelectorState = useDatabaseSelectorStateSnapshot()
// [Ali] Kill switch to hide the SQL Editor Explain tab and its entry points
const disablePrettyExplain = useFlag('DisablePrettyExplainOnSqlEditor')
const diff = useSqlEditorDiff()
const { isDiffOpen, defaultSqlDiff } = diff
const prompt = useSqlEditorPrompt()
const { promptState, setPromptState, promptInput, setPromptInput, resetPrompt } = prompt
const [hasSelection, setHasSelection] = useState<boolean>(false)
const [activeUtilityTab, setActiveUtilityTab] = useState<string>('results')
const { id, urlId, generatedNewSnippetName, isLoading } = useSnippetIdentity()
const { onMount, editorMountCount } = useEditorMount({ id })
useAddDefinitions(id, monacoRef.current)
const { data: databases, isSuccess: isSuccessReadReplicas } = useReadReplicasQuery(
{
projectRef: ref,
},
{ enabled: isValidConnString(project?.connectionString) }
)
const { setAiTitle } = useSnippetTitleGenerator()
const prettifyQuery = usePrettifyQuery({ id, isDiffOpen })
// Reads the SQL to run from the editor as an UntrustedSqlFragment. The
// untrusted→safe promotion (acceptUntrustedSql) happens in the run / explain
// gesture + warning-modal handlers below — as close to the user action as
// possible — before the SQL reaches the execute* pipelines.
const readEditorSql = useCallback((): UntrustedSqlFragment | undefined => {
const snippet = getSqlEditorV2StateSnapshot().snippets[id]
return getEditorSqlFromEditor(snippet?.snippet.content?.unchecked_sql)
}, [getEditorSqlFromEditor, id])
const { executeQuery, isExecuting, potentialIssues, resetPotentialIssues } =
useSqlEditorExecution({
id,
isDiffOpen,
hasSelection,
activeUtilityTab,
setActiveUtilityTab,
setAiTitle,
})
const { executeExplainQuery, isExplainExecuting } = useSqlEditorExplain({
id,
isDiffOpen,
setActiveUtilityTab,
})
// Run/explain gestures. These are the deliberate user actions, so the
// untrusted→safe promotion (acceptUntrustedSql) happens here — as close to the
// event handler as possible — before the SQL reaches the execute* pipelines.
const executeQueryFromButton = useCallback(() => {
markRefocusAfterRun()
refocusEditor()
const sql = readEditorSql()
if (sql === undefined) return clearPendingRunRefocus()
void executeQuery(acceptUntrustedSql(sql))
}, [clearPendingRunRefocus, executeQuery, markRefocusAfterRun, readEditorSql, refocusEditor])
const handleRunShortcut = useCallback(() => {
const sql = readEditorSql()
if (sql !== undefined) void executeQuery(acceptUntrustedSql(sql))
}, [executeQuery, readEditorSql])
const handleRunExplain = useCallback(() => {
const sql = readEditorSql()
if (sql !== undefined) void executeExplainQuery(acceptUntrustedSql(sql))
}, [executeExplainQuery, readEditorSql])
const {
handlePrompt,
acceptAiHandler,
discardAiHandler,
onDebug,
buildDebugPrompt,
handleDiffEditorMount,
isCompletionLoading,
showWidget,
} = useSqlEditorAi({ id, editorMountCount, diff, prompt })
useSqlEditorShortcuts({
isDiffOpen,
isPromptOpen: promptState.isOpen,
disablePrettyExplain,
prettifyQuery,
runExplain: handleRunExplain,
acceptAiHandler,
discardAiHandler,
resetPrompt,
})
/** All useEffects are at the bottom before returning the TSX */
const saveScrollPosition = useEffectEvent((snippetId: string) => {
if (ref) {
const tabId = createTabId('sql', { id: snippetId })
tabs.updateTab(tabId, { scrollTop: scrollTopRef.current })
}
})
useEffect(() => {
// Save the departing snippet's scroll position on unmount / snippet switch.
return () => saveScrollPosition(id)
// Temporary until we update eslint to ignore useEffectEvent
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
useEffect(() => {
if (isSuccessReadReplicas) {
const primaryDatabase = databases.find((db) => db.identifier === ref)
databaseSelectorState.setSelectedDatabaseId(primaryDatabase?.identifier)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccessReadReplicas, databases, ref])
return (
<>
<RunQueryWarningModal
visible={!!potentialIssues}
potentialIssues={potentialIssues}
onCancel={() => {
clearPendingRunRefocus()
resetPotentialIssues()
refocusEditor()
}}
onConfirm={() => {
markRefocusAfterRun()
resetPotentialIssues()
refocusEditor()
// The user has reviewed the warning and confirmed — promote here.
const sql = readEditorSql()
if (sql === undefined) return clearPendingRunRefocus()
void executeQuery(acceptUntrustedSql(sql), true)
}}
onConfirmWithRLS={() => {
const tables = potentialIssues?.createTablesMissingRLS ?? []
if (tables.length === 0) return
const baseSql = readEditorSql() ?? untrustedSql('')
const rewrittenSql = appendEnableRLSStatements(baseSql, tables)
markRefocusAfterRun()
resetPotentialIssues()
refocusEditor()
// The user has reviewed the warning and confirmed — promote here.
void executeQuery(acceptUntrustedSql(untrustedSql(rewrittenSql)), true)
}}
/>
<div className="flex flex-col h-full">
<UtilityActions
id={id}
isExecuting={isExecuting}
isDisabled={isDiffOpen}
hasSelection={hasSelection}
prettifyQuery={prettifyQuery}
executeQuery={executeQueryFromButton}
className="px-4 min-h-[42px] border-b shrink-0"
/>
<ResizablePanelGroup
className="relative flex-1 min-h-0"
orientation="vertical"
autoSaveId={LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE}
>
<ResizablePanel defaultSize="50" maxSize="70">
<div className="grow overflow-y-auto border-b h-full">
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="animate-spin text-brand" />
</div>
) : (
<>
{isDiffOpen && (
<div className="w-full h-full">
<DiffEditor
language="pgsql"
original={defaultSqlDiff.original}
modified={defaultSqlDiff.modified}
onMount={handleDiffEditorMount}
/>
{showWidget && (
<ResizableAIWidget
editor={diffEditorRef.current!}
id="ask-ai-diff"
value={promptInput}
onChange={setPromptInput}
onSubmit={(prompt: string) => {
handlePrompt(prompt, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection || defaultSqlDiff.modified,
afterSelection: promptState.afterSelection,
})
}}
onAccept={acceptAiHandler}
onReject={discardAiHandler}
onCancel={resetPrompt}
isDiffVisible={true}
isLoading={isCompletionLoading}
startLineNumber={Math.max(0, promptState.startLineNumber)}
endLineNumber={promptState.endLineNumber}
/>
)}
</div>
)}
<div key={id} className="w-full h-full relative">
<MonacoEditor
autoFocus
placeholder={
!promptState.isOpen && !editorRef.current?.getValue()
? 'Hit ' +
(os === 'macos' ? 'CMD+SHIFT+K' : `CTRL+SHIFT+K`) +
' to generate query or just start typing'
: ''
}
id={id}
snippetName={
urlId === 'new'
? generatedNewSnippetName
: (snapV2.snippets[id]?.snippet.name ?? generatedNewSnippetName)
}
className={cn(isDiffOpen && 'hidden')}
editorRef={editorRef}
monacoRef={monacoRef}
executeQuery={handleRunShortcut}
executeExplainQuery={handleRunExplain}
showExplainAction={!disablePrettyExplain}
prettifyQuery={prettifyQuery}
onHasSelection={setHasSelection}
onMount={onMount}
onPrompt={({
selection,
beforeSelection,
afterSelection,
startLineNumber,
endLineNumber,
}) => {
setPromptState((prev) => ({
...prev,
isOpen: true,
selection,
beforeSelection,
afterSelection,
startLineNumber,
endLineNumber,
}))
}}
/>
{editorRef.current && promptState.isOpen && !isDiffOpen && (
<ResizableAIWidget
editor={editorRef.current}
id="ask-ai"
value={promptInput}
onChange={setPromptInput}
onSubmit={(prompt: string) => {
handlePrompt(prompt, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection,
afterSelection: promptState.afterSelection,
})
}}
onCancel={resetPrompt}
isDiffVisible={false}
isLoading={isCompletionLoading}
startLineNumber={Math.max(0, promptState.startLineNumber)}
endLineNumber={promptState.endLineNumber}
/>
)}
</div>
</>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="50" maxSize="70">
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="animate-spin text-brand" />
</div>
) : (
<UtilityPanel
id={id}
isExecuting={isExecuting}
isExplainExecuting={isExplainExecuting}
isDisabled={isDiffOpen}
executeExplainQuery={handleRunExplain}
showExplainTab={!disablePrettyExplain}
onDebug={onDebug}
buildDebugPrompt={buildDebugPrompt}
activeTab={activeUtilityTab}
onActiveTabChange={setActiveUtilityTab}
/>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
</>
)
}
import { SQLEditorProvider } from './SQLEditorContext'
import { SQLEditorControllersProvider } from './SQLEditorControllers'
import { SQLEditorLayout } from './SQLEditorLayout'
export const SQLEditor = () => (
<SQLEditorProvider>
<SQLEditorContent />
<SQLEditorControllersProvider>
<SQLEditorLayout />
</SQLEditorControllersProvider>
</SQLEditorProvider>
)
@@ -37,3 +37,6 @@ export type PotentialIssues = {
hasAlterDatabasePreventConnection?: boolean
createTablesMissingRLS?: { schema?: string; tableName: string }[]
}
/** The tabs available in the SQL editor's results/utility panel. */
export type UtilityTab = 'results' | 'explain'
@@ -0,0 +1,264 @@
import { type UntrustedSqlFragment } from '@supabase/pg-meta'
import { useFlag, useParams } from 'common'
import {
createContext,
use,
useCallback,
useEffect,
useEffectEvent,
useMemo,
useState,
type Context,
type Dispatch,
type PropsWithChildren,
type SetStateAction,
} from 'react'
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
import type { UtilityTab } from './SQLEditor.types'
import { useSQLEditorContext } from './SQLEditorContext'
import { useAddDefinitions } from './useAddDefinitions'
import { useEditorMount } from './useEditorMount'
import { usePrettifyQuery } from './usePrettifyQuery'
import { useSnippetIdentity } from './useSnippetIdentity'
import { useSnippetTitleGenerator } from './useSnippetTitleGenerator'
import { useSqlEditorAi } from './useSqlEditorAi'
import { useSqlEditorExecution } from './useSqlEditorExecution'
import { useSqlEditorExplain } from './useSqlEditorExplain'
import { useSqlEditorShortcuts } from './useSqlEditorShortcuts'
import { isValidConnString } from '@/data/fetchers'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
import {
getSqlEditorV2StateSnapshot,
useSqlEditorV2StateSnapshot,
} from '@/state/sql-editor/sql-editor-state'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
/**
* Reactive SQL-editor controllers, distributed via a handful of focused
* contexts so the visual tree consumes exactly the slice it needs instead of
* prop-drilling whole hook bundles. The stable Monaco refs + imperative helpers
* live in `SQLEditorContext`; this provider owns everything that changes with
* state.
*
* SQL that a user runs is NOT promoted to safety here — the provider only
* exposes the already-safe `executeQuery` / `executeExplainQuery` pipelines plus
* `readEditorSql` (which returns an `UntrustedSqlFragment`). Each user-action
* site (the toolbar/editor/results components and the shortcut handlers)
* promotes with `acceptUntrustedSql` right where the user acts, so the promotion
* is auditable.
*/
type SnippetContextValue = {
id: string
snippetName: string
isLoading: boolean
}
type AssistantContextValue = {
diff: ReturnType<typeof useSqlEditorDiff>
prompt: ReturnType<typeof useSqlEditorPrompt>
ai: ReturnType<typeof useSqlEditorAi>
}
type SqlEditorExecution = ReturnType<typeof useSqlEditorExecution>
type SqlEditorExplain = ReturnType<typeof useSqlEditorExplain>
type SqlEditorMount = ReturnType<typeof useEditorMount>
/** Running SQL: the safe execute pipelines, their status, and formatting. */
type RunContextValue = {
executeQuery: SqlEditorExecution['executeQuery']
executeExplainQuery: SqlEditorExplain['executeExplainQuery']
readEditorSql: () => UntrustedSqlFragment | undefined
isExecuting: boolean
isExplainExecuting: boolean
potentialIssues: SqlEditorExecution['potentialIssues']
resetPotentialIssues: () => void
prettifyQuery: () => void
}
/** Editor-surface UI state: selection, the active results tab, and mount. */
type UiContextValue = {
hasSelection: boolean
setHasSelection: Dispatch<SetStateAction<boolean>>
activeUtilityTab: UtilityTab
setActiveUtilityTab: Dispatch<SetStateAction<UtilityTab>>
onMount: SqlEditorMount['onMount']
}
const SnippetContext = createContext<SnippetContextValue | null>(null)
const AssistantContext = createContext<AssistantContextValue | null>(null)
const RunContext = createContext<RunContextValue | null>(null)
const UiContext = createContext<UiContextValue | null>(null)
function useGuardedContext<T>(context: Context<T | null>, hookName: string): T {
const value = use(context)
if (!value) {
throw new Error(`${hookName} must be used within a SQLEditorControllersProvider`)
}
return value
}
/** Snippet identity + display name for the current tab. */
export const useSqlEditorSnippet = () => useGuardedContext(SnippetContext, 'useSqlEditorSnippet')
/** The Assistant / diff cluster controllers (diff, prompt, ai). */
export const useSqlEditorAssistant = () =>
useGuardedContext(AssistantContext, 'useSqlEditorAssistant')
/** Run / explain execution, warnings, and query formatting. */
export const useSqlEditorRun = () => useGuardedContext(RunContext, 'useSqlEditorRun')
/** Editor-surface UI state (selection, active results tab, editor mount). */
export const useSqlEditorUi = () => useGuardedContext(UiContext, 'useSqlEditorUi')
export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => {
const { monacoRef, scrollTopRef, getEditorSql: getEditorSqlFromEditor } = useSQLEditorContext()
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const tabs = useTabsStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const { setSelectedDatabaseId } = useDatabaseSelectorStateSnapshot()
// [Ali] Kill switch to hide the SQL Editor Explain tab and its entry points
const disablePrettyExplain = useFlag('DisablePrettyExplainOnSqlEditor')
const diff = useSqlEditorDiff()
const { isDiffOpen } = diff
const prompt = useSqlEditorPrompt()
const { promptState, resetPrompt } = prompt
const [hasSelection, setHasSelection] = useState<boolean>(false)
const [activeUtilityTab, setActiveUtilityTab] = useState<UtilityTab>('results')
const { id, urlId, generatedNewSnippetName, isLoading } = useSnippetIdentity()
const { onMount, editorMountCount } = useEditorMount({ id })
useAddDefinitions(id, monacoRef.current)
const { data: databases, isSuccess: isSuccessReadReplicas } = useReadReplicasQuery(
{
projectRef: ref,
},
{ enabled: isValidConnString(project?.connectionString) }
)
const { setAiTitle } = useSnippetTitleGenerator()
const prettifyQuery = usePrettifyQuery({ id, isDiffOpen })
// Reads the SQL to run from the editor as an UntrustedSqlFragment. Promotion
// to safety (acceptUntrustedSql) happens at each user-action site, never here.
const readEditorSql = useCallback((): UntrustedSqlFragment | undefined => {
const snippet = getSqlEditorV2StateSnapshot().snippets[id]
return getEditorSqlFromEditor(snippet?.snippet.content?.unchecked_sql)
}, [getEditorSqlFromEditor, id])
const { executeQuery, isExecuting, potentialIssues, resetPotentialIssues } =
useSqlEditorExecution({
id,
isDiffOpen,
hasSelection,
activeUtilityTab,
setActiveUtilityTab,
setAiTitle,
})
const { executeExplainQuery, isExplainExecuting } = useSqlEditorExplain({
id,
isDiffOpen,
setActiveUtilityTab,
})
const ai = useSqlEditorAi({ id, editorMountCount, diff, prompt })
const { acceptAiHandler, discardAiHandler } = ai
useSqlEditorShortcuts({
isDiffOpen,
isPromptOpen: promptState.isOpen,
disablePrettyExplain,
prettifyQuery,
readEditorSql,
executeExplainQuery,
acceptAiHandler,
discardAiHandler,
resetPrompt,
})
const saveScrollPosition = useEffectEvent((snippetId: string) => {
if (ref) {
const tabId = createTabId('sql', { id: snippetId })
tabs.updateTab(tabId, { scrollTop: scrollTopRef.current })
}
})
useEffect(() => {
// Save the departing snippet's scroll position on unmount / snippet switch.
return () => saveScrollPosition(id)
// Temporary until we update eslint to ignore useEffectEvent
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
useEffect(() => {
if (isSuccessReadReplicas) {
const primaryDatabase = databases.find((db) => db.identifier === ref)
setSelectedDatabaseId(primaryDatabase?.identifier)
}
}, [isSuccessReadReplicas, databases, ref, setSelectedDatabaseId])
const snippetName =
urlId === 'new'
? generatedNewSnippetName
: (snapV2.snippets[id]?.snippet.name ?? generatedNewSnippetName)
const snippetValue = useMemo<SnippetContextValue>(
() => ({ id, snippetName, isLoading }),
[id, snippetName, isLoading]
)
const assistantValue = useMemo<AssistantContextValue>(
() => ({ diff, prompt, ai }),
[diff, prompt, ai]
)
const runValue = useMemo<RunContextValue>(
() => ({
executeQuery,
executeExplainQuery,
readEditorSql,
isExecuting,
isExplainExecuting,
potentialIssues,
resetPotentialIssues,
prettifyQuery,
}),
[
executeQuery,
executeExplainQuery,
readEditorSql,
isExecuting,
isExplainExecuting,
potentialIssues,
resetPotentialIssues,
prettifyQuery,
]
)
const uiValue = useMemo<UiContextValue>(
() => ({ hasSelection, setHasSelection, activeUtilityTab, setActiveUtilityTab, onMount }),
[hasSelection, activeUtilityTab, onMount]
)
return (
<SnippetContext.Provider value={snippetValue}>
<AssistantContext.Provider value={assistantValue}>
<RunContext.Provider value={runValue}>
<UiContext.Provider value={uiValue}>{children}</UiContext.Provider>
</RunContext.Provider>
</AssistantContext.Provider>
</SnippetContext.Provider>
)
}
@@ -0,0 +1,189 @@
import { acceptUntrustedSql } from '@supabase/pg-meta'
import { useFlag } from 'common'
import { Loader2 } from 'lucide-react'
import dynamic from 'next/dynamic'
import { useCallback } from 'react'
import { cn } from 'ui'
import { useSQLEditorContext } from './SQLEditorContext'
import {
useSqlEditorAssistant,
useSqlEditorRun,
useSqlEditorSnippet,
useSqlEditorUi,
} from './SQLEditorControllers'
import ResizableAIWidget from '@/components/ui/AIEditor/ResizableAIWidget'
import { detectOS } from '@/lib/helpers'
// Load the monaco editor client-side only (does not behave well server-side)
const MonacoEditor = dynamic(
() => import('./MonacoEditor').then(({ MonacoEditor }) => MonacoEditor),
{ ssr: false }
)
const DiffEditor = dynamic(
() => import('../../ui/DiffEditor').then(({ DiffEditor }) => DiffEditor),
{ ssr: false }
)
const generatePlaceholder = (os: string | undefined) =>
`Hit ${os === 'macos' ? 'CMD+SHIFT+K' : 'CTRL+SHIFT+K'} to generate query or just start typing`
/**
* The ask-AI widget anchored to the diff editor's modified pane. Reads the diff
* editor ref from context and assembles the prompt context from the current
* selection.
*/
const DiffPromptWidget = () => {
const { diffEditorRef } = useSQLEditorContext()
const { diff, prompt, ai } = useSqlEditorAssistant()
const { defaultSqlDiff } = diff
const { promptState, promptInput, setPromptInput, resetPrompt } = prompt
const { handlePrompt, acceptAiHandler, discardAiHandler, isCompletionLoading } = ai
return (
<ResizableAIWidget
editor={diffEditorRef.current!}
id="ask-ai-diff"
value={promptInput}
onChange={setPromptInput}
onSubmit={(value: string) => {
handlePrompt(value, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection || defaultSqlDiff.modified,
afterSelection: promptState.afterSelection,
})
}}
onAccept={acceptAiHandler}
onReject={discardAiHandler}
onCancel={resetPrompt}
isDiffVisible={true}
isLoading={isCompletionLoading}
startLineNumber={Math.max(0, promptState.startLineNumber)}
endLineNumber={promptState.endLineNumber}
/>
)
}
/** The diff editor + its ask-AI widget, shown while a diff is open. */
const SQLEditorDiffView = () => {
const { diff, ai } = useSqlEditorAssistant()
const { defaultSqlDiff } = diff
const { handleDiffEditorMount, showWidget } = ai
return (
<div className="w-full h-full">
<DiffEditor
language="pgsql"
original={defaultSqlDiff.original}
modified={defaultSqlDiff.modified}
onMount={handleDiffEditorMount}
/>
{showWidget && <DiffPromptWidget />}
</div>
)
}
/** The ask-AI widget anchored to the main editor. */
const MainPromptWidget = () => {
const { editorRef } = useSQLEditorContext()
const { prompt, ai } = useSqlEditorAssistant()
const { promptState, promptInput, setPromptInput, resetPrompt } = prompt
const { handlePrompt, isCompletionLoading } = ai
return (
<ResizableAIWidget
editor={editorRef.current!}
id="ask-ai"
value={promptInput}
onChange={setPromptInput}
onSubmit={(value: string) => {
handlePrompt(value, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection,
afterSelection: promptState.afterSelection,
})
}}
onCancel={resetPrompt}
isDiffVisible={false}
isLoading={isCompletionLoading}
startLineNumber={Math.max(0, promptState.startLineNumber)}
endLineNumber={promptState.endLineNumber}
/>
)
}
/**
* The main Monaco editor + its ask-AI widget. Always mounted (hidden behind the
* diff editor while a diff is open) so the editor instance and its model survive
* the diff lifecycle. The `editorRef.current` reads in the placeholder and the
* widget gate happen during render and stay fresh because this component
* subscribes to `promptState` / `isDiffOpen` via context.
*/
const SQLEditorMainView = () => {
const { editorRef, monacoRef } = useSQLEditorContext()
const { id, snippetName } = useSqlEditorSnippet()
const { diff, prompt } = useSqlEditorAssistant()
const { isDiffOpen } = diff
const { promptState, openPrompt } = prompt
const { executeQuery, executeExplainQuery, readEditorSql, prettifyQuery } = useSqlEditorRun()
const { onMount, setHasSelection } = useSqlEditorUi()
const os = detectOS()
const showExplainAction = !useFlag('DisablePrettyExplainOnSqlEditor')
// Run/explain gestures from the editor — promote here, at the user action.
const runQuery = useCallback(() => {
const sql = readEditorSql()
if (sql !== undefined) void executeQuery(acceptUntrustedSql(sql))
}, [executeQuery, readEditorSql])
const runExplain = useCallback(() => {
const sql = readEditorSql()
if (sql !== undefined) void executeExplainQuery(acceptUntrustedSql(sql))
}, [executeExplainQuery, readEditorSql])
return (
<div key={id} className="w-full h-full relative">
<MonacoEditor
autoFocus
placeholder={
!promptState.isOpen && !editorRef.current?.getValue() ? generatePlaceholder(os) : ''
}
id={id}
snippetName={snippetName}
className={cn(isDiffOpen && 'hidden')}
editorRef={editorRef}
monacoRef={monacoRef}
executeQuery={runQuery}
executeExplainQuery={runExplain}
showExplainAction={showExplainAction}
prettifyQuery={prettifyQuery}
onHasSelection={setHasSelection}
onMount={onMount}
onPrompt={openPrompt}
/>
{editorRef.current && promptState.isOpen && !isDiffOpen && <MainPromptWidget />}
</div>
)
}
/** The top (editor) resizable panel: loading state, diff view, and main editor. */
export const SQLEditorEditorPanel = () => {
const { isLoading } = useSqlEditorSnippet()
const { diff } = useSqlEditorAssistant()
return (
<div className="grow overflow-y-auto border-b h-full">
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="animate-spin text-brand" />
</div>
) : (
<>
{diff.isDiffOpen && <SQLEditorDiffView />}
<SQLEditorMainView />
</>
)}
</div>
)
}
@@ -0,0 +1,146 @@
import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta'
import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
import { Loader2 } from 'lucide-react'
import { useCallback } from 'react'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
import { RunQueryWarningModal } from './RunQueryWarningModal'
import type { UtilityTab } from './SQLEditor.types'
import { appendEnableRLSStatements } from './SQLEditor.utils'
import { useSQLEditorContext } from './SQLEditorContext'
import {
useSqlEditorAssistant,
useSqlEditorRun,
useSqlEditorSnippet,
useSqlEditorUi,
} from './SQLEditorControllers'
import { SQLEditorEditorPanel } from './SQLEditorEditorPanel'
import { UtilityActions } from './UtilityPanel/UtilityActions'
import { UtilityPanel } from './UtilityPanel/UtilityPanel'
const SQLEditorRunWarningModal = () => {
const { refocusEditor, clearPendingRunRefocus, markRefocusAfterRun } = useSQLEditorContext()
const { potentialIssues, resetPotentialIssues, executeQuery, readEditorSql } = useSqlEditorRun()
return (
<RunQueryWarningModal
visible={!!potentialIssues}
potentialIssues={potentialIssues}
onCancel={() => {
clearPendingRunRefocus()
resetPotentialIssues()
refocusEditor()
}}
onConfirm={() => {
markRefocusAfterRun()
resetPotentialIssues()
const sql = readEditorSql()
if (sql === undefined) return clearPendingRunRefocus()
// The user has reviewed the warning and confirmed — promote here.
void executeQuery(acceptUntrustedSql(sql), true)
}}
onConfirmWithRLS={() => {
const tables = potentialIssues?.createTablesMissingRLS ?? []
if (tables.length === 0) return
const baseSql = readEditorSql() ?? untrustedSql('')
const rewrittenSql = appendEnableRLSStatements(baseSql, tables)
markRefocusAfterRun()
resetPotentialIssues()
// The user has reviewed the warning and confirmed — promote here.
void executeQuery(acceptUntrustedSql(untrustedSql(rewrittenSql)), true)
}}
/>
)
}
/** The top-bar controls (run / prettify / etc.). */
const SQLEditorToolbar = () => {
const { clearPendingRunRefocus, markRefocusAfterRun } = useSQLEditorContext()
const { id } = useSqlEditorSnippet()
const { diff } = useSqlEditorAssistant()
const { executeQuery, readEditorSql, isExecuting, prettifyQuery } = useSqlEditorRun()
const { hasSelection } = useSqlEditorUi()
// Run gesture from the toolbar button — promote here, at the user action.
const runQuery = useCallback(() => {
markRefocusAfterRun()
const sql = readEditorSql()
if (sql === undefined) return clearPendingRunRefocus()
void executeQuery(acceptUntrustedSql(sql))
}, [clearPendingRunRefocus, executeQuery, markRefocusAfterRun, readEditorSql])
return (
<UtilityActions
id={id}
isExecuting={isExecuting}
isDisabled={diff.isDiffOpen}
hasSelection={hasSelection}
prettifyQuery={prettifyQuery}
executeQuery={runQuery}
className="px-4 min-h-[42px] border-b shrink-0"
/>
)
}
/** The bottom (results) resizable panel: loading state + utility panel. */
const SQLEditorResultsPanel = () => {
const { id, isLoading } = useSqlEditorSnippet()
const { diff, ai } = useSqlEditorAssistant()
const { executeExplainQuery, readEditorSql, isExecuting, isExplainExecuting } = useSqlEditorRun()
const { activeUtilityTab, setActiveUtilityTab } = useSqlEditorUi()
const showExplainTab = !useFlag('DisablePrettyExplainOnSqlEditor')
// Explain gesture from the results panel — promote here, at the user action.
const runExplain = useCallback(() => {
const sql = readEditorSql()
if (sql !== undefined) void executeExplainQuery(acceptUntrustedSql(sql))
}, [executeExplainQuery, readEditorSql])
return isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="animate-spin text-brand" />
</div>
) : (
<UtilityPanel
id={id}
isExecuting={isExecuting}
isExplainExecuting={isExplainExecuting}
isDisabled={diff.isDiffOpen}
executeExplainQuery={runExplain}
showExplainTab={showExplainTab}
onDebug={ai.onDebug}
buildDebugPrompt={ai.buildDebugPrompt}
activeTab={activeUtilityTab}
onActiveTabChange={(tab) => setActiveUtilityTab(tab as UtilityTab)}
/>
)
}
/** The SQL editor shell: warning modal, toolbar, and the editor/results split. */
export const SQLEditorLayout = () => {
return (
<>
<SQLEditorRunWarningModal />
<div className="flex flex-col h-full">
<SQLEditorToolbar />
<ResizablePanelGroup
className="relative flex-1 min-h-0"
orientation="vertical"
autoSaveId={LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE}
>
<ResizablePanel defaultSize="50" maxSize="70">
<SQLEditorEditorPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="50" maxSize="70">
<SQLEditorResultsPanel />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</>
)
}
@@ -95,17 +95,20 @@ export function useSqlEditorDiff() {
setSelectedDiffType(undefined)
}, [])
return {
sourceSqlDiff,
setSourceSqlDiff,
selectedDiffType,
setSelectedDiffType,
isAcceptDiffLoading,
setIsAcceptDiffLoading,
isDiffOpen,
defaultSqlDiff,
closeDiff,
}
return useMemo(
() => ({
sourceSqlDiff,
setSourceSqlDiff,
selectedDiffType,
setSelectedDiffType,
isAcceptDiffLoading,
setIsAcceptDiffLoading,
isDiffOpen,
defaultSqlDiff,
closeDiff,
}),
[sourceSqlDiff, selectedDiffType, isAcceptDiffLoading, isDiffOpen, defaultSqlDiff, closeDiff]
)
}
interface PromptState {
@@ -136,16 +139,24 @@ export function useSqlEditorPrompt() {
}
}, [promptState.isOpen])
const resetPrompt = () => {
const resetPrompt = useCallback(() => {
setPromptState(initialPromptState)
setPromptInput('')
}
}, [])
return {
promptState,
setPromptState,
promptInput,
setPromptInput,
resetPrompt,
}
const openPrompt = useCallback((context: Omit<PromptState, 'isOpen'>) => {
setPromptState((prev) => ({ ...prev, isOpen: true, ...context }))
}, [])
return useMemo(
() => ({
promptState,
setPromptState,
promptInput,
setPromptInput,
resetPrompt,
openPrompt,
}),
[promptState, promptInput, resetPrompt, openPrompt]
)
}
@@ -1,8 +1,9 @@
import { useState } from 'react'
import { useCallback, useContext, useState } from 'react'
import { snapshot } from 'valtio'
import type { IStandaloneCodeEditor } from './SQLEditor.types'
import { useSQLEditorContext } from './SQLEditorContext'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
import { createTabId, TabsStateContext } from '@/state/tabs'
/**
* Owns the editor `onMount` handler (scroll-position restore + tracking) and the
@@ -11,26 +12,31 @@ import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
*/
export function useEditorMount({ id }: { id: string }) {
const { scrollTopRef } = useSQLEditorContext()
const tabs = useTabsStateSnapshot()
// The proxy store (stable reference), read non-reactively in onMount so the
// callback identity doesn't churn on every tab-state change.
const tabsState = useContext(TabsStateContext)
// Bumped on every editor mount (including the keyed remount on snippet switch)
// so a diff request that arrived before the editor was ready gets re-processed.
const [editorMountCount, setEditorMountCount] = useState(0)
const onMount = (editor: IStandaloneCodeEditor) => {
setEditorMountCount((count) => count + 1)
const onMount = useCallback(
(editor: IStandaloneCodeEditor) => {
setEditorMountCount((count) => count + 1)
const tabId = createTabId('sql', { id })
const tabData = tabs.tabsMap[tabId]
const tabId = createTabId('sql', { id })
const tabData = snapshot(tabsState).tabsMap[tabId]
// [Joshen] Tiny timeout to give a bit of time for the content to load before scrolling
setTimeout(() => {
if (tabData?.metadata?.scrollTop) {
editor.setScrollTop(tabData.metadata.scrollTop)
}
}, 20)
editor.onDidScrollChange((e) => (scrollTopRef.current = e.scrollTop))
}
// [Joshen] Tiny timeout to give a bit of time for the content to load before scrolling
setTimeout(() => {
if (tabData?.metadata?.scrollTop) {
editor.setScrollTop(tabData.metadata.scrollTop)
}
}, 20)
editor.onDidScrollChange((e) => (scrollTopRef.current = e.scrollTop))
},
[id, scrollTopRef, tabsState]
)
return { onMount, editorMountCount }
}
@@ -1,6 +1,6 @@
import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useEffectEvent, useState } from 'react'
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react'
import { toast } from 'sonner'
import type { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
@@ -55,7 +55,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
} = diff
const { promptState, setPromptState, resetPrompt } = prompt
const { editorRef, diffEditorRef } = useSQLEditorContext()
const { editorRef, diffEditorRef, refocusEditor } = useSQLEditorContext()
const router = useRouter()
const { ref } = useParams()
@@ -163,6 +163,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
setSelectedDiffType(DiffType.Modification)
resetPrompt()
closeDiff()
refocusEditor()
} finally {
setIsAcceptDiffLoading(false)
}
@@ -178,13 +179,15 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
setSelectedDiffType,
resetPrompt,
closeDiff,
refocusEditor,
])
const discardAiHandler = useCallback(() => {
track('assistant_sql_diff_handler_evaluated', { handlerAccepted: false })
resetPrompt()
closeDiff()
}, [closeDiff, resetPrompt, track])
refocusEditor()
}, [closeDiff, resetPrompt, track, refocusEditor])
const complete = useCallback(
async (
@@ -244,46 +247,54 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
]
)
const handlePrompt = async (
prompt: string,
context: {
beforeSelection: string
selection: string
afterSelection: string
}
) => {
try {
setPromptState((prev) => ({
...prev,
selection: context.selection,
beforeSelection: context.beforeSelection,
afterSelection: context.afterSelection,
}))
const headerData = await constructHeaders()
const handlePrompt = useCallback(
async (
prompt: string,
context: {
beforeSelection: string
selection: string
afterSelection: string
}
) => {
try {
setPromptState((prev) => ({
...prev,
selection: context.selection,
beforeSelection: context.beforeSelection,
afterSelection: context.afterSelection,
}))
const headerData = await constructHeaders()
const authorizationHeader = headerData.get('Authorization')
const authorizationHeader = headerData.get('Authorization')
await complete(prompt, {
...(authorizationHeader ? { headers: { Authorization: authorizationHeader } } : undefined),
body: {
completionMetadata: {
textBeforeCursor: context.beforeSelection,
textAfterCursor: context.afterSelection,
language: 'pgsql',
prompt,
selection: context.selection,
await complete(prompt, {
...(authorizationHeader
? { headers: { Authorization: authorizationHeader } }
: undefined),
body: {
completionMetadata: {
textBeforeCursor: context.beforeSelection,
textAfterCursor: context.afterSelection,
language: 'pgsql',
prompt,
selection: context.selection,
},
},
},
})
} catch (error) {
setPromptState((prev) => ({ ...prev, isLoading: false }))
}
}
})
} catch (error) {
setPromptState((prev) => ({ ...prev, isLoading: false }))
}
},
[complete, setPromptState]
)
const handleDiffEditorMount = (editor: IStandaloneDiffEditor) => {
diffEditorRef.current = editor
setIsDiffEditorMounted(true)
}
const handleDiffEditorMount = useCallback(
(editor: IStandaloneDiffEditor) => {
diffEditorRef.current = editor
setIsDiffEditorMounted(true)
},
[diffEditorRef]
)
const resetDiff = useEffectEvent(() => {
if (id) {
@@ -360,14 +371,26 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
}
}, [diffEditorRef, isDiffOpen, isDiffEditorMounted])
return {
handlePrompt,
acceptAiHandler,
discardAiHandler,
onDebug,
buildDebugPrompt,
handleDiffEditorMount,
isCompletionLoading,
showWidget,
}
return useMemo(
() => ({
handlePrompt,
acceptAiHandler,
discardAiHandler,
onDebug,
buildDebugPrompt,
handleDiffEditorMount,
isCompletionLoading,
showWidget,
}),
[
handlePrompt,
acceptAiHandler,
discardAiHandler,
onDebug,
buildDebugPrompt,
handleDiffEditorMount,
isCompletionLoading,
showWidget,
]
)
}
@@ -5,7 +5,7 @@ import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import { untitledSnippetTitle } from './SQLEditor.constants'
import type { PotentialIssues } from './SQLEditor.types'
import type { PotentialIssues, UtilityTab } from './SQLEditor.types'
import {
checkAlterDatabaseConnection,
checkDestructiveQuery,
@@ -39,8 +39,8 @@ type UseSqlEditorExecutionArgs = {
id: string
isDiffOpen: boolean
hasSelection: boolean
activeUtilityTab: string
setActiveUtilityTab: (tab: string) => void
activeUtilityTab: UtilityTab
setActiveUtilityTab: (tab: UtilityTab) => void
setAiTitle: (id: string, sql: string) => void
}
@@ -3,6 +3,7 @@ import { useParams } from 'common'
import { useCallback } from 'react'
import { toast } from 'sonner'
import type { UtilityTab } from './SQLEditor.types'
import { buildExplainSql } from './SQLEditor.utils'
import { useSQLEditorContext } from './SQLEditorContext'
import { splitSqlStatements } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils'
@@ -20,7 +21,7 @@ import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-sessi
type UseSqlEditorExplainArgs = {
id: string
isDiffOpen: boolean
setActiveUtilityTab: (tab: string) => void
setActiveUtilityTab: (tab: UtilityTab) => void
}
export function useSqlEditorExplain({
@@ -1,3 +1,8 @@
import {
acceptUntrustedSql,
type SafeSqlFragment,
type UntrustedSqlFragment,
} from '@supabase/pg-meta'
import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useCallback, useEffect } from 'react'
@@ -12,7 +17,8 @@ type UseSqlEditorShortcutsArgs = {
isPromptOpen: boolean
disablePrettyExplain: boolean
prettifyQuery: () => void
runExplain: () => void
readEditorSql: () => UntrustedSqlFragment | undefined
executeExplainQuery: (sql: SafeSqlFragment) => unknown
acceptAiHandler: () => void
discardAiHandler: () => void
resetPrompt: () => void
@@ -28,7 +34,8 @@ export function useSqlEditorShortcuts({
isPromptOpen,
disablePrettyExplain,
prettifyQuery,
runExplain,
readEditorSql,
executeExplainQuery,
acceptAiHandler,
discardAiHandler,
resetPrompt,
@@ -46,6 +53,15 @@ export function useSqlEditorShortcuts({
router.push(`/project/${ref}/sql/new?skip=true`)
}, [ref, router])
// The Explain keyboard shortcut is an explicit user action, so the
// untrusted→safe promotion (acceptUntrustedSql) happens here in the shortcut
// handler itself — right next to where it is registered — before the SQL
// reaches the explain pipeline.
const runExplainShortcut = useCallback(() => {
const sql = readEditorSql()
if (sql !== undefined) void executeExplainQuery(acceptUntrustedSql(sql))
}, [executeExplainQuery, readEditorSql])
useShortcut(SHORTCUT_IDS.SQL_EDITOR_FOCUS_EDITOR, refocusEditor, {
registerInCommandMenu: true,
})
@@ -58,7 +74,7 @@ export function useSqlEditorShortcuts({
registerInCommandMenu: true,
})
useShortcut(SHORTCUT_IDS.SQL_EDITOR_EXPLAIN, runExplain, {
useShortcut(SHORTCUT_IDS.SQL_EDITOR_EXPLAIN, runExplainShortcut, {
enabled: !disablePrettyExplain,
registerInCommandMenu: true,
})