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 } 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 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 }> editorRef?: RefObject monacoRef?: RefObject 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, loading, options, value, placeholder, actions, editorRef: editorRefProps, monacoRef: monacoRefProps, onInputChange = noop, onMount: onMountProps, }: CodeEditorProps) => { const monaco = useMonaco() const { data: project } = useSelectedProjectQuery() const hasValue = useRef>(null) const ref = useRef(null) const editorRef = editorRefProps || ref const internalMonacoRef = useRef(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, domReadOnly: isReadOnly, 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 ( <> } options={optionsMerged} onMount={handleMount} onChange={onChangeContent} /> {placeholder !== undefined && (
div>p]:text-foreground-lighter [&>div>p]:m-0! tracking-tighter', showPlaceholder ? 'block' : 'hidden' )} >
)} ) }