Files
supabase/apps/studio/components/ui/CodeEditor/CodeEditor.tsx
T
Joshen Lim 8d59e69da4 Add support for running only selected query in QueryEditor (#49651)
## Context

As per PR title - this behaviour currently exists in the SQL Editor so
just bringing it over to the Explorer, applies to both QueryTab and
QueryCell since they use the same QueryEditor component

"Run" button also updates to "Run selected" for clarity when a specific
portion of the code editor is selected

<img width="1387" height="958" alt="image"
src="https://github.com/user-attachments/assets/7e652951-3c07-4f8d-851b-bb9219d0c1f2"
/>


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

* **New Features**
* Run only the selected SQL when text is highlighted in the query
editor.
  * Run the full query when no text is selected.
  * Updated the run button label and tooltip to reflect the action.

* **Bug Fixes**
  * Improved query execution for empty or collapsed selections.
  * Corrected selection state when reopening the query editor.

* **Tests**
* Added coverage for selected-text, full-query, and editor reopening
scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-28 12:49:35 +08:00

289 lines
9.0 KiB
TypeScript

import Editor, { EditorProps, Monaco, OnChange, OnMount, useMonaco } from '@monaco-editor/react'
import { merge, noop } from 'lodash'
import { Loader2 } from 'lucide-react'
import type { editor } from 'monaco-editor'
import { RefObject, useEffect, useRef, useState } from 'react'
import { cn } from 'ui'
import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
import {
alignEditor,
BASE_MONACO_EDITOR_OPTIONS,
getEditorValueOrSelection,
} from './CodeEditor.utils'
import { Markdown } from '@/components/interfaces/Markdown'
import { useLatest } from '@/hooks/misc/useLatest'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { formatSql } from '@/lib/formatSql'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled'
type CodeEditorActions = { enabled: boolean; callback: (value: any) => void }
const DEFAULT_ACTIONS = {
runQuery: { enabled: false, callback: noop },
formatDocument: { enabled: true, callback: noop },
placeholderFill: { enabled: true },
}
export type ValidLanguages =
| 'pgsql'
| 'json'
| 'html'
| 'typescript'
| 'javascript'
| 'css'
| 'csv'
| 'plaintext'
| 'markdown'
interface CodeEditorProps {
id?: string
language: ValidLanguages
autofocus?: boolean
defaultValue?: string
isReadOnly?: boolean
hideLineNumbers?: boolean
className?: string
wrapperClassName?: string
placeholderClassName?: string
loading?: boolean
options?: EditorProps['options']
value?: string
placeholder?: string
/* Determines what actions to add for code editor context menu */
actions?: Partial<{
runQuery: CodeEditorActions
formatDocument: CodeEditorActions
placeholderFill: Omit<CodeEditorActions, 'callback'>
}>
editorRef?: RefObject<editor.IStandaloneCodeEditor | null>
monacoRef?: RefObject<Monaco | null>
onInputChange?: (value?: string) => void
/**
* Fired after CodeEditor's own mount setup runs, so wrappers can register
* additional actions/keybindings on the shared editor instance.
*/
onMount?: OnMount
}
export const CodeEditor = ({
id,
language,
defaultValue,
autofocus = true,
isReadOnly = false,
hideLineNumbers = false,
className,
wrapperClassName,
placeholderClassName,
loading,
options,
value,
placeholder,
actions,
editorRef: editorRefProps,
monacoRef: monacoRefProps,
onInputChange = noop,
onMount: onMountProps,
}: CodeEditorProps) => {
const monaco = useMonaco()
const { data: project } = useSelectedProjectQuery()
const hasValue = useRef<editor.IContextKey<boolean>>(null)
const ref = useRef<editor.IStandaloneCodeEditor>(null)
const editorRef = editorRefProps || ref
const internalMonacoRef = useRef<Monaco | null>(null)
const monacoRef = monacoRefProps || internalMonacoRef
const { runQuery, placeholderFill, formatDocument } = {
...DEFAULT_ACTIONS,
...actions,
}
// Monaco claims Cmd+K as a chord prefix, which swallows the global command menu
// shortcut while the editor is focused. CodeEditor intercepts it for every editor
// (see handleMount) so it behaves the same inside and outside the editor.
const commandMenuHotkeyEnabledRef = useLatest(
useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN)
)
const setCommandMenuOpenRef = useLatest(useSetCommandMenuOpen())
const runQueryCallbackRef = useLatest(runQuery.callback)
const showPlaceholderDefault = placeholder !== undefined && (value ?? '').trim().length === 0
const [showPlaceholder, setShowPlaceholder] = useState(showPlaceholderDefault)
const optionsMerged = merge(
{
...BASE_MONACO_EDITOR_OPTIONS,
domReadOnly: isReadOnly,
readOnly: isReadOnly,
lineNumbers: hideLineNumbers ? 'off' : undefined,
glyphMargin: hideLineNumbers ? false : undefined,
lineNumbersMinChars: hideLineNumbers ? 0 : 4,
folding: hideLineNumbers ? false : undefined,
},
options
)
const handleMount: OnMount = async (editor, monaco) => {
editorRef.current = editor
monacoRef.current = monaco
alignEditor(editor)
hasValue.current = editor.createContextKey('hasValue', false)
hasValue.current.set(value !== undefined && value.trim().length > 0)
setShowPlaceholder(showPlaceholderDefault)
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, () => {
if (commandMenuHotkeyEnabledRef.current) {
setCommandMenuOpenRef.current(true)
}
})
if (placeholderFill.enabled) {
editor.addCommand(
monaco.KeyCode.Tab,
() => {
editor.executeEdits('source', [
{
// @ts-ignore
identifier: 'add-placeholder',
range: new monaco.Range(1, 1, 1, 1),
text: (placeholder ?? '').split('\n\n').join('\n').replaceAll('&nbsp;', ' '),
},
])
},
'!hasValue'
)
}
if (runQuery.enabled) {
editor.addAction({
id: 'run-query',
label: 'Run Query',
keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter],
contextMenuGroupId: 'operation',
contextMenuOrder: 0,
run: () => {
if (!editorRef.current) return
runQueryCallbackRef.current(getEditorValueOrSelection(editorRef.current))
},
})
}
const model = editor.getModel()
if (model) {
const position = model.getPositionAt((value ?? '').length)
editor.setPosition(position)
}
// Run last so wrappers can register actions and have the final say on cursor
// position / focus before CodeEditor's own (timeout-deferred) autofocus.
onMountProps?.(editor, monaco)
// auto focus on mount
setTimeout(() => {
if (autofocus) {
if (editor.getValue().length === 1) editor.setPosition({ lineNumber: 1, column: 2 })
editor.focus()
}
}, 0)
}
const onChangeContent: OnChange = (value) => {
if (hasValue.current) {
hasValue.current.set((value ?? '').length > 0)
}
setShowPlaceholder(!value)
onInputChange(value)
}
useEffect(() => {
setShowPlaceholder(showPlaceholderDefault)
}, [showPlaceholderDefault])
useEffect(() => {
if (
placeholderFill.enabled &&
editorRef.current !== undefined &&
monacoRef.current !== undefined
) {
const editor = editorRef.current
if (editor == null) return
const monaco = monacoRef.current
if (monaco == null) return
editor.addCommand(
monaco.KeyCode.Tab,
() => {
editor.executeEdits('source', [
{
// @ts-ignore
identifier: 'add-placeholder',
range: new monaco.Range(1, 1, 1, 1),
text: (placeholder ?? ' ')
.split('\n\n')
.join('\n')
.replaceAll('*', '')
.replaceAll('&nbsp;', ''),
},
])
},
'!hasValue'
)
}
}, [placeholder, placeholderFill.enabled])
useEffect(() => {
if (monaco && project && formatDocument.enabled) {
const formatProvider = monaco.languages.registerDocumentFormattingEditProvider('pgsql', {
async provideDocumentFormattingEdits(model: any) {
const value = model.getValue()
const formatted = formatSql(value)
formatDocument.callback(formatted)
return [{ range: model.getFullModelRange(), text: formatted }]
},
})
return () => formatProvider.dispose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [monaco, project, formatDocument.enabled])
return (
<>
<Editor
path={id}
theme="supabase"
// `h-full` keeps this wrapper filling its container even if a global `.monaco-editor`
// rule flips it to `position: absolute` (which happens after visiting GraphiQL, since it
// injects a second copy of Monaco's CSS onto the shared instance). Without an explicit
// height, an absolutely-positioned wrapper collapses to 0 and Monaco lays out at ~5px.
// Order matters: `h-full` is a default, so a caller-supplied height in `className`
// (e.g. `h-96`) wins via tailwind-merge instead of being clobbered.
className={cn('monaco-editor', 'h-full', className)}
wrapperProps={{ className: wrapperClassName }}
value={value ?? undefined}
language={language}
defaultValue={defaultValue ?? undefined}
loading={loading || <Loader2 className="animate-spin" strokeWidth={2} size={20} />}
options={optionsMerged}
onMount={handleMount}
onChange={onChangeContent}
/>
{placeholder !== undefined && (
<div
className={cn(
'monaco-placeholder absolute top-[5px] left-[57px] text-sm pointer-events-none font-mono',
'[&>div>p]:text-foreground-lighter [&>div>p]:m-0! tracking-tighter',
showPlaceholder ? 'block' : 'hidden',
placeholderClassName
)}
>
<Markdown content={placeholder} />
</div>
)}
</>
)
}