mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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,
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user