CodeEditor.tsx265 lines · main
| 1 | import Editor, { EditorProps, Monaco, OnChange, OnMount, useMonaco } from '@monaco-editor/react' |
| 2 | import { merge, noop } from 'lodash' |
| 3 | import type { editor } from 'monaco-editor' |
| 4 | import { MutableRefObject, useEffect, useRef, useState } from 'react' |
| 5 | import { cn, LogoLoader } from 'ui' |
| 6 | |
| 7 | import { alignEditor } from './CodeEditor.utils' |
| 8 | import { Markdown } from '@/components/interfaces/Markdown' |
| 9 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 10 | import { formatSql } from '@/lib/formatSql' |
| 11 | import { timeout } from '@/lib/helpers' |
| 12 | |
| 13 | type CodeEditorActions = { enabled: boolean; callback: (value: any) => void } |
| 14 | const DEFAULT_ACTIONS = { |
| 15 | runQuery: { enabled: false, callback: noop }, |
| 16 | explainCode: { enabled: false, callback: noop }, |
| 17 | formatDocument: { enabled: true, callback: noop }, |
| 18 | placeholderFill: { enabled: true }, |
| 19 | closeAssistant: { enabled: false, callback: noop }, |
| 20 | } |
| 21 | |
| 22 | interface CodeEditorProps { |
| 23 | id: string |
| 24 | language: 'pgsql' | 'json' | 'html' | 'typescript' | undefined |
| 25 | autofocus?: boolean |
| 26 | defaultValue?: string |
| 27 | isReadOnly?: boolean |
| 28 | hideLineNumbers?: boolean |
| 29 | className?: string |
| 30 | loading?: boolean |
| 31 | options?: EditorProps['options'] |
| 32 | value?: string |
| 33 | placeholder?: string |
| 34 | /* Determines what actions to add for code editor context menu */ |
| 35 | actions?: Partial<{ |
| 36 | runQuery: CodeEditorActions |
| 37 | formatDocument: CodeEditorActions |
| 38 | placeholderFill: Omit<CodeEditorActions, 'callback'> |
| 39 | explainCode: CodeEditorActions |
| 40 | closeAssistant: CodeEditorActions |
| 41 | }> |
| 42 | editorRef?: MutableRefObject<editor.IStandaloneCodeEditor | null> |
| 43 | onInputChange?: (value?: string) => void |
| 44 | } |
| 45 | |
| 46 | export const CodeEditor = ({ |
| 47 | id, |
| 48 | language, |
| 49 | defaultValue, |
| 50 | autofocus = true, |
| 51 | isReadOnly = false, |
| 52 | hideLineNumbers = false, |
| 53 | className, |
| 54 | loading, |
| 55 | options, |
| 56 | value, |
| 57 | placeholder, |
| 58 | actions = DEFAULT_ACTIONS, |
| 59 | editorRef: editorRefProps, |
| 60 | onInputChange = noop, |
| 61 | }: CodeEditorProps) => { |
| 62 | const monaco = useMonaco() |
| 63 | const { data: project } = useSelectedProjectQuery() |
| 64 | |
| 65 | const hasValue = useRef<editor.IContextKey<boolean>>(null) |
| 66 | const ref = useRef<editor.IStandaloneCodeEditor>(null) |
| 67 | const editorRef = editorRefProps || ref |
| 68 | const monacoRef = useRef<Monaco>(null) |
| 69 | |
| 70 | const { runQuery, placeholderFill, formatDocument, explainCode, closeAssistant } = { |
| 71 | ...DEFAULT_ACTIONS, |
| 72 | ...actions, |
| 73 | } |
| 74 | |
| 75 | const runQueryCallbackRef = useRef(runQuery.callback) |
| 76 | useEffect(() => { |
| 77 | runQueryCallbackRef.current = runQuery.callback |
| 78 | }, [runQuery.callback]) |
| 79 | |
| 80 | const showPlaceholderDefault = placeholder !== undefined && (value ?? '').trim().length === 0 |
| 81 | const [showPlaceholder, setShowPlaceholder] = useState(showPlaceholderDefault) |
| 82 | |
| 83 | const optionsMerged = merge( |
| 84 | { |
| 85 | tabSize: 2, |
| 86 | fontSize: 13, |
| 87 | readOnly: isReadOnly, |
| 88 | minimap: { enabled: false }, |
| 89 | wordWrap: 'on', |
| 90 | fixedOverflowWidgets: true, |
| 91 | contextmenu: true, |
| 92 | lineNumbers: hideLineNumbers ? 'off' : undefined, |
| 93 | glyphMargin: hideLineNumbers ? false : undefined, |
| 94 | lineNumbersMinChars: hideLineNumbers ? 0 : 4, |
| 95 | folding: hideLineNumbers ? false : undefined, |
| 96 | scrollBeyondLastLine: false, |
| 97 | }, |
| 98 | options |
| 99 | ) |
| 100 | |
| 101 | const onMount: OnMount = async (editor, monaco) => { |
| 102 | editorRef.current = editor |
| 103 | monacoRef.current = monaco |
| 104 | alignEditor(editor) |
| 105 | |
| 106 | hasValue.current = editor.createContextKey('hasValue', false) |
| 107 | hasValue.current.set(value !== undefined && value.trim().length > 0) |
| 108 | setShowPlaceholder(showPlaceholderDefault) |
| 109 | |
| 110 | if (placeholderFill.enabled) { |
| 111 | editor.addCommand( |
| 112 | monaco.KeyCode.Tab, |
| 113 | () => { |
| 114 | editor.executeEdits('source', [ |
| 115 | { |
| 116 | // @ts-ignore |
| 117 | identifier: 'add-placeholder', |
| 118 | range: new monaco.Range(1, 1, 1, 1), |
| 119 | text: (placeholder ?? '').split('\n\n').join('\n').replaceAll(' ', ' '), |
| 120 | }, |
| 121 | ]) |
| 122 | }, |
| 123 | '!hasValue' |
| 124 | ) |
| 125 | } |
| 126 | |
| 127 | if (runQuery.enabled) { |
| 128 | editor.addAction({ |
| 129 | id: 'run-query', |
| 130 | label: 'Run Query', |
| 131 | keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], |
| 132 | contextMenuGroupId: 'operation', |
| 133 | contextMenuOrder: 0, |
| 134 | run: () => { |
| 135 | const selectedValue = (editorRef?.current as any) |
| 136 | .getModel() |
| 137 | .getValueInRange((editorRef?.current as any)?.getSelection()) |
| 138 | runQueryCallbackRef.current(selectedValue || (editorRef?.current as any)?.getValue()) |
| 139 | }, |
| 140 | }) |
| 141 | } |
| 142 | |
| 143 | if (explainCode.enabled) { |
| 144 | editor.addAction({ |
| 145 | id: 'explain-code', |
| 146 | label: 'Explain Code', |
| 147 | contextMenuGroupId: 'operation', |
| 148 | contextMenuOrder: 1, |
| 149 | run: () => { |
| 150 | const selectedValue = (editorRef?.current as any) |
| 151 | .getModel() |
| 152 | .getValueInRange((editorRef?.current as any)?.getSelection()) |
| 153 | explainCode.callback(selectedValue) |
| 154 | }, |
| 155 | }) |
| 156 | } |
| 157 | |
| 158 | if (closeAssistant.enabled) { |
| 159 | editor.addAction({ |
| 160 | id: 'close-assistant', |
| 161 | label: 'Close Assistant', |
| 162 | keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI], |
| 163 | run: () => closeAssistant.callback(), |
| 164 | }) |
| 165 | } |
| 166 | |
| 167 | const model = editor.getModel() |
| 168 | if (model) { |
| 169 | const position = model.getPositionAt((value ?? '').length) |
| 170 | editor.setPosition(position) |
| 171 | } |
| 172 | |
| 173 | await timeout(500) |
| 174 | if (autofocus) editor?.focus() |
| 175 | } |
| 176 | |
| 177 | const onChangeContent: OnChange = (value) => { |
| 178 | if (hasValue.current) { |
| 179 | hasValue.current.set((value ?? '').length > 0) |
| 180 | } |
| 181 | setShowPlaceholder(!value) |
| 182 | onInputChange(value) |
| 183 | } |
| 184 | |
| 185 | useEffect(() => { |
| 186 | setShowPlaceholder(showPlaceholderDefault) |
| 187 | }, [showPlaceholderDefault]) |
| 188 | |
| 189 | useEffect(() => { |
| 190 | if ( |
| 191 | placeholderFill.enabled && |
| 192 | editorRef.current !== undefined && |
| 193 | monacoRef.current !== undefined |
| 194 | ) { |
| 195 | const editor = editorRef.current |
| 196 | if (editor == null) return |
| 197 | const monaco = monacoRef.current |
| 198 | if (monaco == null) return |
| 199 | |
| 200 | editor.addCommand( |
| 201 | monaco.KeyCode.Tab, |
| 202 | () => { |
| 203 | editor.executeEdits('source', [ |
| 204 | { |
| 205 | // @ts-ignore |
| 206 | identifier: 'add-placeholder', |
| 207 | range: new monaco.Range(1, 1, 1, 1), |
| 208 | text: (placeholder ?? ' ') |
| 209 | .split('\n\n') |
| 210 | .join('\n') |
| 211 | .replaceAll('*', '') |
| 212 | .replaceAll(' ', ''), |
| 213 | }, |
| 214 | ]) |
| 215 | }, |
| 216 | '!hasValue' |
| 217 | ) |
| 218 | } |
| 219 | }, [placeholder, placeholderFill.enabled]) |
| 220 | |
| 221 | useEffect(() => { |
| 222 | if (monaco && project && formatDocument.enabled) { |
| 223 | const formatProvider = monaco.languages.registerDocumentFormattingEditProvider('pgsql', { |
| 224 | async provideDocumentFormattingEdits(model: any) { |
| 225 | const value = model.getValue() |
| 226 | const formatted = formatSql(value) |
| 227 | formatDocument.callback(formatted) |
| 228 | return [{ range: model.getFullModelRange(), text: formatted }] |
| 229 | }, |
| 230 | }) |
| 231 | return () => formatProvider.dispose() |
| 232 | } |
| 233 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 234 | }, [monaco, project, formatDocument.enabled]) |
| 235 | |
| 236 | return ( |
| 237 | <> |
| 238 | <Editor |
| 239 | path={id} |
| 240 | theme="briven" |
| 241 | className={cn(className, 'monaco-editor')} |
| 242 | value={value ?? undefined} |
| 243 | language={language} |
| 244 | defaultValue={defaultValue ?? undefined} |
| 245 | loading={loading || <LogoLoader />} |
| 246 | options={optionsMerged} |
| 247 | onMount={onMount} |
| 248 | onChange={onChangeContent} |
| 249 | /> |
| 250 | {placeholder !== undefined && ( |
| 251 | <div |
| 252 | className={cn( |
| 253 | 'monaco-placeholder absolute top-[3px] left-[57px] text-sm pointer-events-none font-mono', |
| 254 | '[&>div>p]:text-foreground-lighter [&>div>p]:m-0! tracking-tighter', |
| 255 | showPlaceholder ? 'block' : 'hidden' |
| 256 | )} |
| 257 | > |
| 258 | <Markdown content={placeholder} /> |
| 259 | </div> |
| 260 | )} |
| 261 | </> |
| 262 | ) |
| 263 | } |
| 264 | |
| 265 | export default CodeEditor |