import React from 'react'; import NiceModal from '@ebay/nice-modal-react'; import {createMonacoGhostInstance} from '@ydb-platform/monaco-ghost'; import debounce from 'lodash/debounce'; import throttle from 'lodash/throttle'; import type Monaco from 'monaco-editor'; import {MonacoEditor} from '../../../../../components/MonacoEditor/MonacoEditor'; import {useMultiTabQueryEditorEnabled} from '../../../../../store/reducers/capabilities/hooks'; import { clearPendingSnippet, renameQueryTab, selectActiveTab, selectActiveTabPendingSnippet, selectUserInput, setIsDirty, } from '../../../../../store/reducers/query/query'; import type {QueryInHistory} from '../../../../../store/reducers/query/types'; import {SETTING_KEYS} from '../../../../../store/reducers/settings/constants'; import type {QueryAction} from '../../../../../types/store/query'; import { useEventHandler, useSetting, useTypedDispatch, useTypedSelector, } from '../../../../../utils/hooks'; import {YQL_LANGUAGE_ID} from '../../../../../utils/monaco/constats'; import {useUpdateErrorsHighlighting} from '../../../../../utils/monaco/highlightErrors'; import {QUERY_ACTIONS} from '../../../../../utils/query'; import {SAVE_QUERY_DIALOG, useSaveQueryWithTabSync} from '../../SaveQuery/SaveQuery'; import i18n from '../../i18n'; import {getTabTitleForSave} from '../../utils/queryTabTitles'; import {useSavedQueries} from '../../utils/useSavedQueries'; import {RENAME_QUERY_DIALOG} from '../EditorTabs/RenameQueryDialog'; import {useCodeAssistHelpers} from '../hooks/useCodeAssistHelpers'; import {useEditorOptions} from '../hooks/useEditorOptions'; import {useQueryTabsActions} from '../hooks/useQueryTabsActions'; import type {QueryExecution} from '../types'; import {TabsManager} from '../utils/tabsManager'; import type {CurrentYqlStatement} from './currentStatement'; import {extractYqlStatements, findYqlStatementAtOffset} from './currentStatement'; import {getKeyBindings} from './keybindings'; const CONTEXT_MENU_GROUP_ID = 'navigation'; const AUTOCOMPLETE_OPEN_PARENTHESIS = '('; const AUTOCOMPLETE_CLOSE_PARENTHESIS = ')'; const TRIGGER_SUGGEST_ACTION_ID = 'editor.action.triggerSuggest'; const TRIGGER_SUGGEST_SOURCE = 'ydb-parenthesis-autocomplete'; const CURRENT_STATEMENT_RECALCULATION_DELAY = 150; type SnippetController = {insert: (snippet: string) => void}; type PendingSnippetKey = {tabId: string; snippet: string}; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } function isSnippetController(value: unknown): value is SnippetController { if (!isRecord(value)) { return false; } return typeof value.insert === 'function'; } interface YqlEditorProps { changeUserInput: (arg: {input: string}) => void; theme: string; handleGetExplainQueryClick: (text: string) => void; handleSendExecuteClick: (execution: QueryExecution) => void; handleGetExplainAnalyzeQueryClick: (text: string) => void; historyQueries: QueryInHistory[]; goToPreviousQuery: () => void; goToNextQuery: () => void; onEditorReady?: () => void; } export function YqlEditor({ changeUserInput, theme, handleSendExecuteClick, handleGetExplainQueryClick, handleGetExplainAnalyzeQueryClick, historyQueries, goToPreviousQuery, goToNextQuery, onEditorReady, }: YqlEditorProps) { const input = useTypedSelector(selectUserInput); const activeTab = useTypedSelector(selectActiveTab); const pendingSnippet = useTypedSelector(selectActiveTabPendingSnippet); const {savedQueries} = useSavedQueries(); const createSaveQueryHandler = useSaveQueryWithTabSync(); const { activeTabId, tabsOrder, handleNewTabClick, handleCloseActiveTab, handleCloseOtherTabs, handleCloseAllTabs, handleDuplicateTab, handleNextTab, handlePreviousTab, } = useQueryTabsActions(); const dispatch = useTypedDispatch(); const [monacoGhostInstance, setMonacoGhostInstance] = React.useState>(); const [isCodeAssistEnabled] = useSetting(SETTING_KEYS.ENABLE_CODE_ASSISTANT); const [enableAutocomplete] = useSetting(SETTING_KEYS.ENABLE_AUTOCOMPLETE); const editorOptions = useEditorOptions(); const updateErrorsHighlighting = useUpdateErrorsHighlighting(); const editorRef = React.useRef(null); const monacoRef = React.useRef(null); const tabsManagerRef = React.useRef(new TabsManager()); const appliedPendingSnippetRef = React.useRef(null); const currentStatementCleanupRef = React.useRef(); const [isEditorMounted, setIsEditorMounted] = React.useState(false); React.useEffect(() => { // onEditorReady removes the parent's visibility:hidden loading state. // Focus after that render commits; focusing during editorDidMount is too early. if (isEditorMounted) { editorRef.current?.focus(); } }, [isEditorMounted]); const isMultiTabQueryEditorEnabled = useMultiTabQueryEditorEnabled(); const isAutocompleteEnabled = useEventHandler(() => Boolean(enableAutocomplete)); const applyPendingSnippet = React.useCallback( (editor: Monaco.editor.IStandaloneCodeEditor, tabId: string, snippet: string): boolean => { const alreadyApplied = appliedPendingSnippetRef.current?.tabId === tabId && appliedPendingSnippetRef.current?.snippet === snippet; if (alreadyApplied) { return false; } const contribution = editor.getContribution('snippetController2'); if (!isSnippetController(contribution)) { return false; } editor.focus(); editor.setValue(''); contribution.insert(snippet); appliedPendingSnippetRef.current = {tabId, snippet}; dispatch(setIsDirty(false)); dispatch(clearPendingSnippet({tabId})); return true; }, [dispatch], ); React.useEffect(() => { if (!isMultiTabQueryEditorEnabled || !activeTabId) { return; } const editor = editorRef.current; const monaco = monacoRef.current; if (!editor || !monaco) { return; } tabsManagerRef.current.setActiveTabModel({ tabId: activeTabId, nextValue: input, editor, monaco, }); }, [activeTabId, input, isMultiTabQueryEditorEnabled]); React.useEffect(() => { if (!isMultiTabQueryEditorEnabled) { return; } tabsManagerRef.current.disposeRemovedTabs(tabsOrder); }, [isMultiTabQueryEditorEnabled, tabsOrder]); React.useEffect(() => { if (!activeTabId || !pendingSnippet || !isEditorMounted) { if (!pendingSnippet) { appliedPendingSnippetRef.current = null; } return; } const editor = editorRef.current; if (!editor) { return; } applyPendingSnippet(editor, activeTabId, pendingSnippet); }, [activeTabId, applyPendingSnippet, isEditorMounted, pendingSnippet]); const [lastUsedQueryAction] = useSetting(SETTING_KEYS.LAST_USED_QUERY_ACTION); const getLastQueryText = useEventHandler(() => { if (!historyQueries || historyQueries.length === 0) { return ''; } return historyQueries[historyQueries.length - 1].queryText; }); const handleSendQuery = useEventHandler(() => { if (lastUsedQueryAction === QUERY_ACTIONS.explain) { handleGetExplainQueryClick(input); } else if (lastUsedQueryAction === QUERY_ACTIONS.explainAnalyze) { handleGetExplainAnalyzeQueryClick(input); } else { handleSendExecuteClick({text: input}); } }); const handleNewTabAction = useEventHandler(() => { handleNewTabClick(); }); const handleCloseActiveTabAction = useEventHandler(() => { handleCloseActiveTab(); }); const handleNextTabAction = useEventHandler(() => { handleNextTab(); }); const handlePreviousTabAction = useEventHandler(() => { handlePreviousTab(); }); const handleDuplicateActiveTabAction = useEventHandler(() => { handleDuplicateTab(activeTabId); }); const handleCloseOtherTabsAction = useEventHandler(() => { handleCloseOtherTabs(activeTabId); }); const handleCloseAllTabsAction = useEventHandler(() => { handleCloseAllTabs(); }); const handleRenameTabAction = useEventHandler(() => { const tabIdToRename = activeTabId; if (!tabIdToRename) { return; } NiceModal.show(RENAME_QUERY_DIALOG, { title: activeTab?.title || '', onRename: (title: string) => { dispatch(renameQueryTab({tabId: tabIdToRename, title})); }, }); }); const handleSaveQueryAsAction = useEventHandler(() => { if (!activeTab) { return; } const defaultQueryName = getTabTitleForSave(activeTab); NiceModal.show(SAVE_QUERY_DIALOG, { savedQueries, onSaveQuery: createSaveQueryHandler(activeTab.id), queryBody: input, defaultQueryName, }); }); const editorWillUnmount = () => { currentStatementCleanupRef.current?.(); currentStatementCleanupRef.current = undefined; window.ydbEditor = undefined; tabsManagerRef.current.disposeAll(); editorRef.current = null; monacoRef.current = null; appliedPendingSnippetRef.current = null; setIsEditorMounted(false); }; const {monacoGhostConfig, prepareUserQueriesCache} = useCodeAssistHelpers(historyQueries); React.useEffect(() => { if (monacoGhostInstance && isCodeAssistEnabled) { monacoGhostInstance.register(monacoGhostConfig); prepareUserQueriesCache(); } return () => { monacoGhostInstance?.unregister(); }; }, [isCodeAssistEnabled, monacoGhostConfig, monacoGhostInstance, prepareUserQueriesCache]); const editorDidMount = (editor: Monaco.editor.IStandaloneCodeEditor, monaco: typeof Monaco) => { currentStatementCleanupRef.current?.(); currentStatementCleanupRef.current = undefined; window.ydbEditor = editor; editorRef.current = editor; monacoRef.current = monaco; setIsEditorMounted(true); if (isMultiTabQueryEditorEnabled && activeTabId) { tabsManagerRef.current.setActiveTabModel({ tabId: activeTabId, nextValue: input, editor, monaco, }); } const keybindings = getKeyBindings(monaco); monaco.editor.registerCommand('insertSnippetToEditor', (_asessor, snippet: string) => { //suggestController is not properly typed yet in monaco-editor package const contribution = editor.getContribution('snippetController2'); if (isSnippetController(contribution)) { editor.focus(); editor.setValue(''); contribution.insert(snippet); dispatch(setIsDirty(false)); } }); if (window.api.codeAssist) { setMonacoGhostInstance(createMonacoGhostInstance(editor)); } initAutocompleteInParenthesesHandler(editor, isAutocompleteEnabled); initResizeHandler(editor); if (!isMultiTabQueryEditorEnabled) { initUserPrompt(editor, getLastQueryText); } editor.addAction({ id: 'sendQuery', label: i18n('action_send-query'), keybindings: [keybindings.sendQuery], // A precondition for this action. precondition: undefined, // A rule to evaluate on top of the precondition in order to dispatch the keybindings. keybindingContext: undefined, contextMenuGroupId: CONTEXT_MENU_GROUP_ID, contextMenuOrder: 1, // Method that will be executed when the action is triggered. // @param editor The editor instance is passed in as a convenience run: () => handleSendQuery(), }); const currentStatementDecoration = editor.createDecorationsCollection(); const canSendQueryFragment = editor.createContextKey( 'canSendQueryFragment', false, ); let activeModelText = ''; let statementPositions: ReturnType = []; let currentStatement: (CurrentYqlStatement & {range: Monaco.IRange}) | undefined; let decoratedModel: Monaco.editor.ITextModel | null = null; let decoratedRange: Monaco.IRange | undefined; let isStatementIndexStale = true; const statementIndexCache = new WeakMap< Monaco.editor.ITextModel, { versionId: number; positions: ReturnType; } >(); const getOnlySelection = (currentEditor: Monaco.editor.ICodeEditor) => { const selections = currentEditor.getSelections(); return selections?.length === 1 ? selections[0] : undefined; }; const updateSendQueryFragmentAvailability = () => { const selection = getOnlySelection(editor); const hasStaleModelText = Boolean( isStatementIndexStale && editor.getModel()?.getValueLength(), ); canSendQueryFragment.set( Boolean( selection && (!selection.isEmpty() || currentStatement || hasStaleModelText), ), ); }; const clearCurrentStatementDecoration = () => { currentStatementDecoration.clear(); decoratedModel = null; decoratedRange = undefined; }; const updateCurrentStatement = () => { const model = editor.getModel(); const position = editor.getPosition(); if (!model || !position) { currentStatement = undefined; clearCurrentStatementDecoration(); updateSendQueryFragmentAvailability(); return; } const statement = findYqlStatementAtOffset( activeModelText, model.getOffsetAt(position), statementPositions, ); if (!statement) { currentStatement = undefined; clearCurrentStatementDecoration(); updateSendQueryFragmentAvailability(); return; } const start = model.getPositionAt(statement.startIndex); const end = model.getPositionAt(statement.endIndex); const range: Monaco.IRange = { startLineNumber: start.lineNumber, startColumn: start.column, endLineNumber: end.lineNumber, endColumn: end.column, }; currentStatement = {...statement, range}; if (statementPositions.length <= 1) { clearCurrentStatementDecoration(); updateSendQueryFragmentAvailability(); return; } if ( decoratedModel !== model || !decoratedRange || decoratedRange.startLineNumber !== range.startLineNumber || decoratedRange.startColumn !== range.startColumn || decoratedRange.endLineNumber !== range.endLineNumber || decoratedRange.endColumn !== range.endColumn ) { currentStatementDecoration.set([ { range, options: { className: 'ydb-current-query-highlight', shouldFillLineOnLineBreak: true, stickiness: monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, }, }, ]); decoratedModel = model; decoratedRange = range; } updateSendQueryFragmentAvailability(); }; const recalculateStatements = () => { const model = editor.getModel(); if (!model) { activeModelText = ''; statementPositions = []; currentStatement = undefined; isStatementIndexStale = false; clearCurrentStatementDecoration(); updateSendQueryFragmentAvailability(); return; } const versionId = model.getVersionId(); const cachedStatementIndex = statementIndexCache.get(model); if (cachedStatementIndex?.versionId === versionId) { activeModelText = model.getValue(); statementPositions = cachedStatementIndex.positions; } else { activeModelText = editor.getValue(); statementPositions = extractYqlStatements(activeModelText); statementIndexCache.set(model, { versionId, positions: statementPositions, }); } isStatementIndexStale = false; updateCurrentStatement(); }; const recalculateStatementsDebounced = debounce( recalculateStatements, CURRENT_STATEMENT_RECALCULATION_DELAY, ); const invalidateStatementIndex = () => { const model = editor.getModel(); if (model) { statementIndexCache.delete(model); } activeModelText = ''; statementPositions = []; currentStatement = undefined; isStatementIndexStale = true; clearCurrentStatementDecoration(); updateSendQueryFragmentAvailability(); recalculateStatementsDebounced(); }; const restoreOrRecalculateStatementIndex = () => { recalculateStatementsDebounced.cancel(); const model = editor.getModel(); const cachedStatementIndex = model && statementIndexCache.get(model); if (model && cachedStatementIndex?.versionId === model.getVersionId()) { activeModelText = model.getValue(); statementPositions = cachedStatementIndex.positions; currentStatement = undefined; isStatementIndexStale = false; updateCurrentStatement(); return; } invalidateStatementIndex(); }; const currentStatementDisposables = [ editor.onDidChangeModel(restoreOrRecalculateStatementIndex), editor.onDidChangeModelContent((event) => { if (event.isFlush) { decoratedModel = null; decoratedRange = undefined; } invalidateStatementIndex(); }), editor.onDidChangeCursorPosition(updateCurrentStatement), editor.onDidChangeCursorSelection(updateCurrentStatement), ]; restoreOrRecalculateStatementIndex(); currentStatementCleanupRef.current = () => { recalculateStatementsDebounced.cancel(); currentStatementDisposables.forEach((disposable) => disposable.dispose()); canSendQueryFragment.reset(); currentStatementDecoration.clear(); }; editor.addAction({ id: 'sendSelectedQuery', label: i18n('action_send-selected-query'), keybindings: [keybindings.sendSelectedQuery], precondition: 'canSendQueryFragment', contextMenuGroupId: CONTEXT_MENU_GROUP_ID, contextMenuOrder: 1, run: (currentEditor) => { const model = currentEditor.getModel(); const selection = getOnlySelection(currentEditor); if (!model || !selection) { return; } if (!selection.isEmpty()) { handleSendExecuteClick({ text: model.getValueInRange(selection), range: selection, }); return; } if (isStatementIndexStale) { recalculateStatementsDebounced.cancel(); recalculateStatements(); } if (currentStatement) { handleSendExecuteClick({ text: currentStatement.text, range: currentStatement.range, }); } }, }); editor.addAction({ id: 'previous-query', label: i18n('action_open-previous-query'), contextMenuGroupId: CONTEXT_MENU_GROUP_ID, contextMenuOrder: 2, run: () => { goToPreviousQuery(); }, }); editor.addAction({ id: 'next-query', label: i18n('action_open-next-query'), contextMenuGroupId: CONTEXT_MENU_GROUP_ID, contextMenuOrder: 3, run: () => { goToNextQuery(); }, }); editor.addAction({ id: 'save-query', label: i18n('action_save-query'), keybindings: [keybindings.saveQuery], run: () => handleSaveQueryAsAction(), }); editor.addAction({ id: 'openKeyboardShortcutsPanel', label: i18n('action_open-keyboard-shortcuts'), keybindings: [keybindings.shortcutsHotkey], contextMenuGroupId: CONTEXT_MENU_GROUP_ID, contextMenuOrder: 4, run: () => { // Dispatch an event that can be caught by the AsideNavigation component const event = new CustomEvent('openKeyboardShortcutsPanel'); window.dispatchEvent(event); }, }); if (isMultiTabQueryEditorEnabled) { editor.addAction({ id: 'newEditorTab', label: i18n('action_open-new-editor-tab'), keybindings: [keybindings.newTab], run: () => handleNewTabAction(), }); editor.addAction({ id: 'closeEditorTab', label: i18n('action_close-editor-tab'), keybindings: [keybindings.closeTab], run: () => handleCloseActiveTabAction(), }); editor.addAction({ id: 'renameEditorTab', label: i18n('action_rename-query'), keybindings: [keybindings.renameTab], run: () => handleRenameTabAction(), }); editor.addAction({ id: 'duplicateEditorTab', label: i18n('action_duplicate-query-tab'), run: () => handleDuplicateActiveTabAction(), }); editor.addAction({ id: 'nextEditorTab', label: i18n('action_open-next-editor-tab'), keybindings: [keybindings.nextTab], run: () => handleNextTabAction(), }); editor.addAction({ id: 'previousEditorTab', label: i18n('action_open-previous-editor-tab'), keybindings: [keybindings.previousTab], run: () => handlePreviousTabAction(), }); editor.addAction({ id: 'closeOtherEditorTabs', label: i18n('action_close-other-query-tabs'), keybindings: [keybindings.closeOtherTabs], run: () => handleCloseOtherTabsAction(), }); editor.addAction({ id: 'closeAllEditorTabs', label: i18n('action_close-all-query-tabs'), keybindings: [keybindings.closeAllTabs], run: () => handleCloseAllTabsAction(), }); editor.addAction({ id: 'saveQueryAs', label: i18n('action_save-query-as'), keybindings: [keybindings.saveQueryAs], run: () => handleSaveQueryAsAction(), }); } onEditorReady?.(); }; const onChange = (newValue: string) => { updateErrorsHighlighting(); changeUserInput({input: newValue}); }; return ( ); } function initAutocompleteInParenthesesHandler( editor: Monaco.editor.IStandaloneCodeEditor, isAutocompleteEnabled: () => boolean, ) { // Use content changes as a guard for cursor changes: cursor events alone would also fire // when the user clicks or navigates into existing empty parentheses. We only want to // trigger suggestions after a just-typed opening parenthesis or Monaco auto-closing `()`. let shouldTriggerSuggest = false; const modelContentDisposable = editor.onDidChangeModelContent(({changes}) => { shouldTriggerSuggest = changes.some(({text}) => { return text === AUTOCOMPLETE_OPEN_PARENTHESIS || text === '()'; }); }); const cursorPositionDisposable = editor.onDidChangeCursorPosition(({position}) => { if (!shouldTriggerSuggest) { return; } shouldTriggerSuggest = false; if (!isAutocompleteEnabled()) { return; } const model = editor.getModel(); if (!model) { return; } const lineContent = model.getLineContent(position.lineNumber); const nextCharacter = lineContent[position.column - 1]; if (nextCharacter !== AUTOCOMPLETE_CLOSE_PARENTHESIS) { return; } editor.trigger(TRIGGER_SUGGEST_SOURCE, TRIGGER_SUGGEST_ACTION_ID, undefined); }); editor.onDidDispose(() => { modelContentDisposable.dispose(); cursorPositionDisposable.dispose(); }); } function initResizeHandler(editor: Monaco.editor.IStandaloneCodeEditor) { const layoutEditor = throttle(() => { editor.layout(); }, 100); editor.layout(); window.addEventListener('resize', layoutEditor); editor.onDidDispose(() => { window.removeEventListener('resize', layoutEditor); }); } function initUserPrompt(editor: Monaco.editor.IStandaloneCodeEditor, getInitialText: () => string) { setUserPrompt(editor.getValue(), getInitialText()); editor.onDidChangeModelContent(() => { setUserPrompt(editor.getValue(), getInitialText()); }); editor.onDidDispose(() => { window.onbeforeunload = null; }); } function setUserPrompt(text: string, initialText: string) { const hasUnsavedInput = text ? text !== initialText : false; if (hasUnsavedInput) { window.onbeforeunload = (e) => { e.preventDefault(); // Chrome requires returnValue to be set const unloadEvent = e; unloadEvent.returnValue = ''; }; } else { window.onbeforeunload = null; } }