diff --git a/Composer/packages/client/src/pages/language-generation/code-editor.tsx b/Composer/packages/client/src/pages/language-generation/code-editor.tsx index f226009d4e..aa852e0de6 100644 --- a/Composer/packages/client/src/pages/language-generation/code-editor.tsx +++ b/Composer/packages/client/src/pages/language-generation/code-editor.tsx @@ -16,10 +16,11 @@ import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'reac import { useRecoilValue } from 'recoil'; import { dispatcherState, userSettingsState } from '../../recoilModel'; -import { localeState, settingsState } from '../../recoilModel/atoms/botState'; +import { dialogState, localeState, settingsState } from '../../recoilModel/atoms/botState'; import { getMemoryVariables } from '../../recoilModel/dispatchers/utils/project'; import { lgFilesSelectorFamily } from '../../recoilModel/selectors/lg'; import TelemetryClient from '../../telemetry/TelemetryClient'; +import { navigateTo } from '../../utils/navigation'; import { DiffCodeEditor } from '../language-understanding/diff-editor'; const lspServerPath = '/lg-language-server'; @@ -40,6 +41,7 @@ const CodeEditor: React.FC = (props) => { const locale = useRecoilValue(localeState(actualProjectId)); const lgFiles = useRecoilValue(lgFilesSelectorFamily(actualProjectId)); const settings = useRecoilValue(settingsState(actualProjectId)); + const currentDialog = useRecoilValue(dialogState({ projectId: actualProjectId, dialogId })); const { languages, defaultLanguage } = settings; @@ -174,6 +176,24 @@ const CodeEditor: React.FC = (props) => { templateId: template?.name, }; + const navigateToLgPage = useCallback( + (lgFileId: string, options?: { templateId?: string; line?: number }) => { + // eslint-disable-next-line security/detect-non-literal-regexp + const pattern = new RegExp(`.${locale}`, 'g'); + const fileId = currentDialog.isFormDialog ? lgFileId : lgFileId.replace(pattern, ''); + let url = currentDialog.isFormDialog + ? `/bot/${actualProjectId}/language-generation/${currentDialog.id}/item/${fileId}` + : `/bot/${actualProjectId}/language-generation/${fileId}`; + if (options?.line) { + url = url + `/edit#L=${options.line}`; + } else if (options?.templateId) { + url = url + `/edit?t=${options.templateId}`; + } + navigateTo(url); + }, + [actualProjectId, locale] + ); + const currentLanguageFileEditor = useMemo(() => { return ( = (props) => { value={content} onChange={onChange} onChangeSettings={handleSettingsChange} + onNavigateToLgPage={navigateToLgPage} /> ); }, [lgOption, userSettings.codeEditor]); diff --git a/Composer/packages/lib/code-editor/src/lg/LgCodeEditor.tsx b/Composer/packages/lib/code-editor/src/lg/LgCodeEditor.tsx index b556d359cd..4afa00f711 100644 --- a/Composer/packages/lib/code-editor/src/lg/LgCodeEditor.tsx +++ b/Composer/packages/lib/code-editor/src/lg/LgCodeEditor.tsx @@ -107,6 +107,7 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { quickSuggestions: true, wordBasedSuggestions: false, folding: true, + definitions: true, ...props.options, }; @@ -137,6 +138,10 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { const [expanded, setExpanded] = useState(false); useEffect(() => { + if (props.options?.readOnly) { + return; + } + if (!editor) return; if (!window.monacoServiceInstance) { @@ -156,16 +161,34 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { ['botbuilderlg'], connection ); + sendRequestWithRetry(languageClient, 'initializeDocuments', { lgOption, uri }); const disposable = languageClient.start(); connection.onClose(() => disposable.dispose()); window.monacoLGEditorInstance = languageClient; + + languageClient.onReady().then(() => + languageClient.onNotification('GotoDefinition', (result) => { + if (lgOption?.projectId) { + onNavigateToLgPage?.(result.fileId, { templateId: result.templateId, line: result.line }); + } + }) + ); }, }); } else { - sendRequestWithRetry(window.monacoLGEditorInstance, 'initializeDocuments', { lgOption, uri }); + if (!props.options?.readOnly) { + sendRequestWithRetry(window.monacoLGEditorInstance, 'initializeDocuments', { lgOption, uri }); + } + window.monacoLGEditorInstance.onReady().then(() => + window.monacoLGEditorInstance.onNotification('GotoDefinition', (result) => { + if (lgOption?.projectId) { + onNavigateToLgPage?.(result.fileId, { templateId: result.templateId, line: result.line }); + } + }) + ); } - }, [editor]); + }, [editor, onNavigateToLgPage]); const onInit: OnInit = (monaco) => { registerLGLanguage(monaco); @@ -200,7 +223,7 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { ); const navigateToLgPage = React.useCallback(() => { - onNavigateToLgPage?.(lgOption?.fileId ?? 'common', lgOption?.templateId); + onNavigateToLgPage?.(lgOption?.fileId ?? 'common', { templateId: lgOption?.templateId, line: undefined }); }, [onNavigateToLgPage, lgOption]); const onExpandedEditorChange = React.useCallback( diff --git a/Composer/packages/lib/code-editor/src/types.ts b/Composer/packages/lib/code-editor/src/types.ts index b663f805da..668e6d91bf 100644 --- a/Composer/packages/lib/code-editor/src/types.ts +++ b/Composer/packages/lib/code-editor/src/types.ts @@ -33,7 +33,7 @@ export type LgCodeEditorProps = LgCommonEditorProps & popExpandOptions?: { onEditorPopToggle?: (expanded: boolean) => void; popExpandTitle: string }; toolbarHidden?: boolean; showDirectTemplateLink?: boolean; - onNavigateToLgPage?: (lgFileId: string, templateId?: string) => void; + onNavigateToLgPage?: (lgFileId: string, options?: { templateId?: string; line?: number }) => void; languageServer?: | { host?: string; diff --git a/Composer/packages/tools/language-servers/language-generation/src/LGServer.ts b/Composer/packages/tools/language-servers/language-generation/src/LGServer.ts index 12c78be9ae..e4c79c6523 100644 --- a/Composer/packages/tools/language-servers/language-generation/src/LGServer.ts +++ b/Composer/packages/tools/language-servers/language-generation/src/LGServer.ts @@ -1,5 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import path from 'path'; + import URI from 'vscode-uri'; import { IConnection, TextDocuments } from 'vscode-languageserver'; import formatMessage from 'format-message'; @@ -19,6 +21,7 @@ import { DocumentOnTypeFormattingParams, FoldingRangeParams, FoldingRange, + Location, } from 'vscode-languageserver-protocol'; import get from 'lodash/get'; import uniq from 'lodash/uniq'; @@ -57,6 +60,8 @@ export class LGServer { private _lgParser = new LgParser(); private _luisEntities: string[] = []; private _lastLuContent: string[] = []; + private _lgFile: LgFile | undefined = undefined; + private _templateDefinitions: Record = {}; private _curDefinedVariblesInLG: Record = {}; private _otherDefinedVariblesInLG: Record = {}; private _mergedVariables: Record = {}; @@ -94,6 +99,7 @@ export class LGServer { }, hoverProvider: true, foldingRangeProvider: true, + definitionProvider: true, documentOnTypeFormattingProvider: { firstTriggerCharacter: '\n', }, @@ -101,6 +107,7 @@ export class LGServer { }; }); this.connection.onCompletion(async (params) => await this.completion(params)); + this.connection.onDefinition((params: TextDocumentPositionParams) => this.definitionHandler(params)); this.connection.onHover(async (params) => await this.hover(params)); this.connection.onDocumentOnTypeFormatting((docTypingParams) => this.docTypeFormat(docTypingParams)); this.connection.onFoldingRanges((foldingRangeParams: FoldingRangeParams) => @@ -113,6 +120,7 @@ export class LGServer { const textDocument = this.documents.get(uri); if (textDocument) { this.addLGDocument(textDocument, lgOption); + this.recordTemplatesDefintions(lgOption); this.validateLgOption(textDocument, lgOption); this.validate(textDocument); this.getOtherLGVariables(lgOption); @@ -136,6 +144,43 @@ export class LGServer { this.connection.listen(); } + protected definitionHandler(params: TextDocumentPositionParams): Location | undefined { + const document = this.documents.get(params.textDocument.uri); + if (!document) { + return; + } + + const importRegex = /^\s*\[[^[\]]+\](\([^()]+\))/; + const curLine = document.getText().split(/\r?\n/g)[params.position.line]; + if (importRegex.test(curLine)) { + const importedFile = curLine.match(importRegex)?.[1]; + if (importedFile) { + const source = importedFile.substr(1, importedFile.length - 2); // remove starting [ and tailing + const fileId = path.parse(source).name; + this.connection.sendNotification('GotoDefinition', { fileId: fileId }); + return; + } + } + + const wordRange = getRangeAtPosition(document, params.position); + const word = document.getText(wordRange); + const curFileResult = this._lgFile?.templates.find((t) => t.name === word); + + if (curFileResult?.range) { + return Location.create( + params.textDocument.uri, + Range.create(curFileResult.range.start.line - 1, 0, curFileResult.range.end.line, 0) + ); + } + + const refResult = this._templateDefinitions[word]; + if (refResult) { + this.connection.sendNotification('GotoDefinition', refResult); + } + + return; + } + protected foldingRangeHandler(params: FoldingRangeParams): FoldingRange[] { const document = this.documents.get(params.textDocument.uri); const items: FoldingRange[] = []; @@ -255,6 +300,7 @@ export class LGServer { return await this._lgParser.updateTemplate(lgFile, templateId, { body: content }, lgTextFiles); } } + return await this._lgParser.parse(fileId || uri, content, lgTextFiles); }; const lgDocument: LGDocument = { @@ -267,6 +313,55 @@ export class LGServer { this.LGDocuments.push(lgDocument); } + protected async recordTemplatesDefintions(lgOption?: LGOption) { + const { fileId, projectId } = lgOption || {}; + if (projectId) { + const curLocale = this.getLocale(fileId); + const fileIdWitoutLocale = this.removeLocaleInId(fileId); + const lgTextFiles = projectId ? this.getLgResources(projectId) : []; + for (const file of lgTextFiles) { + //Only stroe templates in other LG files + if (this.removeLocaleInId(file.id) !== fileIdWitoutLocale && this.getLocale(file.id) === curLocale) { + const lgTemplates = await this._lgParser.parse(file.id, file.content, lgTextFiles); + this._templateDefinitions = {}; + for (const template of lgTemplates.templates) { + this._templateDefinitions[template.name] = { + fileId: file.id, + templateId: template.name, + line: template?.range?.start?.line, + }; + } + } + } + } + } + + private removeLocaleInId(fileId: string | undefined): string { + if (!fileId) { + return ''; + } + + const idx = fileId.lastIndexOf('.'); + if (idx !== -1) { + return fileId.substring(0, idx); + } else { + return fileId; + } + } + + private getLocale(fileId: string | undefined): string { + if (!fileId) { + return ''; + } + + const idx = fileId.lastIndexOf('.'); + if (idx !== -1) { + return fileId.substring(idx, fileId.length); + } else { + return ''; + } + } + protected getLGDocument(document: TextDocument): LGDocument | undefined { return this.LGDocuments.find(({ uri }) => uri === document.uri); } @@ -898,6 +993,7 @@ export class LGServer { return; } + this._lgFile = lgFile; if (text.length === 0) { this.cleanDiagnostics(document); return; diff --git a/Composer/packages/tools/language-servers/language-generation/src/utils.ts b/Composer/packages/tools/language-servers/language-generation/src/utils.ts index 089f3783c1..73b7f95f6e 100644 --- a/Composer/packages/tools/language-servers/language-generation/src/utils.ts +++ b/Composer/packages/tools/language-servers/language-generation/src/utils.ts @@ -54,7 +54,7 @@ export function getRangeAtPosition(document: TextDocument, position: Position): const pos = position.character; const lineText = text.split(/\r?\n/g)[line]; let match: RegExpMatchArray | null; - const wordDefinition = /[a-zA-Z0-9_/.]+/g; + const wordDefinition = /[a-zA-Z0-9_]+/g; while ((match = wordDefinition.exec(lineText))) { const matchIndex = match.index || 0; if (matchIndex > pos) { diff --git a/Composer/packages/ui-plugins/lg/src/LgField.tsx b/Composer/packages/ui-plugins/lg/src/LgField.tsx index b125c15a13..e96d6a2174 100644 --- a/Composer/packages/ui-plugins/lg/src/LgField.tsx +++ b/Composer/packages/ui-plugins/lg/src/LgField.tsx @@ -213,13 +213,20 @@ const LgField: React.FC> = (props) => { }, [editorMode, allowResponseEditor, props.onChange, shellApi.telemetryClient]); const navigateToLgPage = useCallback( - (lgFileId: string, templateId?: string) => { + (lgFileId: string, options?: { templateId?: string; line?: number }) => { // eslint-disable-next-line security/detect-non-literal-regexp const pattern = new RegExp(`.${locale}`, 'g'); const fileId = currentDialog.isFormDialog ? lgFileId : lgFileId.replace(pattern, ''); - const url = currentDialog.isFormDialog + let url = currentDialog.isFormDialog ? `/bot/${projectId}/language-generation/${currentDialog.id}/item/${fileId}` - : `/bot/${projectId}/language-generation/${fileId}${templateId ? `/edit?t=${templateId}` : ''}`; + : `/bot/${projectId}/language-generation/${fileId}`; + + if (options?.line) { + url = url + `/edit#L=${options.line}`; + } else if (options?.templateId) { + url = url + `/edit?t=${options.templateId}`; + } + shellApi.navigateTo(url); }, [shellApi, projectId, locale]