MonacoEditor.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import Editor, { Monaco, OnMount } from '@monaco-editor/react'
  2. import { useDebounce } from '@uidotdev/usehooks'
  3. import { LOCAL_STORAGE_KEYS, useParams } from 'common'
  4. import { useRouter } from 'next/router'
  5. import { MutableRefObject, useEffect, useRef, useState } from 'react'
  6. import { cn } from 'ui'
  7. import { Admonition } from 'ui-patterns'
  8. import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
  9. import type { IStandaloneCodeEditor } from './SQLEditor.types'
  10. import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils'
  11. import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
  12. import { getEditorSelectionParts } from '@/components/ui/AIEditor/utils'
  13. import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
  14. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  15. import { useProfile } from '@/lib/profile'
  16. import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
  17. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  18. import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled'
  19. import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
  20. import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
  21. import { useTabsStateSnapshot } from '@/state/tabs'
  22. export type MonacoEditorProps = {
  23. id: string
  24. snippetName: string
  25. className?: string
  26. editorRef: MutableRefObject<IStandaloneCodeEditor | null>
  27. monacoRef: MutableRefObject<Monaco | null>
  28. autoFocus?: boolean
  29. executeQuery: () => void
  30. executeExplainQuery: () => void
  31. prettifyQuery: () => void
  32. onHasSelection: (value: boolean) => void
  33. onMount?: (editor: IStandaloneCodeEditor) => void
  34. onPrompt?: (value: {
  35. selection: string
  36. beforeSelection: string
  37. afterSelection: string
  38. startLineNumber: number
  39. endLineNumber: number
  40. }) => void
  41. placeholder?: string
  42. }
  43. const MonacoEditor = ({
  44. id,
  45. snippetName,
  46. editorRef,
  47. monacoRef,
  48. autoFocus = true,
  49. placeholder = '',
  50. className,
  51. executeQuery,
  52. executeExplainQuery,
  53. prettifyQuery,
  54. onHasSelection,
  55. onPrompt,
  56. onMount,
  57. }: MonacoEditorProps) => {
  58. const router = useRouter()
  59. const { profile } = useProfile()
  60. const { ref, content } = useParams()
  61. const { data: project } = useSelectedProjectQuery()
  62. const snapV2 = useSqlEditorV2StateSnapshot()
  63. const tabsSnap = useTabsStateSnapshot()
  64. const aiSnap = useAiAssistantStateSnapshot()
  65. const { openSidebar, toggleSidebar } = useSidebarManagerSnapshot()
  66. const [intellisenseEnabled] = useLocalStorageQuery(
  67. LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
  68. true
  69. )
  70. const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE)
  71. const isCommandMenuHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN)
  72. const setCommandMenuOpen = useSetCommandMenuOpen()
  73. // [Joshen] Lodash debounce doesn't seem to be working here, so opting to use useDebounce
  74. const [value, setValue] = useState('')
  75. const debouncedValue = useDebounce(value, 1000)
  76. const snippet = snapV2.snippets[id]
  77. const disableEdit =
  78. snippet?.snippet.visibility === 'project' && snippet?.snippet.owner_id !== profile?.id
  79. const executeQueryRef = useRef(executeQuery)
  80. executeQueryRef.current = executeQuery
  81. const executeExplainQueryRef = useRef(executeExplainQuery)
  82. executeExplainQueryRef.current = executeExplainQuery
  83. const prettifyQueryRef = useRef(prettifyQuery)
  84. prettifyQueryRef.current = prettifyQuery
  85. const aiHotkeyEnabledRef = useRef(isAIAssistantHotkeyEnabled)
  86. aiHotkeyEnabledRef.current = isAIAssistantHotkeyEnabled
  87. const commandMenuHotkeyEnabledRef = useRef(isCommandMenuHotkeyEnabled)
  88. commandMenuHotkeyEnabledRef.current = isCommandMenuHotkeyEnabled
  89. const setCommandMenuOpenRef = useRef(setCommandMenuOpen)
  90. setCommandMenuOpenRef.current = setCommandMenuOpen
  91. const handleEditorOnMount: OnMount = async (editor, monaco) => {
  92. editorRef.current = editor
  93. monacoRef.current = monaco
  94. const model = editorRef.current.getModel()
  95. if (model !== null) {
  96. monacoRef.current.editor.setModelMarkers(model, 'owner', [])
  97. }
  98. // Blur the editor on Escape so users can hop out to the rest of the UI.
  99. // The precondition defers to Monaco's own Escape consumers (suggest widget,
  100. // find widget, parameter hints, snippet/rename mode, inline suggestions) and
  101. // to selection/multi-cursor cancellation, so inline features keep working.
  102. editor.addCommand(
  103. monaco.KeyCode.Escape,
  104. () => {
  105. ;(document.activeElement as HTMLElement | null)?.blur()
  106. },
  107. [
  108. 'editorTextFocus',
  109. '!editorHasSelection',
  110. '!editorHasMultipleSelections',
  111. '!suggestWidgetVisible',
  112. '!findWidgetVisible',
  113. '!parameterHintsVisible',
  114. '!renameInputVisible',
  115. '!inSnippetMode',
  116. '!accessibilityHelpWidgetVisible',
  117. '!inlineSuggestionVisible',
  118. ].join(' && ')
  119. )
  120. editor.addAction({
  121. id: 'run-query',
  122. label: 'Run Query',
  123. keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter],
  124. contextMenuGroupId: 'operation',
  125. contextMenuOrder: 0,
  126. run: () => {
  127. executeQueryRef.current()
  128. },
  129. })
  130. editor.addAction({
  131. id: 'run-explain-query',
  132. label: 'Run EXPLAIN ANALYZE',
  133. keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter],
  134. contextMenuGroupId: 'operation',
  135. contextMenuOrder: 1,
  136. run: () => {
  137. executeExplainQueryRef.current()
  138. },
  139. })
  140. editor.addAction({
  141. id: 'save-query',
  142. label: 'Save Query',
  143. keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyS],
  144. contextMenuGroupId: 'operation',
  145. contextMenuOrder: 0,
  146. run: () => {
  147. if (snippet) snapV2.addNeedsSaving(snippet.snippet.id)
  148. },
  149. })
  150. editor.addAction({
  151. id: 'prettify-query',
  152. label: 'Prettify SQL',
  153. keybindings: [monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyF],
  154. contextMenuGroupId: 'operation',
  155. contextMenuOrder: 2,
  156. run: () => {
  157. prettifyQueryRef.current()
  158. },
  159. })
  160. editor.addAction({
  161. id: 'explain-code',
  162. label: 'Explain Code',
  163. contextMenuGroupId: 'operation',
  164. contextMenuOrder: 1,
  165. run: () => {
  166. const selectedValue = (editorRef?.current as any)
  167. .getModel()
  168. .getValueInRange((editorRef?.current as any)?.getSelection())
  169. openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
  170. aiSnap.newChat({
  171. name: 'Explain code section',
  172. sqlSnippets: [selectedValue],
  173. initialInput: 'Can you explain this section to me in more detail?',
  174. })
  175. },
  176. })
  177. editor.addAction({
  178. id: 'toggle-ai-assistant',
  179. label: 'Toggle AI Assistant',
  180. keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI],
  181. run: () => {
  182. if (aiHotkeyEnabledRef.current) {
  183. toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
  184. }
  185. },
  186. })
  187. if (onPrompt) {
  188. editor.addAction({
  189. id: 'generate-sql',
  190. label: 'Generate SQL',
  191. keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyK],
  192. run: () => {
  193. const selectionParts = getEditorSelectionParts(editor)
  194. if (selectionParts) onPrompt(selectionParts)
  195. },
  196. })
  197. }
  198. // Monaco claims Cmd+K as a chord prefix, which swallows the global command
  199. // menu shortcut while the editor is focused. Intercept it here and open the
  200. // command menu directly so it works the same inside and outside the editor.
  201. editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, () => {
  202. if (commandMenuHotkeyEnabledRef.current) {
  203. setCommandMenuOpenRef.current(true)
  204. }
  205. })
  206. editor.onDidChangeCursorSelection(({ selection }) => {
  207. const noSelection =
  208. selection.startLineNumber === selection.endLineNumber &&
  209. selection.startColumn === selection.endColumn
  210. onHasSelection(!noSelection)
  211. })
  212. if (autoFocus) {
  213. if (editor.getValue().length === 1) editor.setPosition({ lineNumber: 1, column: 2 })
  214. editor.focus()
  215. }
  216. onMount?.(editor)
  217. }
  218. function handleEditorChange(value: string | undefined) {
  219. tabsSnap.makeActiveTabPermanent()
  220. if (id && value) {
  221. if (!snippet && ref && profile !== undefined && project !== undefined) {
  222. const snippet = createSqlSnippetSkeletonV2({
  223. idOverride: id,
  224. name: snippetName,
  225. sql: value,
  226. owner_id: profile?.id,
  227. project_id: project?.id,
  228. })
  229. snapV2.addSnippet({ projectRef: ref, snippet })
  230. router.push(`/project/${ref}/sql/${snippet.id}`, undefined, { shallow: true })
  231. }
  232. setValue(value)
  233. }
  234. }
  235. useEffect(() => {
  236. if (debouncedValue.length > 0 && snippet) {
  237. const shouldInvalidate = snippet.snippet.isNotSavedInDatabaseYet
  238. snapV2.setSql({ id, sql: value, shouldInvalidate })
  239. }
  240. // eslint-disable-next-line react-hooks/exhaustive-deps
  241. }, [debouncedValue])
  242. // if an SQL query is passed by the content parameter, set the editor value to its content. This
  243. // is usually used for sending the user to SQL editor from other pages with SQL.
  244. useEffect(() => {
  245. if (content && content.length > 0) handleEditorChange(content)
  246. }, [])
  247. return (
  248. <>
  249. {disableEdit && (
  250. <Admonition
  251. type="default"
  252. className="rounded-none border-0 border-b"
  253. title="Read-only snippet"
  254. description="This snippet has been shared to the project and is only editable by the owner who created this snippet. You may duplicate this snippet into a personal copy by right clicking on the snippet and selecting “Duplicate query”."
  255. />
  256. )}
  257. <Editor
  258. className={cn(className, 'monaco-editor')}
  259. theme={'briven'}
  260. onMount={handleEditorOnMount}
  261. onChange={handleEditorChange}
  262. defaultLanguage="pgsql"
  263. defaultValue={snippet?.snippet.content?.unchecked_sql}
  264. path={id}
  265. options={{
  266. tabSize: 2,
  267. fontSize: 13,
  268. placeholder,
  269. lineDecorationsWidth: 0,
  270. readOnly: disableEdit,
  271. minimap: { enabled: false },
  272. wordWrap: 'on',
  273. padding: { top: 4 },
  274. // [Joshen] Commenting the following out as it causes the autocomplete suggestion popover
  275. // to be positioned wrongly somehow. I'm not sure if this affects anything though, but leaving
  276. // comment just in case anyone might be wondering. Relevant issues:
  277. // - https://github.com/microsoft/monaco-editor/issues/2229
  278. // - https://github.com/microsoft/monaco-editor/issues/2503
  279. // fixedOverflowWidgets: true,
  280. suggest: {
  281. showMethods: intellisenseEnabled,
  282. showFunctions: intellisenseEnabled,
  283. showConstructors: intellisenseEnabled,
  284. showDeprecated: intellisenseEnabled,
  285. showFields: intellisenseEnabled,
  286. showVariables: intellisenseEnabled,
  287. showClasses: intellisenseEnabled,
  288. showStructs: intellisenseEnabled,
  289. showInterfaces: intellisenseEnabled,
  290. showModules: intellisenseEnabled,
  291. showProperties: intellisenseEnabled,
  292. showEvents: intellisenseEnabled,
  293. showOperators: intellisenseEnabled,
  294. showUnits: intellisenseEnabled,
  295. showValues: intellisenseEnabled,
  296. showConstants: intellisenseEnabled,
  297. showEnums: intellisenseEnabled,
  298. showEnumMembers: intellisenseEnabled,
  299. showKeywords: intellisenseEnabled,
  300. showWords: intellisenseEnabled,
  301. showColors: intellisenseEnabled,
  302. showFiles: intellisenseEnabled,
  303. showReferences: intellisenseEnabled,
  304. showFolders: intellisenseEnabled,
  305. showTypeParameters: intellisenseEnabled,
  306. showIssues: intellisenseEnabled,
  307. showUsers: intellisenseEnabled,
  308. showSnippets: intellisenseEnabled,
  309. },
  310. }}
  311. />
  312. </>
  313. )
  314. }
  315. export default MonacoEditor