diff --git a/extensions/html-language-features/client/src/htmlMain.ts b/extensions/html-language-features/client/src/htmlMain.ts
index 89bdc325721b6..194935b25fc90 100644
--- a/extensions/html-language-features/client/src/htmlMain.ts
+++ b/extensions/html-language-features/client/src/htmlMain.ts
@@ -14,10 +14,14 @@ import { EMPTY_ELEMENTS } from './htmlEmptyTagsShared';
import { activateTagClosing } from './tagClosing';
import TelemetryReporter from 'vscode-extension-telemetry';
import { getCustomDataPathsInAllWorkspaces, getCustomDataPathsFromAllExtensions } from './customData';
+import { activateMatchingTagPosition as activateMatchingTagSelection } from './matchingTag';
namespace TagCloseRequest {
export const type: RequestType = new RequestType('html/tag');
}
+namespace MatchingTagPositionRequest {
+ export const type: RequestType = new RequestType('html/matchingTagPosition');
+}
interface IPackageInfo {
name: string;
@@ -84,6 +88,14 @@ export function activate(context: ExtensionContext) {
disposable = activateTagClosing(tagRequestor, { html: true, handlebars: true }, 'html.autoClosingTags');
toDispose.push(disposable);
+ const matchingTagPositionRequestor = (document: TextDocument, position: Position) => {
+ let param = client.code2ProtocolConverter.asTextDocumentPositionParams(document, position);
+ return client.sendRequest(MatchingTagPositionRequest.type, param);
+ };
+
+ disposable = activateMatchingTagSelection(matchingTagPositionRequestor, { html: true, handlebars: true }, 'html.autoSelectingMatchingTags');
+ toDispose.push(disposable);
+
disposable = client.onTelemetry(e => {
if (telemetryReporter) {
telemetryReporter.sendTelemetryEvent(e.key, e.data);
diff --git a/extensions/html-language-features/client/src/matchingTag.ts b/extensions/html-language-features/client/src/matchingTag.ts
new file mode 100644
index 0000000000000..589504ce1bb0b
--- /dev/null
+++ b/extensions/html-language-features/client/src/matchingTag.ts
@@ -0,0 +1,147 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import {
+ window,
+ workspace,
+ Disposable,
+ TextDocument,
+ Position,
+ TextEditorSelectionChangeEvent,
+ Selection,
+ Range,
+ WorkspaceEdit
+} from 'vscode';
+
+export function activateMatchingTagPosition(
+ matchingTagPositionProvider: (document: TextDocument, position: Position) => Thenable,
+ supportedLanguages: { [id: string]: boolean },
+ configName: string
+): Disposable {
+ let disposables: Disposable[] = [];
+
+ window.onDidChangeTextEditorSelection(event => onDidChangeTextEditorSelection(event), null, disposables);
+
+ let isEnabled = false;
+ updateEnabledState();
+
+ window.onDidChangeActiveTextEditor(updateEnabledState, null, disposables);
+
+ function updateEnabledState() {
+ isEnabled = false;
+ let editor = window.activeTextEditor;
+ if (!editor) {
+ return;
+ }
+ let document = editor.document;
+ if (!supportedLanguages[document.languageId]) {
+ return;
+ }
+ if (!workspace.getConfiguration(undefined, document.uri).get(configName)) {
+ return;
+ }
+ isEnabled = true;
+ }
+
+ // let prevCursorCount = 0;
+ let cursorCount = 0;
+ let inMirrorMode = false;
+
+ function onDidChangeTextEditorSelection(event: TextEditorSelectionChangeEvent) {
+ if (!isEnabled) {
+ return;
+ }
+
+ // prevCursorCount = cursorCount;
+ cursorCount = event.selections.length;
+
+ if (cursorCount === 1) {
+ if (event.selections[0].isEmpty) {
+ matchingTagPositionProvider(event.textEditor.document, event.selections[0].active).then(position => {
+ if (position && window.activeTextEditor) {
+ inMirrorMode = true;
+ const newCursor = new Selection(position.line, position.character, position.line, position.character);
+ window.activeTextEditor.selections = [...window.activeTextEditor.selections, newCursor];
+ }
+ });
+ }
+ }
+
+ if (cursorCount === 2 && inMirrorMode) {
+ // Check two cases
+ if (event.selections[0].isEmpty && event.selections[1].isEmpty) {
+ const charBeforePrimarySelection = getCharBefore(event.textEditor.document, event.selections[0].anchor);
+ const charAfterPrimarySelection = getCharAfter(event.textEditor.document, event.selections[0].anchor);
+ const charBeforeSecondarySelection = getCharBefore(event.textEditor.document, event.selections[1].anchor);
+ const charAfterSecondarySelection = getCharAfter(event.textEditor.document, event.selections[1].anchor);
+
+ // Exit mirror mode when cursor position no longer mirror
+ // Unless it's in the case of `<|>|>`
+ const charBeforeBothPositionRoughlyEqual =
+ charBeforePrimarySelection === charBeforeSecondarySelection ||
+ (charBeforePrimarySelection === '/' && charBeforeSecondarySelection === '<') ||
+ (charBeforeSecondarySelection === '/' && charBeforePrimarySelection === '<');
+ const charAfterBothPositionRoughlyEqual =
+ charAfterPrimarySelection === charAfterSecondarySelection ||
+ (charAfterPrimarySelection === ' ' && charAfterSecondarySelection === '>') ||
+ (charAfterSecondarySelection === ' ' && charAfterPrimarySelection === '>');
+
+ if (!charBeforeBothPositionRoughlyEqual || !charAfterBothPositionRoughlyEqual) {
+ inMirrorMode = false;
+ window.activeTextEditor!.selections = [window.activeTextEditor!.selections[0]];
+ return;
+ } else {
+ // Need to cleanup in the case of
+ if (
+ charBeforePrimarySelection === ' ' &&
+ charAfterPrimarySelection === '>' &&
+ charBeforeSecondarySelection === ' ' &&
+ charAfterSecondarySelection === '>'
+ ) {
+ inMirrorMode = false;
+ const cleanupEdit = new WorkspaceEdit();
+
+ const primaryBeforeSecondary =
+ event.textEditor.document.offsetAt(event.selections[0].anchor) <
+ event.textEditor.document.offsetAt(event.selections[1].anchor);
+ const cleanupRange = primaryBeforeSecondary
+ ? new Range(event.selections[1].anchor.translate(0, -1), event.selections[1].anchor)
+ : new Range(event.selections[0].anchor.translate(0, -1), event.selections[0].anchor);
+
+ cleanupEdit.replace(event.textEditor.document.uri, cleanupRange, '');
+ window.activeTextEditor!.selections = primaryBeforeSecondary
+ ? [window.activeTextEditor!.selections[0]]
+ : [window.activeTextEditor!.selections[1]];
+ workspace.applyEdit(cleanupEdit);
+ }
+ }
+ }
+ }
+ }
+
+ return Disposable.from(...disposables);
+}
+
+function getCharBefore(document: TextDocument, position: Position) {
+ const offset = document.offsetAt(position);
+ if (offset === 0) {
+ return '';
+ }
+
+ return document.getText(
+ new Range(document.positionAt(offset - 1), position)
+ );
+}
+
+function getCharAfter(document: TextDocument, position: Position) {
+ const offset = document.offsetAt(position);
+ if (offset === document.getText().length) {
+ return '';
+ }
+
+ return document.getText(
+ new Range(position, document.positionAt(offset + 1))
+ );
+}
\ No newline at end of file
diff --git a/extensions/html-language-features/package.json b/extensions/html-language-features/package.json
index 7c4f83d6b5b56..a641eb4ca4616 100644
--- a/extensions/html-language-features/package.json
+++ b/extensions/html-language-features/package.json
@@ -161,6 +161,12 @@
"default": true,
"description": "%html.autoClosingTags%"
},
+ "html.autoSelectingMatchingTags": {
+ "type": "boolean",
+ "scope": "resource",
+ "default": true,
+ "description": "%html.autoSelectingMatchingTags%"
+ },
"html.trace.server": {
"type": "string",
"scope": "window",
diff --git a/extensions/html-language-features/package.nls.json b/extensions/html-language-features/package.nls.json
index 2cb7e3d16973a..4e4d666b9f7f5 100644
--- a/extensions/html-language-features/package.nls.json
+++ b/extensions/html-language-features/package.nls.json
@@ -24,5 +24,6 @@
"html.trace.server.desc": "Traces the communication between VS Code and the HTML language server.",
"html.validate.scripts": "Controls whether the built-in HTML language support validates embedded scripts.",
"html.validate.styles": "Controls whether the built-in HTML language support validates embedded styles.",
- "html.autoClosingTags": "Enable/disable autoclosing of HTML tags."
+ "html.autoClosingTags": "Enable/disable autoclosing of HTML tags.",
+ "html.autoSelectingMatchingTags": "Enable/disable auto selecting matching HTML tags."
}
diff --git a/extensions/html-language-features/server/package.json b/extensions/html-language-features/server/package.json
index 0f3ff9f949733..be9e3fd2a2175 100644
--- a/extensions/html-language-features/server/package.json
+++ b/extensions/html-language-features/server/package.json
@@ -10,7 +10,7 @@
"main": "./out/htmlServerMain",
"dependencies": {
"vscode-css-languageservice": "^4.0.3-next.20",
- "vscode-html-languageservice": "^3.0.4-next.9",
+ "vscode-html-languageservice": "^3.0.4-next.10",
"vscode-languageserver": "^6.0.0-next.3",
"vscode-nls": "^4.1.1",
"vscode-uri": "^2.0.3"
diff --git a/extensions/html-language-features/server/src/htmlServerMain.ts b/extensions/html-language-features/server/src/htmlServerMain.ts
index dcbbdca483b3f..8964b5ee035bc 100644
--- a/extensions/html-language-features/server/src/htmlServerMain.ts
+++ b/extensions/html-language-features/server/src/htmlServerMain.ts
@@ -24,6 +24,9 @@ import { getDataProviders } from './customData';
namespace TagCloseRequest {
export const type: RequestType = new RequestType('html/tag');
}
+namespace MatchingTagPositionRequest {
+ export const type: RequestType = new RequestType('html/matchingTagPosition');
+}
// Create a connection for the server
const connection: IConnection = createConnection();
@@ -485,5 +488,21 @@ connection.onRenameRequest((params, token) => {
}, null, `Error while computing rename for ${params.textDocument.uri}`, token);
});
+connection.onRequest(MatchingTagPositionRequest.type, (params, token) => {
+ return runSafe(() => {
+ const document = documents.get(params.textDocument.uri);
+ if (document) {
+ const pos = params.position;
+ if (pos.character > 0) {
+ const mode = languageModes.getModeAtPosition(document, Position.create(pos.line, pos.character - 1));
+ if (mode && mode.findMatchingTagPosition) {
+ return mode.findMatchingTagPosition(document, pos);
+ }
+ }
+ }
+ return null;
+ }, null, `Error while computing matching tag position for ${params.textDocument.uri}`, token);
+});
+
// Listen on the connection
connection.listen();
diff --git a/extensions/html-language-features/server/src/modes/htmlMode.ts b/extensions/html-language-features/server/src/modes/htmlMode.ts
index 9c64fb3a9d7d1..23304a28ec71c 100644
--- a/extensions/html-language-features/server/src/modes/htmlMode.ts
+++ b/extensions/html-language-features/server/src/modes/htmlMode.ts
@@ -81,6 +81,10 @@ export function getHTMLMode(htmlLanguageService: HTMLLanguageService, workspace:
onDocumentRemoved(document: TextDocument) {
htmlDocuments.onDocumentRemoved(document);
},
+ findMatchingTagPosition(document: TextDocument, position: Position) {
+ const htmlDocument = htmlDocuments.get(document);
+ return htmlLanguageService.findMatchingTagPosition(document, position, htmlDocument);
+ },
dispose() {
htmlDocuments.dispose();
}
diff --git a/extensions/html-language-features/server/src/modes/languageModes.ts b/extensions/html-language-features/server/src/modes/languageModes.ts
index f50287330eaa3..a1fc67b27e1a5 100644
--- a/extensions/html-language-features/server/src/modes/languageModes.ts
+++ b/extensions/html-language-features/server/src/modes/languageModes.ts
@@ -48,6 +48,7 @@ export interface LanguageMode {
findDocumentColors?: (document: TextDocument) => ColorInformation[];
getColorPresentations?: (document: TextDocument, color: Color, range: Range) => ColorPresentation[];
doAutoClose?: (document: TextDocument, position: Position) => string | null;
+ findMatchingTagPosition?: (document: TextDocument, position: Position) => Position | null;
getFoldingRanges?: (document: TextDocument) => FoldingRange[];
onDocumentRemoved(document: TextDocument): void;
dispose(): void;
diff --git a/extensions/html-language-features/server/yarn.lock b/extensions/html-language-features/server/yarn.lock
index 439f360ddb555..07ad507e2ffbf 100644
--- a/extensions/html-language-features/server/yarn.lock
+++ b/extensions/html-language-features/server/yarn.lock
@@ -621,10 +621,10 @@ vscode-css-languageservice@^4.0.3-next.20:
vscode-nls "^4.1.1"
vscode-uri "^2.1.1"
-vscode-html-languageservice@^3.0.4-next.9:
- version "3.0.4-next.9"
- resolved "https://registry.yarnpkg.com/vscode-html-languageservice/-/vscode-html-languageservice-3.0.4-next.9.tgz#b27f26c29f3af64fa32eabb7425749f95f64036a"
- integrity sha512-9V9G7508ybFcn9gQpuucEZIGv8kKlBMEVD8lFFWwWS1yEonKchsxIGJZFbmSGr/n//2anfya8F8yL5ybKuWIRA==
+vscode-html-languageservice@^3.0.4-next.10:
+ version "3.0.4-next.10"
+ resolved "https://registry.yarnpkg.com/vscode-html-languageservice/-/vscode-html-languageservice-3.0.4-next.10.tgz#da426326833770c51712abb2c7473b9b30bf1cbc"
+ integrity sha512-8P0QBtMPJ9nDMhW8MF/z+5JGg6rK6UOa9po18KIleNuV0rDHU9CAqDyUjxW0CEfLrHYz6dQdkW12ZTClvQnNHw==
dependencies:
vscode-languageserver-textdocument "^1.0.0-next.4"
vscode-languageserver-types "^3.15.0-next.6"