import Editor, { Monaco, OnMount } from '@monaco-editor/react' import { useDebounce } from '@uidotdev/usehooks' import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { useRouter } from 'next/router' import { MutableRefObject, useEffect, useRef, useState } from 'react' import { cn } from 'ui' import { Admonition } from 'ui-patterns' import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' import type { IStandaloneCodeEditor } from './SQLEditor.types' import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { getEditorSelectionParts } from '@/components/ui/AIEditor/utils' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useProfile } from '@/lib/profile' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' import { useTabsStateSnapshot } from '@/state/tabs' export type MonacoEditorProps = { id: string snippetName: string className?: string editorRef: MutableRefObject monacoRef: MutableRefObject autoFocus?: boolean executeQuery: () => void executeExplainQuery: () => void prettifyQuery: () => void onHasSelection: (value: boolean) => void onMount?: (editor: IStandaloneCodeEditor) => void onPrompt?: (value: { selection: string beforeSelection: string afterSelection: string startLineNumber: number endLineNumber: number }) => void placeholder?: string } const MonacoEditor = ({ id, snippetName, editorRef, monacoRef, autoFocus = true, placeholder = '', className, executeQuery, executeExplainQuery, prettifyQuery, onHasSelection, onPrompt, onMount, }: MonacoEditorProps) => { const router = useRouter() const { profile } = useProfile() const { ref, content } = useParams() const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() const tabsSnap = useTabsStateSnapshot() const aiSnap = useAiAssistantStateSnapshot() const { openSidebar, toggleSidebar } = useSidebarManagerSnapshot() const [intellisenseEnabled] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE, true ) const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE) const isCommandMenuHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN) const setCommandMenuOpen = useSetCommandMenuOpen() // [Joshen] Lodash debounce doesn't seem to be working here, so opting to use useDebounce const [value, setValue] = useState('') const debouncedValue = useDebounce(value, 1000) const snippet = snapV2.snippets[id] const disableEdit = snippet?.snippet.visibility === 'project' && snippet?.snippet.owner_id !== profile?.id const executeQueryRef = useRef(executeQuery) executeQueryRef.current = executeQuery const executeExplainQueryRef = useRef(executeExplainQuery) executeExplainQueryRef.current = executeExplainQuery const prettifyQueryRef = useRef(prettifyQuery) prettifyQueryRef.current = prettifyQuery const aiHotkeyEnabledRef = useRef(isAIAssistantHotkeyEnabled) aiHotkeyEnabledRef.current = isAIAssistantHotkeyEnabled const commandMenuHotkeyEnabledRef = useRef(isCommandMenuHotkeyEnabled) commandMenuHotkeyEnabledRef.current = isCommandMenuHotkeyEnabled const setCommandMenuOpenRef = useRef(setCommandMenuOpen) setCommandMenuOpenRef.current = setCommandMenuOpen const handleEditorOnMount: OnMount = async (editor, monaco) => { editorRef.current = editor monacoRef.current = monaco const model = editorRef.current.getModel() if (model !== null) { monacoRef.current.editor.setModelMarkers(model, 'owner', []) } // Blur the editor on Escape so users can hop out to the rest of the UI. // The precondition defers to Monaco's own Escape consumers (suggest widget, // find widget, parameter hints, snippet/rename mode, inline suggestions) and // to selection/multi-cursor cancellation, so inline features keep working. editor.addCommand( monaco.KeyCode.Escape, () => { ;(document.activeElement as HTMLElement | null)?.blur() }, [ 'editorTextFocus', '!editorHasSelection', '!editorHasMultipleSelections', '!suggestWidgetVisible', '!findWidgetVisible', '!parameterHintsVisible', '!renameInputVisible', '!inSnippetMode', '!accessibilityHelpWidgetVisible', '!inlineSuggestionVisible', ].join(' && ') ) editor.addAction({ id: 'run-query', label: 'Run Query', keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], contextMenuGroupId: 'operation', contextMenuOrder: 0, run: () => { executeQueryRef.current() }, }) editor.addAction({ id: 'run-explain-query', label: 'Run EXPLAIN ANALYZE', keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter], contextMenuGroupId: 'operation', contextMenuOrder: 1, run: () => { executeExplainQueryRef.current() }, }) editor.addAction({ id: 'save-query', label: 'Save Query', keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyS], contextMenuGroupId: 'operation', contextMenuOrder: 0, run: () => { if (snippet) snapV2.addNeedsSaving(snippet.snippet.id) }, }) editor.addAction({ id: 'prettify-query', label: 'Prettify SQL', keybindings: [monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyF], contextMenuGroupId: 'operation', contextMenuOrder: 2, run: () => { prettifyQueryRef.current() }, }) editor.addAction({ id: 'explain-code', label: 'Explain Code', contextMenuGroupId: 'operation', contextMenuOrder: 1, run: () => { const selectedValue = (editorRef?.current as any) .getModel() .getValueInRange((editorRef?.current as any)?.getSelection()) openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) aiSnap.newChat({ name: 'Explain code section', sqlSnippets: [selectedValue], initialInput: 'Can you explain this section to me in more detail?', }) }, }) editor.addAction({ id: 'toggle-ai-assistant', label: 'Toggle AI Assistant', keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI], run: () => { if (aiHotkeyEnabledRef.current) { toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) } }, }) if (onPrompt) { editor.addAction({ id: 'generate-sql', label: 'Generate SQL', keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyK], run: () => { const selectionParts = getEditorSelectionParts(editor) if (selectionParts) onPrompt(selectionParts) }, }) } // Monaco claims Cmd+K as a chord prefix, which swallows the global command // menu shortcut while the editor is focused. Intercept it here and open the // command menu directly so it works the same inside and outside the editor. editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, () => { if (commandMenuHotkeyEnabledRef.current) { setCommandMenuOpenRef.current(true) } }) editor.onDidChangeCursorSelection(({ selection }) => { const noSelection = selection.startLineNumber === selection.endLineNumber && selection.startColumn === selection.endColumn onHasSelection(!noSelection) }) if (autoFocus) { if (editor.getValue().length === 1) editor.setPosition({ lineNumber: 1, column: 2 }) editor.focus() } onMount?.(editor) } function handleEditorChange(value: string | undefined) { tabsSnap.makeActiveTabPermanent() if (id && value) { if (!snippet && ref && profile !== undefined && project !== undefined) { const snippet = createSqlSnippetSkeletonV2({ idOverride: id, name: snippetName, sql: value, owner_id: profile?.id, project_id: project?.id, }) snapV2.addSnippet({ projectRef: ref, snippet }) router.push(`/project/${ref}/sql/${snippet.id}`, undefined, { shallow: true }) } setValue(value) } } useEffect(() => { if (debouncedValue.length > 0 && snippet) { const shouldInvalidate = snippet.snippet.isNotSavedInDatabaseYet snapV2.setSql({ id, sql: value, shouldInvalidate }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedValue]) // if an SQL query is passed by the content parameter, set the editor value to its content. This // is usually used for sending the user to SQL editor from other pages with SQL. useEffect(() => { if (content && content.length > 0) handleEditorChange(content) }, []) return ( <> {disableEdit && ( )} ) } export default MonacoEditor