From 9e80a159b5fed01cd2ac338f9d920e471a3b84ca Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:10:09 -0400 Subject: [PATCH] refactor(sql-editor): decompose into controller contexts + presentational components (decompose 6/6) (#47938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. ## 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. --- .../interfaces/SQLEditor/SQLEditor.tsx | 373 +----------------- .../interfaces/SQLEditor/SQLEditor.types.ts | 3 + .../SQLEditor/SQLEditorControllers.tsx | 264 +++++++++++++ .../SQLEditor/SQLEditorEditorPanel.tsx | 189 +++++++++ .../interfaces/SQLEditor/SQLEditorLayout.tsx | 146 +++++++ .../components/interfaces/SQLEditor/hooks.ts | 51 ++- .../interfaces/SQLEditor/useEditorMount.ts | 36 +- .../interfaces/SQLEditor/useSqlEditorAi.ts | 121 +++--- .../SQLEditor/useSqlEditorExecution.ts | 6 +- .../SQLEditor/useSqlEditorExplain.ts | 3 +- .../SQLEditor/useSqlEditorShortcuts.ts | 22 +- 11 files changed, 756 insertions(+), 458 deletions(-) create mode 100644 apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index aa10baf4cf8..34f4efdcbef 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -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(false) - const [activeUtilityTab, setActiveUtilityTab] = useState('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 ( - <> - { - 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) - }} - /> - -
- - - -
- {isLoading ? ( -
- -
- ) : ( - <> - {isDiffOpen && ( -
- - {showWidget && ( - { - 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} - /> - )} -
- )} -
- { - setPromptState((prev) => ({ - ...prev, - isOpen: true, - selection, - beforeSelection, - afterSelection, - startLineNumber, - endLineNumber, - })) - }} - /> - {editorRef.current && promptState.isOpen && !isDiffOpen && ( - { - 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} - /> - )} -
- - )} -
-
- - - - - {isLoading ? ( -
- -
- ) : ( - - )} -
-
-
- - ) -} +import { SQLEditorProvider } from './SQLEditorContext' +import { SQLEditorControllersProvider } from './SQLEditorControllers' +import { SQLEditorLayout } from './SQLEditorLayout' export const SQLEditor = () => ( - + + + ) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts index 185196f6fcc..8c87cea61a1 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts @@ -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' diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx new file mode 100644 index 00000000000..44b0998b55d --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx @@ -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 + prompt: ReturnType + ai: ReturnType +} + +type SqlEditorExecution = ReturnType +type SqlEditorExplain = ReturnType +type SqlEditorMount = ReturnType + +/** 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> + activeUtilityTab: UtilityTab + setActiveUtilityTab: Dispatch> + onMount: SqlEditorMount['onMount'] +} + +const SnippetContext = createContext(null) +const AssistantContext = createContext(null) +const RunContext = createContext(null) +const UiContext = createContext(null) + +function useGuardedContext(context: Context, 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(false) + const [activeUtilityTab, setActiveUtilityTab] = useState('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( + () => ({ id, snippetName, isLoading }), + [id, snippetName, isLoading] + ) + + const assistantValue = useMemo( + () => ({ diff, prompt, ai }), + [diff, prompt, ai] + ) + + const runValue = useMemo( + () => ({ + executeQuery, + executeExplainQuery, + readEditorSql, + isExecuting, + isExplainExecuting, + potentialIssues, + resetPotentialIssues, + prettifyQuery, + }), + [ + executeQuery, + executeExplainQuery, + readEditorSql, + isExecuting, + isExplainExecuting, + potentialIssues, + resetPotentialIssues, + prettifyQuery, + ] + ) + + const uiValue = useMemo( + () => ({ hasSelection, setHasSelection, activeUtilityTab, setActiveUtilityTab, onMount }), + [hasSelection, activeUtilityTab, onMount] + ) + + return ( + + + + {children} + + + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx new file mode 100644 index 00000000000..bc29180ca05 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx @@ -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 ( + { + 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 ( +
+ + {showWidget && } +
+ ) +} + +/** 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 ( + { + 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 ( +
+ + {editorRef.current && promptState.isOpen && !isDiffOpen && } +
+ ) +} + +/** The top (editor) resizable panel: loading state, diff view, and main editor. */ +export const SQLEditorEditorPanel = () => { + const { isLoading } = useSqlEditorSnippet() + const { diff } = useSqlEditorAssistant() + + return ( +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {diff.isDiffOpen && } + + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx new file mode 100644 index 00000000000..85bc92e3271 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx @@ -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 ( + { + 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 ( + + ) +} + +/** 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 ? ( +
+ +
+ ) : ( + setActiveUtilityTab(tab as UtilityTab)} + /> + ) +} + +/** The SQL editor shell: warning modal, toolbar, and the editor/results split. */ +export const SQLEditorLayout = () => { + return ( + <> + + +
+ + + + + + + + + + + + +
+ + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/hooks.ts b/apps/studio/components/interfaces/SQLEditor/hooks.ts index 7c7b2e391ce..3f531c4aba4 100644 --- a/apps/studio/components/interfaces/SQLEditor/hooks.ts +++ b/apps/studio/components/interfaces/SQLEditor/hooks.ts @@ -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) => { + setPromptState((prev) => ({ ...prev, isOpen: true, ...context })) + }, []) + + return useMemo( + () => ({ + promptState, + setPromptState, + promptInput, + setPromptInput, + resetPrompt, + openPrompt, + }), + [promptState, promptInput, resetPrompt, openPrompt] + ) } diff --git a/apps/studio/components/interfaces/SQLEditor/useEditorMount.ts b/apps/studio/components/interfaces/SQLEditor/useEditorMount.ts index 12714c2e400..c5ff808e135 100644 --- a/apps/studio/components/interfaces/SQLEditor/useEditorMount.ts +++ b/apps/studio/components/interfaces/SQLEditor/useEditorMount.ts @@ -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 } } diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts index d49d4fa036c..ca0fec82805 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts @@ -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, + ] + ) } diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts index 2eadbbf5dba..00a49baf654 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts @@ -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 } diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExplain.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExplain.ts index 48386d6e400..32c4cfe8f00 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExplain.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExplain.ts @@ -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({ diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts index 4f119980d36..19204b6b1c4 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts @@ -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, })