/
githubmirror
/
ydb-embedded-ui
Обзор
Документация
Войти
/
githubmirror
/
ydb-embedded-ui
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx
804 строки
28 KB
Anton Standrik
feat: add safe external query editor opening API (#4191)
11 авг 2026, 14:46
Не верифицирован
11 авг 2026, 14:46
5295776
Код
Авторство
О чём код?
import React from 'react'; import NiceModal from '@ebay/nice-modal-react'; import type {Settings} from '@gravity-ui/react-data-table'; import {Loader} from '@gravity-ui/uikit'; import {isEqual} from 'lodash'; import {v4 as uuidv4} from 'uuid'; import SplitPane from '../../../../components/SplitPane'; import { useMultiTabQueryEditorEnabled, useStreamingAvailable, useTracingLevelOptionAvailable, } from '../../../../store/reducers/capabilities/hooks'; import type {useQueriesHistory} from '../../../../store/reducers/query/hooks'; import { queryApi, renameQueryTab, selectActiveTab, selectActiveTabId, selectLastExecutedQueryText, selectResult, selectTenantPath, setHistoryCurrentQueryId, setIsDirty, setLastExecutedQueryText, setResultTab, setTenantPath, } from '../../../../store/reducers/query/query'; import type {QueryResult} from '../../../../store/reducers/query/types'; import {setQueryAction} from '../../../../store/reducers/queryActions/queryActions'; import {selectShowPreview, setShowPreview} from '../../../../store/reducers/schema/schema'; import {SETTING_KEYS} from '../../../../store/reducers/settings/constants'; import type {EPathSubType, EPathType} from '../../../../types/api/schema'; import type {QueryAction} from '../../../../types/store/query'; import {cn} from '../../../../utils/cn'; import {DEFAULT_SIZE_RESULT_PANE_KEY} from '../../../../utils/constants'; import { useEventHandler, useQueryExecutionSettings, useQueryStreamingSetting, useResourcePools, useSetting, useTypedDispatch, useTypedSelector, } from '../../../../utils/hooks'; import {useChangedQuerySettings} from '../../../../utils/hooks/useChangedQuerySettings'; import {useLastQueryExecutionSettings} from '../../../../utils/hooks/useLastQueryExecutionSettings'; import { DEFAULT_QUERY_SETTINGS, QUERY_ACTIONS, isQueryCancelledError, isStreamingSupportedForMode, } from '../../../../utils/query'; import {reachMetricaGoal} from '../../../../utils/yaMetrica'; import {useCurrentSchema} from '../../TenantContext'; import type {InitialPaneState} from '../../utils/paneVisibilityToggleHelpers'; import { PaneVisibilityActionTypes, paneVisibilityToggleReducer, } from '../../utils/paneVisibilityToggleHelpers'; import {PreviewContainer} from '../Preview/Preview'; import {QueryEditorControls} from '../QueryEditorControls/QueryEditorControls'; import {QueryResultViewer} from '../QueryResult/QueryResultViewer'; import {RESULT_OPTIONS_IDS} from '../QueryResult/constants'; import {QuerySettingsDialog} from '../QuerySettingsDialog/QuerySettingsDialog'; import {SAVE_QUERY_DIALOG, useSaveQueryWithTabSync} from '../SaveQuery/SaveQuery'; import {getTabTitleForSave} from '../utils/queryTabTitles'; import {useSavedQueries} from '../utils/useSavedQueries'; import {EditorTabs} from './EditorTabs/EditorTabs'; import {RENAME_QUERY_DIALOG} from './EditorTabs/RenameQueryDialog'; import {QueryEditorZeroTabsState} from './QueryEditorZeroTabsState'; import {YqlEditor} from './YqlEditor/YqlEditor'; import {useEditorTabsGlobalHotkeys} from './hooks/useEditorTabsGlobalHotkeys'; import {useQueryPageLeaveGuard} from './hooks/useQueryPageLeaveGuard'; import {useQueryTabsActions} from './hooks/useQueryTabsActions'; import type {QueryExecution} from './types'; import {queryExecutionManagerInstance} from './utils/queryExecutionManager'; import {reachExplainQueryMetricaGoals} from './utils/reachExplainQueryMetricaGoals'; import './QueryEditor.scss'; const b = cn('query-editor'); const STOP_APPEAR_TIMEOUT = 400; const initialTenantCommonInfoState = { triggerExpand: false, triggerCollapse: false, collapsed: true, }; interface QueryEditorProps { changeUserInput: (arg: {input: string}) => void; theme: string; queriesHistory: ReturnType<typeof useQueriesHistory>; type: EPathType | undefined; subType: EPathSubType | undefined; } export default function QueryEditor({ theme, changeUserInput, queriesHistory, type, subType, }: QueryEditorProps) { const dispatch = useTypedDispatch(); const {database, path, databaseFullPath} = useCurrentSchema(); const savedPath = useTypedSelector(selectTenantPath); const activeTabId = useTypedSelector(selectActiveTabId); const result = useTypedSelector(selectResult); const showPreview = useTypedSelector(selectShowPreview); const { historyQueries, historyCurrentQueryId, saveQueryToHistory, updateQueryInHistory, goToPreviousQuery, goToNextQuery, } = queriesHistory; const isResultLoaded = Boolean(result); const [querySettings, setQuerySettings] = useQueryExecutionSettings(); const enableTracingLevel = useTracingLevelOptionAvailable(); const [lastQueryExecutionSettings, setLastQueryExecutionSettings] = useLastQueryExecutionSettings(); const {resetBanner} = useChangedQuerySettings(); const [lastUsedQueryAction, setLastUsedQueryAction] = useSetting<QueryAction>( SETTING_KEYS.LAST_USED_QUERY_ACTION, ); const lastExecutedQueryText = useTypedSelector(selectLastExecutedQueryText) || ''; const [isQueryStreamingEnabled] = useQueryStreamingSetting(); const [binaryDataInPlainTextDisplay] = useSetting<boolean>( SETTING_KEYS.BINARY_DATA_IN_PLAIN_TEXT_DISPLAY, ); const { resourcePools, normalizedResourcePool, isLoading: isResourcePoolsLoading, } = useResourcePools(database, querySettings.resourcePool); const encodeTextWithBase64 = !binaryDataInPlainTextDisplay; const isStreamingEnabled = useStreamingAvailable() && isQueryStreamingEnabled && isStreamingSupportedForMode(querySettings.queryMode); const [sendQuery] = queryApi.useUseSendQueryMutation(); const [streamQuery] = queryApi.useUseStreamQueryMutation(); const isMultiTabQueryEditorEnabled = useMultiTabQueryEditorEnabled(); const { activeTabId: tabsActiveTabId, tabsOrder, handleNewTabClick, handleCloseActiveTab, handleCloseOtherTabs: closeOtherTabs, handleCloseAllTabs, handleDuplicateTab, handleNextTab, handlePreviousTab, } = useQueryTabsActions(); const activeTab = useTypedSelector(selectActiveTab); const {savedQueries} = useSavedQueries(); const createSaveQueryHandler = useSaveQueryWithTabSync(); const hasTabs = tabsOrder.length > 0; const handleGlobalRenameTab = React.useCallback(() => { const tabIdToRename = tabsActiveTabId; if (!tabIdToRename) { return; } NiceModal.show(RENAME_QUERY_DIALOG, { title: activeTab?.title || '', onRename: (title: string) => { dispatch(renameQueryTab({tabId: tabIdToRename, title})); }, }); }, [tabsActiveTabId, activeTab?.title, dispatch]); const handleGlobalDuplicateTab = React.useCallback(() => { if (!tabsActiveTabId) { return; } handleDuplicateTab(tabsActiveTabId); }, [handleDuplicateTab, tabsActiveTabId]); const handleGlobalCloseOtherTabs = React.useCallback(() => { if (!tabsActiveTabId) { return; } closeOtherTabs(tabsActiveTabId); }, [closeOtherTabs, tabsActiveTabId]); const handleGlobalSaveQueryAs = React.useCallback(() => { if (!activeTab) { return; } const defaultQueryName = getTabTitleForSave(activeTab); NiceModal.show(SAVE_QUERY_DIALOG, { savedQueries, onSaveQuery: createSaveQueryHandler(activeTab.id), queryBody: activeTab.input, defaultQueryName, }); }, [activeTab, createSaveQueryHandler, savedQueries]); useEditorTabsGlobalHotkeys(isMultiTabQueryEditorEnabled, { handleNewTab: handleNewTabClick, handleCloseActiveTab, handleRenameTab: handleGlobalRenameTab, handleDuplicateTab: handleGlobalDuplicateTab, handleNextTab, handlePreviousTab, handleCloseOtherTabs: handleGlobalCloseOtherTabs, handleCloseAllTabs, handleSaveQueryAs: handleGlobalSaveQueryAs, }); useQueryPageLeaveGuard(isMultiTabQueryEditorEnabled); const [isEditorReady, setIsEditorReady] = React.useState(false); const handleEditorReady = React.useCallback(() => setIsEditorReady(true), []); // Normalize stored resourcePool if it's not available for current database React.useEffect(() => { if (isResourcePoolsLoading) { return; } if (querySettings.resourcePool === normalizedResourcePool) { return; } setQuerySettings({ ...querySettings, resourcePool: normalizedResourcePool, }); }, [ isResourcePoolsLoading, normalizedResourcePool, querySettings, resourcePools.length, setQuerySettings, ]); const tableSettings = React.useMemo(() => { return isStreamingEnabled ? { displayIndices: { maxIndex: (querySettings.limitRows || DEFAULT_QUERY_SETTINGS.limitRows) + 1, }, } : undefined; }, [isStreamingEnabled, querySettings.limitRows]); React.useEffect(() => { if (savedPath !== database) { dispatch(setTenantPath(database)); } }, [dispatch, database, savedPath]); const [isStoppable, setIsStoppable] = React.useState(Boolean(result?.isLoading)); const stopButtonAppearRef = React.useRef<number | null>(null); const runSetStoppableTimeout = React.useCallback(() => { if (stopButtonAppearRef.current) { window.clearTimeout(stopButtonAppearRef.current); } setIsStoppable(false); stopButtonAppearRef.current = window.setTimeout(() => { setIsStoppable(true); }, STOP_APPEAR_TIMEOUT); }, []); React.useEffect(() => { return () => { if (stopButtonAppearRef.current) { window.clearTimeout(stopButtonAppearRef.current); } }; }, []); const [resultVisibilityState, dispatchResultVisibilityState] = React.useReducer( paneVisibilityToggleReducer, initialTenantCommonInfoState, ); const collapsedRef = React.useRef(resultVisibilityState.collapsed); collapsedRef.current = resultVisibilityState.collapsed; React.useEffect(() => { dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerCollapse); }, []); React.useLayoutEffect(() => { if (isMultiTabQueryEditorEnabled || hasTabs) { return; } handleNewTabClick(); }, [handleNewTabClick, hasTabs, isMultiTabQueryEditorEnabled]); React.useEffect(() => { if (!hasTabs) { return; } if (showPreview || isResultLoaded) { // Only expand to default size if the pane is collapsed. // If the user has manually resized the pane, keep their layout. if (collapsedRef.current) { dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerExpand); } } else { dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerCollapse); } }, [hasTabs, showPreview, isResultLoaded]); const prepareExecuteQueryAction = useEventHandler( ({ execution, actionType, saveToHistory, }: { execution: QueryExecution; actionType: QueryAction; saveToHistory: boolean; }) => { if (!activeTabId) { return undefined; } const {text} = execution; runSetStoppableTimeout(); setLastUsedQueryAction(actionType); dispatch(setLastExecutedQueryText({tabId: activeTabId, queryText: text})); if (!isEqual(lastQueryExecutionSettings, querySettings)) { resetBanner(); setLastQueryExecutionSettings(querySettings); } dispatch(setShowPreview(false)); let historyQueryId: string | undefined; const queryId = uuidv4(); const startTime = Date.now(); if (saveToHistory) { historyQueryId = historyCurrentQueryId ?? uuidv4(); const currentQuery = historyCurrentQueryId ? historyQueries.find((q) => q.queryId === historyCurrentQueryId) : null; const lastQuery = historyQueries.at(-1); if (text === lastQuery?.queryText && !lastQuery.operationId) { // Don't add the same query as the previous one to the query history, // unless it has server-stored results (operationId) — then save every launch. historyQueryId = lastQuery.queryId; // Keep history navigation anchored to the entry we are updating if (historyCurrentQueryId !== lastQuery.queryId) { dispatch(setHistoryCurrentQueryId(lastQuery.queryId)); } } else if (text !== currentQuery?.queryText || currentQuery?.operationId) { // Queries with results stored on the server (operationId) get a separate history // entry per launch, unless they match the most recent history item (handled above). historyQueryId = queryId; saveQueryToHistory(text, queryId, startTime); } dispatch(setIsDirty(false)); } // Only reset pane to default size if it's currently collapsed. // If the user has manually resized the pane, respect their layout. if (resultVisibilityState.collapsed) { dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerExpand); } // Abort previous query if there was any queryExecutionManagerInstance.abortQuery(activeTabId); return { tabId: activeTabId, queryId, historyQueryId, startTime, }; }, ); const updateStoppedQueryInHistory = useEventHandler( ({ error, historyQueryId, startTime, }: { error: unknown; historyQueryId?: string; startTime: number; }) => { const extra = error && typeof error === 'object' && 'extra' in error ? error.extra : undefined; const queryStats = extra && typeof extra === 'object' && 'queryStats' in extra && extra.queryStats && typeof extra.queryStats === 'object' ? extra.queryStats : undefined; const stoppedHistoryQueryId = extra && typeof extra === 'object' && 'historyQueryId' in extra && typeof extra.historyQueryId === 'string' ? extra.historyQueryId : historyQueryId; if (!stoppedHistoryQueryId) { return; } updateQueryInHistory(stoppedHistoryQueryId, { startTime, durationUs: (Date.now() - startTime) * 1000, ...queryStats, status: 'stopped', }); }, ); const runNonStreamingQueryAction = useEventHandler( ({ execution, actionType, tabId, queryId, historyQueryId, startTime, }: { execution: QueryExecution; actionType: QueryAction; tabId: string; queryId: string; historyQueryId?: string; startTime: number; }) => { const {text, range} = execution; reachMetricaGoal('runQuery', {actionType, ...querySettings}); const query = sendQuery({ tabId, actionType, startTime, query: text, database, querySettings, enableTracingLevel, queryId, historyQueryId, base64: encodeTextWithBase64, sourcePosition: range ? {lineNumber: range.startLineNumber, column: range.startColumn} : undefined, }); query .unwrap() .then((data) => { if (data?.historyQueryId) { updateQueryInHistory( data.historyQueryId, data?.queryStats, undefined, data.queryId, ); } }) .catch((error) => { if (isQueryCancelledError(error)) { updateStoppedQueryInHistory({error, historyQueryId, startTime}); return; } if (error?.extra?.historyQueryId) { updateQueryInHistory( error.extra.historyQueryId, error.extra.queryStats, error.extra.operationId, error.extra.queryId, ); } else { // Do not add query stats for failed query console.error('Failed to update query history:', error); } }); queryExecutionManagerInstance.registerQuery(tabId, query, database); }, ); const handleSendExecuteClick = useEventHandler((queryExecution: QueryExecution) => { const execution = prepareExecuteQueryAction({ execution: queryExecution, actionType: QUERY_ACTIONS.execute, saveToHistory: !queryExecution.range, }); if (!execution) { return; } const {tabId, queryId, historyQueryId, startTime} = execution; const {text, range} = queryExecution; if (isStreamingEnabled) { reachMetricaGoal('runQuery', { actionType: QUERY_ACTIONS.execute, isStreaming: true, ...querySettings, }); const query = streamQuery({ tabId, actionType: QUERY_ACTIONS.execute, startTime, query: text, database, querySettings, enableTracingLevel, base64: encodeTextWithBase64, historyQueryId, sourcePosition: range ? {lineNumber: range.startLineNumber, column: range.startColumn} : undefined, }); query .unwrap() .then((data) => { if (data.historyQueryId) { updateQueryInHistory( data.historyQueryId, data.queryStats, data.operationId, data.queryId, ); } }) .catch((error) => { if (isQueryCancelledError(error)) { updateStoppedQueryInHistory({error, historyQueryId, startTime}); return; } if (error?.extra?.historyQueryId) { updateQueryInHistory( error.extra.historyQueryId, error.extra.queryStats, error.extra.operationId, error.extra.queryId, ); } else { // Do not add query stats for failed query console.error('Failed to update query history:', error); } }); queryExecutionManagerInstance.registerQuery(tabId, query, database); } else { runNonStreamingQueryAction({ execution: queryExecution, actionType: QUERY_ACTIONS.execute, tabId, queryId, historyQueryId, startTime, }); } }); const handleRunEditorClick = useEventHandler((text: string) => { handleSendExecuteClick({text}); }); const handleSettingsClick = () => { dispatch(setQueryAction('settings')); }; const runExplainQueryAction = useEventHandler( ({text, actionType}: {text: string; actionType: QueryAction}) => { const execution = prepareExecuteQueryAction({ execution: {text}, actionType, saveToHistory: false, }); if (!execution) { return; } const {tabId, queryId, startTime} = execution; if (actionType === QUERY_ACTIONS.explainAnalyze) { dispatch( setResultTab({ queryType: QUERY_ACTIONS.explainAnalyze, tabId: RESULT_OPTIONS_IDS.simplified, }), ); } reachExplainQueryMetricaGoals(actionType, querySettings); const query = sendQuery({ tabId, actionType, startTime, query: text, database, querySettings, enableTracingLevel, queryId, base64: encodeTextWithBase64, }); queryExecutionManagerInstance.registerQuery(tabId, query, database); }, ); const handleGetExplainQueryClick = useEventHandler((text: string) => { runExplainQueryAction({text, actionType: QUERY_ACTIONS.explain}); }); const handleGetExplainAnalyzeQueryClick = useEventHandler((text: string) => { runExplainQueryAction({text, actionType: QUERY_ACTIONS.explainAnalyze}); }); const onCollapseResultHandler = () => { dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerCollapse); }; const onExpandResultHandler = () => { dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerExpand); }; const onSplitStartDragAdditional = () => { dispatchResultVisibilityState(PaneVisibilityActionTypes.clear); }; const renderControls = () => { return ( <QueryEditorControls handleSendExecuteClick={handleRunEditorClick} onSettingsButtonClick={handleSettingsClick} isLoading={Boolean(result?.isLoading)} isStoppable={isStoppable} handleGetExplainQueryClick={handleGetExplainQueryClick} handleGetExplainAnalyzeQueryClick={handleGetExplainAnalyzeQueryClick} highlightedAction={lastUsedQueryAction} database={database} queryId={result?.queryId} isCurrentQueryStreaming={result?.streamingStatus !== undefined} /> ); }; return ( <div className={b({multiTab: isMultiTabQueryEditorEnabled})}> {hasTabs ? ( <SplitPane direction="vertical" defaultSizePaneKey={DEFAULT_SIZE_RESULT_PANE_KEY} triggerCollapse={resultVisibilityState.triggerCollapse} triggerExpand={resultVisibilityState.triggerExpand} minSize={[0, 52]} collapsedSizes={[100, 0]} onSplitStartDragAdditional={onSplitStartDragAdditional} > <div className={b('pane-wrapper', { top: true, loading: isMultiTabQueryEditorEnabled && !isEditorReady, })} > {isMultiTabQueryEditorEnabled && !isEditorReady ? ( <div className={b('editor-loader')}> <Loader size="l" /> </div> ) : null} {isMultiTabQueryEditorEnabled ? <EditorTabs /> : null} <div className={b('monaco-wrapper')}> <div className={b('monaco')}> <YqlEditor changeUserInput={changeUserInput} theme={theme} handleSendExecuteClick={handleSendExecuteClick} handleGetExplainQueryClick={handleGetExplainQueryClick} handleGetExplainAnalyzeQueryClick={ handleGetExplainAnalyzeQueryClick } historyQueries={historyQueries} goToPreviousQuery={goToPreviousQuery} goToNextQuery={goToNextQuery} onEditorReady={handleEditorReady} /> </div> </div> {renderControls()} </div> <div className={b('pane-wrapper')}> <Result resultVisibilityState={resultVisibilityState} onExpandResultHandler={onExpandResultHandler} onCollapseResultHandler={onCollapseResultHandler} type={type} subType={subType} theme={theme} key={result?.queryId} result={result} database={database} databaseFullPath={databaseFullPath} path={path} showPreview={showPreview} queryText={lastExecutedQueryText} tableSettings={tableSettings} /> </div> </SplitPane> ) : ( <QueryEditorZeroTabsState onCreateTab={handleNewTabClick} /> )} <QuerySettingsDialog /> </div> ); } interface ResultProps { resultVisibilityState: InitialPaneState; onExpandResultHandler: VoidFunction; onCollapseResultHandler: VoidFunction; type?: EPathType; subType?: EPathSubType; theme: string; result?: QueryResult; database: string; databaseFullPath: string; path: string; showPreview?: boolean; queryText: string; tableSettings?: Partial<Settings>; } function Result({ resultVisibilityState, onExpandResultHandler, onCollapseResultHandler, type, subType, theme, result, database, databaseFullPath, path, showPreview, queryText, tableSettings, }: ResultProps) { if (showPreview) { return ( <PreviewContainer database={database} path={path} type={type} subType={subType} databaseFullPath={databaseFullPath} /> ); } if (result) { return ( <QueryResultViewer result={result} resultType={result?.type} theme={theme} database={database} isResultsCollapsed={resultVisibilityState.collapsed} tableSettings={tableSettings} onExpandResults={onExpandResultHandler} onCollapseResults={onCollapseResultHandler} queryText={queryText} /> ); } return null; }