mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## 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 -->
289 lines
9.0 KiB
TypeScript
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(' ', ' '),
|
|
},
|
|
])
|
|
},
|
|
'!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(' ', ''),
|
|
},
|
|
])
|
|
},
|
|
'!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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|