mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
## Context Part of efforts to consolidate all the code editors that we have in the repository `CodeEditor` will serve as the base monaco editor file that all UIs should consume from It's aimed to be generic and just stores the common logic that will be generally used where-ever we need a code editor (editor options, base editor set up on mount, etc) The idea is that `CodeEditor` holds just 3 default actions (run queyr, format document and placeholder fill) If any editor needs specific behaviours (e.g SQL Editor), they can declare them in the `onMount` prop of `CodeEditor` which gives some flexibility ## Changes involved - Use `CodeEditor` component for SQL Editor's `MonacoEditor` - Shifted Cmd K behaviour into `CodeEditor` since that's probably needed everywhere that we render that UI - Deprecate `MonacoEditor` from the table editor's `grid` folder - All files that were using that component to use `CodeEditor` component instead <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **Refactor** * Migrated the studio’s code editing UI (including JSON, text, and payload viewers) to a unified CodeEditor experience. * **New Features** * Added plaintext language support for read-only/truncated views. * **Behavior Changes** * Improved editor startup by setting cursor position consistently and deferring autofocus. * Streamlined editor context-menu actions to the core set (run query, format, placeholder fill). * Updated SQL editor wiring for more consistent command/menu and selection handling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
271 lines
8.3 KiB
TypeScript
271 lines
8.3 KiB
TypeScript
import Editor, { EditorProps, Monaco, OnChange, OnMount, useMonaco } from '@monaco-editor/react'
|
|
import { merge, noop } from 'lodash'
|
|
import type { editor } from 'monaco-editor'
|
|
import { RefObject, useEffect, useRef, useState } from 'react'
|
|
import { cn, LogoLoader } from 'ui'
|
|
import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
|
|
|
|
import { alignEditor } 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 },
|
|
}
|
|
|
|
interface CodeEditorProps {
|
|
id?: string
|
|
language: 'pgsql' | 'json' | 'html' | 'typescript' | 'plaintext' | undefined
|
|
autofocus?: boolean
|
|
defaultValue?: string
|
|
isReadOnly?: boolean
|
|
hideLineNumbers?: boolean
|
|
className?: 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,
|
|
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(
|
|
{
|
|
tabSize: 2,
|
|
fontSize: 13,
|
|
readOnly: isReadOnly,
|
|
minimap: { enabled: false },
|
|
wordWrap: 'on',
|
|
fixedOverflowWidgets: true,
|
|
contextmenu: true,
|
|
lineNumbers: hideLineNumbers ? 'off' : undefined,
|
|
glyphMargin: hideLineNumbers ? false : undefined,
|
|
lineNumbersMinChars: hideLineNumbers ? 0 : 4,
|
|
folding: hideLineNumbers ? false : undefined,
|
|
scrollBeyondLastLine: false,
|
|
},
|
|
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: () => {
|
|
const selection = editorRef?.current?.getSelection()
|
|
if (!selection) return
|
|
|
|
const selectedValue = editorRef?.current?.getModel()?.getValueInRange(selection)
|
|
const editorValue = editorRef?.current?.getValue()
|
|
|
|
runQueryCallbackRef.current(selectedValue || editorValue)
|
|
},
|
|
})
|
|
}
|
|
|
|
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"
|
|
className={cn(className, 'monaco-editor')}
|
|
value={value ?? undefined}
|
|
language={language}
|
|
defaultValue={defaultValue ?? undefined}
|
|
loading={loading || <LogoLoader />}
|
|
options={optionsMerged}
|
|
onMount={handleMount}
|
|
onChange={onChangeContent}
|
|
/>
|
|
{placeholder !== undefined && (
|
|
<div
|
|
className={cn(
|
|
'monaco-placeholder absolute top-[3px] left-[57px] text-sm pointer-events-none font-mono',
|
|
'[&>div>p]:text-foreground-lighter [&>div>p]:m-0! tracking-tighter',
|
|
showPlaceholder ? 'block' : 'hidden'
|
|
)}
|
|
>
|
|
<Markdown content={placeholder} />
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|