Files
supabase/apps/studio/components/ui/CodeEditor/CodeEditor.tsx
T
Joshen Lim f2e20eac34 Joshen/fe 3651 deprecate monacoeditor from grid folder to use codeeditor (#47179)
## 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 -->
2026-06-23 11:39:24 +08:00

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('&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: () => {
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('&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"
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>
)}
</>
)
}