MonacoEditor.tsx347 lines · main
| 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 | |
| 10 | import type { IStandaloneCodeEditor } from './SQLEditor.types' |
| 11 | import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils' |
| 12 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 13 | import { getEditorSelectionParts } from '@/components/ui/AIEditor/utils' |
| 14 | import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' |
| 15 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 16 | import { useProfile } from '@/lib/profile' |
| 17 | import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' |
| 18 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 19 | import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' |
| 20 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 21 | import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' |
| 22 | import { useTabsStateSnapshot } from '@/state/tabs' |
| 23 | |
| 24 | export 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 | |
| 46 | const 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 | |
| 347 | export default MonacoEditor |