From 4639d4194cd4bf5c242bbe2ceaead7766914d971 Mon Sep 17 00:00:00 2001 From: simbiozizv Date: Thu, 30 Jan 2025 11:59:53 +0300 Subject: [PATCH] feat(Queries): inline suggestions [YTFRONT-4612] --- packages/ui/src/server/ServerFactory.ts | 5 + .../ui/src/server/components/layout-config.ts | 2 + .../server/controllers/query-suggestions.ts | 49 ++++++++++ packages/ui/src/server/routes.ts | 4 + packages/ui/src/shared/suggestApi.ts | 45 +++++++++ .../monaco-yql-languages/_.contribution.ts | 12 +++ .../clickhouse/clickhouse.contribution.ts | 2 + .../yql/yql.contribution.ts | 2 + .../query-tracker/QueryEditor/QueryEditor.tsx | 14 ++- .../querySuggestionsModule/api.ts | 26 ++++++ .../createInlineSuggestions.ts | 93 +++++++++++++++++++ .../useMonacoQuerySuggestions.ts | 52 +++++++++++ 12 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/server/controllers/query-suggestions.ts create mode 100644 packages/ui/src/shared/suggestApi.ts create mode 100644 packages/ui/src/ui/pages/query-tracker/querySuggestionsModule/api.ts create mode 100644 packages/ui/src/ui/pages/query-tracker/querySuggestionsModule/createInlineSuggestions.ts create mode 100644 packages/ui/src/ui/pages/query-tracker/querySuggestionsModule/useMonacoQuerySuggestions.ts diff --git a/packages/ui/src/server/ServerFactory.ts b/packages/ui/src/server/ServerFactory.ts index 1adb65972..770b0b936 100644 --- a/packages/ui/src/server/ServerFactory.ts +++ b/packages/ui/src/server/ServerFactory.ts @@ -7,6 +7,7 @@ import renderLayout, {AppLayoutConfig} from './render-layout'; import {isLocalModeByEnvironment} from './utils'; import {VcsApi} from '../shared/vcs'; import {CustomVCSType, VCSSettings} from '../shared/ui-settings'; +import {SuggestApi} from '../shared/suggestApi'; export interface ServerFactory { getExtraRootPages(): Array; @@ -18,6 +19,7 @@ export interface ServerFactory { vcs: Omit, token?: string, ): VcsApi | undefined; + createQuerySuggestApi(): SuggestApi | undefined; } let app: ExpressKit; @@ -52,6 +54,9 @@ const serverFactory: ServerFactory = { createCustomVcsApi() { return undefined; }, + createQuerySuggestApi() { + return undefined; + }, }; function configureServerFactoryItem( diff --git a/packages/ui/src/server/components/layout-config.ts b/packages/ui/src/server/components/layout-config.ts index ae6faa99b..f34975460 100644 --- a/packages/ui/src/server/components/layout-config.ts +++ b/packages/ui/src/server/components/layout-config.ts @@ -6,6 +6,7 @@ import {isUserColumnPresetsEnabled} from '../controllers/table-column-preset'; import {getInterfaceVersion, isProductionEnv} from '../utils'; import {getAuthWay} from '../utils/authorization'; import {getOAuthSettings, isOAuthAllowed} from './oauth'; +import ServerFactory from '../ServerFactory'; interface Params { name?: string; @@ -70,6 +71,7 @@ export async function getLayoutConfig(req: Request, params: Params): Promise { + const suggestApi = ServerFactory.createQuerySuggestApi(); + + if (!suggestApi) { + throw new ErrorWithCode(400, 'Query suggest api is not configured'); + } + + return suggestApi; +}; + +export const getQuerySuggestions = async (req: Request, res: Response) => { + try { + const suggestApi = getQuerySuggestApi(); + const requestId = req.ctx.getMetadata()['x-request-id']; + const {contextId, query, line, column, engine} = req.query as Omit< + QuerySuggestionsData, + 'requestId' + >; + + const data = await suggestApi.getQuerySuggestions(req, { + requestId, + contextId, + query, + line, + column, + engine, + }); + res.status(200).json(data); + } catch (e) { + sendAndLogError(req.ctx, res, 500, e as Error); + } +}; + +export const sendTelemetry = async (req: Request, res: Response) => { + try { + const suggestApi = getQuerySuggestApi(); + const telemetry: QuerySuggestTelemetryData = req.body.telemetry; + + await suggestApi.sendTelemetry(req, telemetry); + res.status(200).json({success: true}); + } catch (e) { + sendAndLogError(req.ctx, res, 500, e as Error); + } +}; diff --git a/packages/ui/src/server/routes.ts b/packages/ui/src/server/routes.ts index d152c524f..af7419df7 100644 --- a/packages/ui/src/server/routes.ts +++ b/packages/ui/src/server/routes.ts @@ -34,6 +34,7 @@ import { removeToken, } from './controllers/vcs'; import {ytTabletErrorsApi} from './controllers/yt-tablet-errors-api'; +import {getQuerySuggestions, sendTelemetry} from './controllers/query-suggestions'; const HOME_INDEX_TARGET: AppRouteDescription = {handler: homeIndexFactory(), ui: true}; @@ -62,6 +63,9 @@ const routes: AppRoutes = { 'GET /api/vcs/branches': {handler: getBranches}, 'GET /api/vcs/tokens-availability': {handler: getVcsTokensAvailability}, + 'GET /api/query-suggestions/suggest': {handler: getQuerySuggestions}, + 'POST /api/query-suggestions/telemetry': {handler: sendTelemetry}, + 'POST /api/yt/:ytAuthCluster/change-password': {handler: handleChangePassword, ui: true}, 'POST /api/remote-copy': {handler: handleRemoteCopy}, diff --git a/packages/ui/src/shared/suggestApi.ts b/packages/ui/src/shared/suggestApi.ts new file mode 100644 index 000000000..9d37022a5 --- /dev/null +++ b/packages/ui/src/shared/suggestApi.ts @@ -0,0 +1,45 @@ +import {Request} from '@gravity-ui/expresskit'; + +export interface SuggestApi { + getQuerySuggestions( + req: Request, + queryData: QuerySuggestionsData, + ): Promise<{items: string[]; requestId: string}>; + sendTelemetry(req: Request, telemetryData: QuerySuggestTelemetryData): Promise; +} + +export type QuerySuggestionsData = { + requestId: string; + contextId: string; + query: string; + line: string; + column: string; + engine: string; +}; + +export type TelemetryData = { + requestId: string; + timestamp: number; +}; + +export type AcceptedTelemetryData = TelemetryData & { + type: 'accepted'; + acceptedText: string; + convertedText: string; +}; + +export type DiscardedTelemetryData = TelemetryData & { + type: 'discarded'; + reason: 'OnCancel'; + discardedText: string; +}; + +export type IgnoredTelemetryData = TelemetryData & { + type: 'ignored'; + ignoredText: string; +}; + +export type QuerySuggestTelemetryData = + | AcceptedTelemetryData + | DiscardedTelemetryData + | IgnoredTelemetryData; diff --git a/packages/ui/src/ui/libs/monaco-yql-languages/_.contribution.ts b/packages/ui/src/ui/libs/monaco-yql-languages/_.contribution.ts index b6503be63..097c56b70 100644 --- a/packages/ui/src/ui/libs/monaco-yql-languages/_.contribution.ts +++ b/packages/ui/src/ui/libs/monaco-yql-languages/_.contribution.ts @@ -15,6 +15,12 @@ interface ILangImpl { ) => | {suggestions: languages.CompletionItem[]} | Promise<{suggestions: languages.CompletionItem[]}>; + provideInlineSuggestionsFunction?: ( + model: editor.ITextModel, + monacoCursorPosition: Position, + _context: languages.InlineCompletionContext, + _token: CancellationToken, + ) => Promise<{items: languages.InlineCompletion[]}>; } const languageDefinitions: {[languageId: string]: ILang} = {}; @@ -82,6 +88,12 @@ export function registerLanguage(def: ILang): void { provideCompletionItems: mod.provideSuggestionsFunction, }); } + if (mod.provideInlineSuggestionsFunction) { + languages.registerInlineCompletionsProvider(languageId, { + provideInlineCompletions: mod.provideInlineSuggestionsFunction, + freeInlineCompletions: () => {}, + }); + } }); } diff --git a/packages/ui/src/ui/libs/monaco-yql-languages/clickhouse/clickhouse.contribution.ts b/packages/ui/src/ui/libs/monaco-yql-languages/clickhouse/clickhouse.contribution.ts index 633097f93..d703ae43c 100644 --- a/packages/ui/src/ui/libs/monaco-yql-languages/clickhouse/clickhouse.contribution.ts +++ b/packages/ui/src/ui/libs/monaco-yql-languages/clickhouse/clickhouse.contribution.ts @@ -10,6 +10,7 @@ import {createProvideSuggestionsFunction} from '../helpers/createProvideSuggesti import {generateClickhouseOldSafariSuggestions} from './clickhouse.keywords'; import {MonacoLanguage} from '../../../constants/monaco'; import {QueryEngine} from '../../../pages/query-tracker/module/engines'; +import {createInlineSuggestions} from '../../../pages/query-tracker/querySuggestionsModule/createInlineSuggestions'; registerLanguage({ id: MonacoLanguage.CHYT, @@ -29,6 +30,7 @@ registerLanguage({ QueryEngine.CHYT, ) : generateClickhouseOldSafariSuggestions, + provideInlineSuggestionsFunction: createInlineSuggestions(QueryEngine.CHYT), }; }, }); diff --git a/packages/ui/src/ui/libs/monaco-yql-languages/yql/yql.contribution.ts b/packages/ui/src/ui/libs/monaco-yql-languages/yql/yql.contribution.ts index d8fded4aa..ef534a3cc 100644 --- a/packages/ui/src/ui/libs/monaco-yql-languages/yql/yql.contribution.ts +++ b/packages/ui/src/ui/libs/monaco-yql-languages/yql/yql.contribution.ts @@ -3,6 +3,7 @@ import {createProvideSuggestionsFunction} from '../helpers/createProvideSuggesti import {MonacoLanguage} from '../../../constants/monaco'; import {generateYqlOldSafariSuggestion} from './yql.keywords'; import {QueryEngine} from '../../../pages/query-tracker/module/engines'; +import {createInlineSuggestions} from '../../../pages/query-tracker/querySuggestionsModule/createInlineSuggestions'; registerLanguage({ id: MonacoLanguage.YQL, @@ -19,6 +20,7 @@ registerLanguage({ provideSuggestionsFunction: autocomplete ? createProvideSuggestionsFunction(autocomplete.parseYqlQuery, QueryEngine.YQL) : generateYqlOldSafariSuggestion, + provideInlineSuggestionsFunction: createInlineSuggestions(QueryEngine.YQL), }; }, }); diff --git a/packages/ui/src/ui/pages/query-tracker/QueryEditor/QueryEditor.tsx b/packages/ui/src/ui/pages/query-tracker/QueryEditor/QueryEditor.tsx index 27808c4e3..654553211 100644 --- a/packages/ui/src/ui/pages/query-tracker/QueryEditor/QueryEditor.tsx +++ b/packages/ui/src/ui/pages/query-tracker/QueryEditor/QueryEditor.tsx @@ -7,9 +7,9 @@ import {Button, Flex, Icon, Loader} from '@gravity-ui/uikit'; import playIcon from '../../../assets/img/svg/play.svg'; import {useDispatch, useSelector} from 'react-redux'; import { - getQuery, getQueryEditorErrors, getQueryEngine, + getQueryId, getQueryText, isQueryExecuted, isQueryLoading, @@ -41,6 +41,7 @@ import {WaitForFont} from '../../../containers/WaitForFont/WaitForFont'; import {getHashLineNumber} from './helpers/getHashLineNumber'; import {makeHighlightedLineDecorator} from './helpers/makeHighlightedLineDecorator'; import {getDecorationsWithoutHighlight} from './helpers/getDecorationsWithoutHighlight'; +import {useMonacoQuerySuggestions} from '../querySuggestionsModule/useMonacoQuerySuggestions'; const b = block('query-container'); @@ -65,7 +66,7 @@ const QueryEditorView = React.memo(function QueryEditorView({ const [changed, setChanged] = useState(false); const editorRef = useRef(); const {setEditor} = useMonaco(); - const activeQuery = useSelector(getQuery); + const id = useSelector(getQueryId); const text = useSelector(getQueryText); const engine = useSelector(getQueryEngine); const editorErrors = useSelector(getQueryEditorErrors); @@ -76,6 +77,7 @@ const QueryEditorView = React.memo(function QueryEditorView({ undefined, ); const model = editorRef.current?.getModel(); + useMonacoQuerySuggestions(editorRef.current); const runQueryCallback = useCallback(() => { dispatch(runQuery(onStartQuery)); @@ -84,7 +86,7 @@ const QueryEditorView = React.memo(function QueryEditorView({ useEffect(() => { editorRef.current?.focus(); editorRef.current?.setScrollTop(0); - }, [activeQuery?.id]); + }, [id]); useEffect(() => { if (editorRef.current) { @@ -167,6 +169,12 @@ const QueryEditorView = React.memo(function QueryEditorView({ minimap: { enabled: true, }, + inlineSuggest: { + enabled: true, + showToolbar: 'always', + mode: 'subword', + keepOnBlur: true, + }, }; }, [engine]); diff --git a/packages/ui/src/ui/pages/query-tracker/querySuggestionsModule/api.ts b/packages/ui/src/ui/pages/query-tracker/querySuggestionsModule/api.ts new file mode 100644 index 000000000..3416e5970 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/querySuggestionsModule/api.ts @@ -0,0 +1,26 @@ +import {QueryEngine} from '../module/engines'; +import axios from 'axios'; +import {QuerySuggestTelemetryData} from '../../../../shared/suggestApi'; + +export type QuerySuggestionProps = { + contextId: string; + query: string; + line: number; + column: number; + engine: QueryEngine; +}; +const BASE_PATH = '/api/query-suggestions'; + +export const getQuerySuggestions = async (data: QuerySuggestionProps) => { + const response = await axios.get<{items: string[]; requestId: string}>(`${BASE_PATH}/suggest`, { + params: data, + }); + return response.data; +}; + +export const sendQuerySuggestionsTelemetry = async (data: QuerySuggestTelemetryData) => { + const response = await axios.post(`${BASE_PATH}/telemetry`, { + telemetry: data, + }); + return response.data; +}; diff --git a/packages/ui/src/ui/pages/query-tracker/querySuggestionsModule/createInlineSuggestions.ts b/packages/ui/src/ui/pages/query-tracker/querySuggestionsModule/createInlineSuggestions.ts new file mode 100644 index 000000000..d7ead6ef0 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/querySuggestionsModule/createInlineSuggestions.ts @@ -0,0 +1,93 @@ +import {CancellationToken, Position, editor, languages} from 'monaco-editor'; +import {getRangeToInsertSuggestion} from '../../../libs/monaco-yql-languages/helpers/getRangeToInsertSuggestion'; +import {QueryEngine} from '../module/engines'; +import debounce_ from 'lodash/debounce'; +import {getWindowStore} from '../../../store/window-store'; +import {getQuerySuggestions, sendQuerySuggestionsTelemetry} from './api'; +import { + PrevAction, + setPrevAction, + setRequestId, + setSuggestions, +} from '../module/querySuggestions/querySuggestionsSlice'; +import { + selectPrevAction, + selectQuerySuggestionsContextId, +} from '../module/querySuggestions/selectors'; +import {AcceptedTelemetryData} from '../../../../shared/suggestApi'; +import {getQuerySuggestionsEnabled} from '../../../store/selectors/settings/settings-ts'; + +const debouncedGetSuggestions = debounce_(getQuerySuggestions, 200); +const store = getWindowStore(); + +export const createInlineSuggestions = + (engine: QueryEngine) => + async ( + model: editor.ITextModel, + monacoCursorPosition: Position, + _context: languages.InlineCompletionContext, + _token: CancellationToken, + ): Promise<{items: languages.InlineCompletion[]}> => { + const state = store.getState(); + const contextId = selectQuerySuggestionsContextId(state); + const prevAction = selectPrevAction(state); + const enabled = getQuerySuggestionsEnabled(state); + + if (!enabled) { + return { + items: [], + }; + } + + const response = await debouncedGetSuggestions({ + contextId, + query: model.getValue(), + line: monacoCursorPosition.lineNumber, + column: monacoCursorPosition.column, + engine, + }); + + if (!response) { + return { + items: [], + }; + } + + let action: PrevAction = response.items.length > 0 ? 'received' : 'empty'; + if (prevAction === 'received') { + action = 'ignored'; + await sendQuerySuggestionsTelemetry({ + requestId: response.requestId, + timestamp: Date.now(), + type: 'ignored', + ignoredText: response.items[0], + }); + } + + store.dispatch(setPrevAction(action)); + store.dispatch(setSuggestions(response.items)); + store.dispatch(setRequestId(response.requestId)); + + const range = getRangeToInsertSuggestion(model, monacoCursorPosition); + return { + items: response.items.map((item) => { + const data: AcceptedTelemetryData = { + requestId: response.requestId, + timestamp: Date.now(), + type: 'accepted', + acceptedText: item, + convertedText: item, + }; + + return { + insertText: item, + range, + command: { + id: 'querySuggestionsTelemetry', + title: 'string', + arguments: [data], + }, + }; + }), + }; + }; diff --git a/packages/ui/src/ui/pages/query-tracker/querySuggestionsModule/useMonacoQuerySuggestions.ts b/packages/ui/src/ui/pages/query-tracker/querySuggestionsModule/useMonacoQuerySuggestions.ts new file mode 100644 index 000000000..92b359c77 --- /dev/null +++ b/packages/ui/src/ui/pages/query-tracker/querySuggestionsModule/useMonacoQuerySuggestions.ts @@ -0,0 +1,52 @@ +import {useCallback, useEffect} from 'react'; +import * as monaco from 'monaco-editor'; +import { + selectQuerySuggestions, + selectQuerySuggestionsRequestId, +} from '../module/querySuggestions/selectors'; +import {sendQuerySuggestionsTelemetry} from './api'; +import {useDispatch, useSelector} from 'react-redux'; +import {DiscardedTelemetryData, QuerySuggestTelemetryData} from '../../../../shared/suggestApi'; +import {getQuerySuggestionsEnabled} from '../../../store/selectors/settings/settings-ts'; +import {setPrevAction} from '../module/querySuggestions/querySuggestionsSlice'; + +export const useMonacoQuerySuggestions = (editor?: monaco.editor.IStandaloneCodeEditor) => { + const dispatch = useDispatch(); + const requestId = useSelector(selectQuerySuggestionsRequestId); + const suggestions = useSelector(selectQuerySuggestions); + const enabled = useSelector(getQuerySuggestionsEnabled); + + const handleCancelTelemetry = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape' && editor && enabled) { + const data: DiscardedTelemetryData = { + requestId, + timestamp: Date.now(), + type: 'discarded', + reason: 'OnCancel', + discardedText: suggestions[0], + }; + editor.trigger(undefined, 'querySuggestionsTelemetry', data); + } + }, + [editor, enabled, requestId, suggestions], + ); + + useEffect(() => { + monaco.editor.registerCommand('querySuggestionsTelemetry', async (_accessor, ...args) => { + if (args.length > 0) { + const data = args[0] as QuerySuggestTelemetryData; + await dispatch(setPrevAction(data.type)); + await sendQuerySuggestionsTelemetry(args[0]); + } + }); + }, [dispatch]); + + useEffect(() => { + document.addEventListener('keydown', handleCancelTelemetry, true); + + return () => { + document.removeEventListener('keydown', handleCancelTelemetry, true); + }; + }, [handleCancelTelemetry, editor]); +};