CodeEditor.tsx 7.8 KB

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