Skip to content

Commit b08cde3

Browse files
committed
[html] VSCode doesn't automatically close HTML tags Fixes microsoft#2246.
1 parent 4010c1b commit b08cde3

9 files changed

Lines changed: 136 additions & 15 deletions

File tree

extensions/html/client/src/htmlMain.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66

77
import * as path from 'path';
88

9-
import { languages, workspace, ExtensionContext, IndentAction } from 'vscode';
10-
import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind, Range, RequestType } from 'vscode-languageclient';
9+
import { languages, workspace, ExtensionContext, IndentAction, Position, TextDocument } from 'vscode';
10+
import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind, Range as LSRange, RequestType, TextDocumentPositionParams } from 'vscode-languageclient';
1111
import { EMPTY_ELEMENTS } from './htmlEmptyTagsShared';
1212
import { activateColorDecorations } from './colorDecorators';
13+
import { activateTagClosing } from './tagClosing';
1314
import TelemetryReporter from 'vscode-extension-telemetry';
1415

1516
import { ConfigurationFeature } from 'vscode-languageclient/lib/proposed';
@@ -18,7 +19,11 @@ import * as nls from 'vscode-nls';
1819
let localize = nls.loadMessageBundle();
1920

2021
namespace ColorSymbolRequest {
21-
export const type: RequestType<string, Range[], any, any> = new RequestType('css/colorSymbols');
22+
export const type: RequestType<string, LSRange[], any, any> = new RequestType('html/colorSymbols');
23+
}
24+
25+
namespace TagCloseRequest {
26+
export const type: RequestType<TextDocumentPositionParams, string, any, any> = new RequestType('html/tag');
2227
}
2328

2429
interface IPackageInfo {
@@ -28,10 +33,13 @@ interface IPackageInfo {
2833
}
2934

3035
export function activate(context: ExtensionContext) {
36+
let toDispose = context.subscriptions;
3137

3238
let packageInfo = getPackageInfo(context);
3339
let telemetryReporter: TelemetryReporter = packageInfo && new TelemetryReporter(packageInfo.name, packageInfo.version, packageInfo.aiKey);
34-
context.subscriptions.push(telemetryReporter);
40+
if (telemetryReporter) {
41+
toDispose.push(telemetryReporter);
42+
}
3543

3644
// The server is implemented in node
3745
let serverModule = context.asAbsolutePath(path.join('server', 'out', 'htmlServerMain.js'));
@@ -64,7 +72,7 @@ export function activate(context: ExtensionContext) {
6472
client.registerFeature(new ConfigurationFeature(client));
6573

6674
let disposable = client.start();
67-
context.subscriptions.push(disposable);
75+
toDispose.push(disposable);
6876
client.onReady().then(() => {
6977
let colorRequestor = (uri: string) => {
7078
return client.sendRequest(ColorSymbolRequest.type, uri).then(ranges => ranges.map(client.protocol2CodeConverter.asRange));
@@ -73,12 +81,21 @@ export function activate(context: ExtensionContext) {
7381
return workspace.getConfiguration().get<boolean>('css.colorDecorators.enable');
7482
};
7583
let disposable = activateColorDecorations(colorRequestor, { html: true, handlebars: true, razor: true }, isDecoratorEnabled);
76-
context.subscriptions.push(disposable);
77-
client.onTelemetry(e => {
84+
toDispose.push(disposable);
85+
86+
let tagRequestor = (document: TextDocument, position: Position) => {
87+
let param = client.code2ProtocolConverter.asTextDocumentPositionParams(document, position);
88+
return client.sendRequest(TagCloseRequest.type, param);
89+
};
90+
disposable = activateTagClosing(tagRequestor, { html: true, handlebars: true, razor: true }, 'html.autoClosingTags.enable');
91+
toDispose.push(disposable);
92+
93+
disposable = client.onTelemetry(e => {
7894
if (telemetryReporter) {
7995
telemetryReporter.sendTelemetryEvent(e.key, e.data);
8096
}
8197
});
98+
toDispose.push(disposable);
8299
});
83100

84101
languages.setLanguageConfiguration('html', {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
'use strict';
6+
7+
import { window, workspace, Disposable, TextDocumentContentChangeEvent, TextDocument, Position, SnippetString } from 'vscode';
8+
9+
export function activateTagClosing(tagProvider: (document: TextDocument, position: Position) => Thenable<string>, supportedLanguages: { [id: string]: boolean }, configName: string): Disposable {
10+
11+
let disposables: Disposable[] = [];
12+
workspace.onDidChangeTextDocument(event => onDidChangeTextDocument(event.document, event.contentChanges), null, disposables);
13+
14+
let isEnabled = false;
15+
updateEnabledState();
16+
window.onDidChangeActiveTextEditor(updateEnabledState, null, disposables);
17+
18+
let timeout: NodeJS.Timer = void 0;
19+
20+
function updateEnabledState() {
21+
isEnabled = false;
22+
let editor = window.activeTextEditor;
23+
if (!editor) {
24+
return;
25+
}
26+
let document = editor.document;
27+
if (!supportedLanguages[document.languageId]) {
28+
return;
29+
}
30+
if (!workspace.getConfiguration(void 0, document.uri).get<boolean>(configName)) {
31+
return;
32+
}
33+
isEnabled = true;
34+
}
35+
36+
function onDidChangeTextDocument(document: TextDocument, changes: TextDocumentContentChangeEvent[]) {
37+
if (!isEnabled) {
38+
return;
39+
}
40+
let activeDocument = window.activeTextEditor && window.activeTextEditor.document;
41+
if (document !== activeDocument || changes.length === 0) {
42+
return;
43+
}
44+
if (typeof timeout !== 'undefined') {
45+
clearTimeout(timeout);
46+
}
47+
let lastChange = changes[changes.length - 1];
48+
let lastCharacter = lastChange.text[lastChange.text.length - 1];
49+
if (lastChange.rangeLength > 0 || lastCharacter !== '>' && lastCharacter !== '/') {
50+
return;
51+
}
52+
let rangeStart = lastChange.range.start;
53+
let version = document.version;
54+
timeout = setTimeout(() => {
55+
let position = new Position(rangeStart.line, rangeStart.character + lastChange.text.length);
56+
tagProvider(document, position).then(text => {
57+
if (text && isEnabled) {
58+
let activeDocument = window.activeTextEditor && window.activeTextEditor.document;
59+
if (document === activeDocument && activeDocument.version === version) {
60+
window.activeTextEditor.insertSnippet(new SnippetString(text), position);
61+
}
62+
}
63+
});
64+
}, 100);
65+
}
66+
return Disposable.from(...disposables);
67+
}

extensions/html/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,12 @@
193193
"default": true,
194194
"description": "%html.validate.styles%"
195195
},
196+
"html.autoClosingTags.enable": {
197+
"type": "boolean",
198+
"scope": "resource",
199+
"default": true,
200+
"description": "%html.autoClosingTags.enable%"
201+
},
196202
"html.trace.server": {
197203
"type": "string",
198204
"scope": "window",

extensions/html/package.nls.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"html.format.enable.desc": "Enable/disable default HTML formatter (requires restart)",
2+
"html.format.enable.desc": "Enable/disable default HTML formatter",
33
"html.format.wrapLineLength.desc": "Maximum amount of characters per line (0 = disable).",
44
"html.format.unformatted.desc": "List of tags, comma separated, that shouldn't be reformatted. 'null' defaults to all tags listed at https://www.w3.org/TR/html5/dom.html#phrasing-content.",
55
"html.format.contentUnformatted.desc": "List of tags, comma separated, where the content shouldn't be reformatted. 'null' defaults to the 'pre' tag.",
@@ -18,5 +18,6 @@
1818
"html.suggest.ionic.desc": "Configures if the built-in HTML language support suggests Ionic tags, properties and values.",
1919
"html.suggest.html5.desc":"Configures if the built-in HTML language support suggests HTML5 tags, properties and values.",
2020
"html.validate.scripts": "Configures if the built-in HTML language support validates embedded scripts.",
21-
"html.validate.styles": "Configures if the built-in HTML language support validates embedded styles."
21+
"html.validate.styles": "Configures if the built-in HTML language support validates embedded styles.",
22+
"html.autoClosingTags.enable": "Enable/disable autoclosing of HTML tags."
2223
}

extensions/html/server/npm-shrinkwrap.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

extensions/html/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
},
1010
"dependencies": {
1111
"vscode-css-languageservice": "^2.1.3",
12-
"vscode-html-languageservice": "^2.0.5",
12+
"vscode-html-languageservice": "^2.0.7",
1313
"vscode-languageserver": "3.4.0-next.4",
1414
"vscode-languageserver-types": "^3.3.0",
1515
"vscode-nls": "^2.0.2",

extensions/html/server/src/htmlServerMain.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55
'use strict';
66

7-
import { createConnection, IConnection, TextDocuments, InitializeParams, InitializeResult, RequestType, DocumentRangeFormattingRequest, Disposable, DocumentSelector, GetConfigurationParams } from 'vscode-languageserver';
7+
import { createConnection, IConnection, TextDocuments, InitializeParams, InitializeResult, RequestType, DocumentRangeFormattingRequest, Disposable, DocumentSelector, GetConfigurationParams, TextDocumentPositionParams } from 'vscode-languageserver';
88
import { DocumentContext } from 'vscode-html-languageservice';
99
import { TextDocument, Diagnostic, DocumentLink, Range, SymbolInformation } from 'vscode-languageserver-types';
1010
import { getLanguageModes, LanguageModes, Settings } from './modes/languageModes';
@@ -22,9 +22,14 @@ import * as nls from 'vscode-nls';
2222
nls.config(process.env['VSCODE_NLS_CONFIG']);
2323

2424
namespace ColorSymbolRequest {
25-
export const type: RequestType<string, Range[], any, any> = new RequestType('css/colorSymbols');
25+
export const type: RequestType<string, Range[], any, any> = new RequestType('html/colorSymbols');
2626
}
2727

28+
namespace TagCloseRequest {
29+
export const type: RequestType<TextDocumentPositionParams, string, any, any> = new RequestType('html/tag');
30+
}
31+
32+
2833
// Create a connection for the server
2934
let connection: IConnection = createConnection();
3035

@@ -96,7 +101,7 @@ connection.onInitialize((params: InitializeParams): InitializeResult => {
96101
capabilities: {
97102
// Tell the client that the server works in FULL text document sync mode
98103
textDocumentSync: documents.syncKind,
99-
completionProvider: clientSnippetSupport ? { resolveProvider: true, triggerCharacters: ['.', ':', '<', '"', '=', '/'] } : null,
104+
completionProvider: clientSnippetSupport ? { resolveProvider: true, triggerCharacters: ['.', ':', '<', '"', '=', '/', '>'] } : null,
100105
hoverProvider: true,
101106
documentHighlightProvider: true,
102107
documentRangeFormattingProvider: false,
@@ -321,5 +326,17 @@ connection.onRequest(ColorSymbolRequest.type, uri => {
321326
return ranges;
322327
});
323328

329+
connection.onRequest(TagCloseRequest.type, params => {
330+
let document = documents.get(params.textDocument.uri);
331+
if (document) {
332+
let mode = languageModes.getModeAtPosition(document, params.position);
333+
if (mode && mode.doAutoClose) {
334+
return mode.doAutoClose(document, params.position);
335+
}
336+
}
337+
return null;
338+
});
339+
340+
324341
// Listen on the connection
325342
connection.listen();

extensions/html/server/src/modes/htmlMode.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export function getHTMLMode(htmlLanguageService: HTMLLanguageService): LanguageM
2121
},
2222
doComplete(document: TextDocument, position: Position, settings: Settings = globalSettings) {
2323
let options = settings && settings.html && settings.html.suggest;
24+
let doAutoComplete = settings && settings.html && settings.html.autoClosingTags.enable;
25+
if (doAutoComplete) {
26+
options.hideAutoCompleteProposals = true;
27+
}
2428
return htmlLanguageService.doComplete(document, position, htmlDocuments.get(document), options);
2529
},
2630
doHover(document: TextDocument, position: Position) {
@@ -44,6 +48,14 @@ export function getHTMLMode(htmlLanguageService: HTMLLanguageService): LanguageM
4448
}
4549
return htmlLanguageService.format(document, range, formatSettings);
4650
},
51+
doAutoClose(document: TextDocument, position: Position) {
52+
let offset = document.offsetAt(position);
53+
let text = document.getText();
54+
if (offset > 0 && text.charAt(offset - 1).match(/[>\/]/g)) {
55+
return htmlLanguageService.doTagComplete(document, position, htmlDocuments.get(document));
56+
}
57+
return null;
58+
},
4759
onDocumentRemoved(document: TextDocument) {
4860
htmlDocuments.onDocumentRemoved(document);
4961
},

extensions/html/server/src/modes/languageModes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface LanguageMode {
4141
findReferences?: (document: TextDocument, position: Position) => Location[];
4242
format?: (document: TextDocument, range: Range, options: FormattingOptions, settings: Settings) => TextEdit[];
4343
findColorSymbols?: (document: TextDocument) => Range[];
44+
doAutoClose?: (document: TextDocument, position: Position) => string;
4445
onDocumentRemoved(document: TextDocument): void;
4546
dispose(): void;
4647
}

0 commit comments

Comments
 (0)