Files
Charis 9e80a159b5 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 -->
2026-07-15 13:10:09 -04:00

163 lines
4.4 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { ContentDiff, DiffType } from './SQLEditor.types'
import {
compareAsAddition,
compareAsModification,
compareAsNewSnippet,
createSqlSnippetSkeletonV2,
} from './SQLEditor.utils'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useProfile } from '@/lib/profile'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
export const useNewQuery = () => {
const router = useRouter()
const { ref } = useParams()
const { profile } = useProfile()
const { data: project } = useSelectedProjectQuery()
const snapV2 = useSqlEditorV2StateSnapshot()
const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
PermissionAction.CREATE,
'user_content',
{
resource: { type: 'sql', owner_id: profile?.id },
subject: { id: profile?.id },
}
)
const newQuery = async (sql: string, name: string, shouldRedirect: boolean = true) => {
if (!ref) return console.error('Project ref is required')
if (!project) return console.error('Project is required')
if (!profile) return console.error('Profile is required')
if (!canCreateSQLSnippet) {
toast('Your queries will not be saved as you do not have sufficient permissions')
return undefined
}
try {
const snippet = createSqlSnippetSkeletonV2({
name,
sql,
owner_id: profile?.id,
project_id: project?.id,
})
snapV2.addSnippet({ projectRef: ref, snippet })
snapV2.addNeedsSaving(snippet.id)
if (shouldRedirect) {
router.push(`/project/${ref}/sql/${snippet.id}`)
return undefined
} else {
return snippet.id
}
} catch (error: any) {
toast.error(`Failed to create new query: ${error.message}`)
return undefined
}
}
return { newQuery }
}
export function useSqlEditorDiff() {
const [sourceSqlDiff, setSourceSqlDiff] = useState<ContentDiff>()
const [selectedDiffType, setSelectedDiffType] = useState<DiffType>()
const [isAcceptDiffLoading, setIsAcceptDiffLoading] = useState(false)
const isDiffOpen = !!sourceSqlDiff
const defaultSqlDiff = useMemo(() => {
if (!sourceSqlDiff) {
return { original: '', modified: '' }
}
switch (selectedDiffType) {
case DiffType.Modification:
return compareAsModification(sourceSqlDiff)
case DiffType.Addition:
return compareAsAddition(sourceSqlDiff)
case DiffType.NewSnippet:
return compareAsNewSnippet(sourceSqlDiff)
default:
return { original: '', modified: '' }
}
}, [selectedDiffType, sourceSqlDiff])
const closeDiff = useCallback(() => {
setSourceSqlDiff(undefined)
setSelectedDiffType(undefined)
}, [])
return useMemo(
() => ({
sourceSqlDiff,
setSourceSqlDiff,
selectedDiffType,
setSelectedDiffType,
isAcceptDiffLoading,
setIsAcceptDiffLoading,
isDiffOpen,
defaultSqlDiff,
closeDiff,
}),
[sourceSqlDiff, selectedDiffType, isAcceptDiffLoading, isDiffOpen, defaultSqlDiff, closeDiff]
)
}
interface PromptState {
isOpen: boolean
selection: string
beforeSelection: string
afterSelection: string
startLineNumber: number
endLineNumber: number
}
const initialPromptState: PromptState = {
isOpen: false,
selection: '',
beforeSelection: '',
afterSelection: '',
startLineNumber: 0,
endLineNumber: 0,
}
export function useSqlEditorPrompt() {
const [promptState, setPromptState] = useState<PromptState>(initialPromptState)
const [promptInput, setPromptInput] = useState('')
useEffect(() => {
if (!promptState.isOpen) {
setPromptInput('')
}
}, [promptState.isOpen])
const resetPrompt = useCallback(() => {
setPromptState(initialPromptState)
setPromptInput('')
}, [])
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]
)
}