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