CodeEditor.tsx265 lines · main
1import Editor, { EditorProps, Monaco, OnChange, OnMount, useMonaco } from '@monaco-editor/react'
2import { merge, noop } from 'lodash'
3import type { editor } from 'monaco-editor'
4import { MutableRefObject, useEffect, useRef, useState } from 'react'
5import { cn, LogoLoader } from 'ui'
6
7import { alignEditor } from './CodeEditor.utils'
8import { Markdown } from '@/components/interfaces/Markdown'
9import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
10import { formatSql } from '@/lib/formatSql'
11import { timeout } from '@/lib/helpers'
12
13type CodeEditorActions = { enabled: boolean; callback: (value: any) => void }
14const 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
22interface 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
46export 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('&nbsp;', ' '),
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('&nbsp;', ''),
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
265export default CodeEditor