Files
supabase/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts
Charis 3d83e026f9 refactor(sql-editor): finish EditorController/DiffController port (Step 2) (#48166)
## Summary
Step 2 of the SQL Editor testability plan.

`SQLEditorContext` already wrapped the Monaco refs and exposed a few
semantic imperative helpers (`getEditorSql`, `clearHighlights`,
`applyErrorHighlight`, `refocusEditor`, …). This finishes that
abstraction so no hook or controller touches
`editorRef.current`/`diffEditorRef.current` directly anymore — they only
call the port. The port is what will let Step 3's test harness inject a
real in-memory editor adapter instead of mocking Monaco; production
wires it to the real Monaco refs, unchanged.

- Extends the context value with two semantic controllers, backed by the
existing refs:
- `editor: EditorController` — `isReady`, `getValue`,
`getSelectionStartLine`, `getSql` (today's `getEditorSql`), `replaceAll`
(wraps the repeated `executeEdits(...)` pattern), `focus`,
`revealLineInCenter`, `highlightErrorLine` (today's
`applyErrorHighlight`), `clearHighlights`.
- `diff: DiffController` — `isMounted`, `getModifiedValue`, `setDiff`
(the diff-sync effect body), `attach` (today's `handleDiffEditorMount`).
- Migrates every touch point off raw refs onto the port:
`useSqlEditorExecution`, `usePrettifyQuery`, `useSqlEditorShortcuts`,
`SQLEditorControllers`' `readEditorSql`, and `useSqlEditorAi`'s
`acceptAiHandler`/`drainDiffRequest`/`handleDiffEditorMount`/diff-sync
effect.
- `SQLEditorEditorPanel.tsx` is intentionally left untouched — it wires
the raw refs into the real Monaco/DiffEditor React components for
rendering, which isn't decision logic to abstract.

Behavior-preserving.

## Test plan
- [x] `pnpm --filter studio typecheck`
- [x] `pnpm test:studio -- SQLEditor` (265 tests passing)
- [x] `pnpm --filter studio run lint:ratchet`
2026-07-21 16:22:47 -04:00

80 lines
2.4 KiB
TypeScript

import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useCallback, useEffect } from 'react'
import { resolveDiffKeyAction } from './SQLEditor.utils'
import { useSQLEditorContext } from './SQLEditorContext'
import { detectOS } from '@/lib/helpers'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
type UseSqlEditorShortcutsArgs = {
isDiffOpen: boolean
isPromptOpen: boolean
prettifyQuery: () => void
acceptAiHandler: () => void
discardAiHandler: () => void
resetPrompt: () => void
}
/**
* Registers the SQL editor's keyboard shortcuts (focus editor, new snippet,
* format) plus the window keydown that accepts/discards an open AI diff
* or dismisses the prompt.
*/
export function useSqlEditorShortcuts({
isDiffOpen,
isPromptOpen,
prettifyQuery,
acceptAiHandler,
discardAiHandler,
resetPrompt,
}: UseSqlEditorShortcutsArgs) {
const os = detectOS()
const router = useRouter()
const { ref } = useParams()
const { editor, refocusEditor } = useSQLEditorContext()
const openNewSnippet = useCallback(() => {
if (!ref) return
// skip=true bypasses the "load last visited snippet" redirect on /sql/new.
// Without it, the effect in pages/project/[ref]/sql/[id].tsx bounces back
// to the previous snippet.
router.push(`/project/${ref}/sql/new?skip=true`)
}, [ref, router])
useShortcut(SHORTCUT_IDS.SQL_EDITOR_FOCUS_EDITOR, refocusEditor, {
registerInCommandMenu: true,
})
useShortcut(SHORTCUT_IDS.SQL_EDITOR_NEW_SNIPPET, openNewSnippet, {
registerInCommandMenu: true,
})
useShortcut(SHORTCUT_IDS.SQL_EDITOR_FORMAT, prettifyQuery, {
registerInCommandMenu: true,
})
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const action = resolveDiffKeyAction(e, { isDiffOpen, isPromptOpen, os })
switch (action.type) {
case 'accept':
acceptAiHandler()
resetPrompt()
return
case 'escape':
if (action.shouldDiscard) discardAiHandler()
resetPrompt()
editor.focus()
return
case 'none':
return
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [editor, os, isDiffOpen, isPromptOpen, acceptAiHandler, discardAiHandler, resetPrompt])
}