diff --git a/extensions/html/client/src/htmlMain.ts b/extensions/html/client/src/htmlMain.ts index 82a1d0ffc59..b100b35a052 100644 --- a/extensions/html/client/src/htmlMain.ts +++ b/extensions/html/client/src/htmlMain.ts @@ -6,10 +6,11 @@ import * as path from 'path'; -import { languages, workspace, ExtensionContext, IndentAction } from 'vscode'; -import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind, Range, RequestType } from 'vscode-languageclient'; +import { languages, workspace, ExtensionContext, IndentAction, Position, TextDocument } from 'vscode'; +import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind, Range as LSRange, RequestType, TextDocumentPositionParams } from 'vscode-languageclient'; import { EMPTY_ELEMENTS } from './htmlEmptyTagsShared'; import { activateColorDecorations } from './colorDecorators'; +import { activateTagClosing } from './tagClosing'; import TelemetryReporter from 'vscode-extension-telemetry'; import { ConfigurationFeature } from 'vscode-languageclient/lib/proposed'; @@ -18,7 +19,11 @@ import * as nls from 'vscode-nls'; let localize = nls.loadMessageBundle(); namespace ColorSymbolRequest { - export const type: RequestType = new RequestType('css/colorSymbols'); + export const type: RequestType = new RequestType('html/colorSymbols'); +} + +namespace TagCloseRequest { + export const type: RequestType = new RequestType('html/tag'); } interface IPackageInfo { @@ -28,10 +33,13 @@ interface IPackageInfo { } export function activate(context: ExtensionContext) { + let toDispose = context.subscriptions; let packageInfo = getPackageInfo(context); let telemetryReporter: TelemetryReporter = packageInfo && new TelemetryReporter(packageInfo.name, packageInfo.version, packageInfo.aiKey); - context.subscriptions.push(telemetryReporter); + if (telemetryReporter) { + toDispose.push(telemetryReporter); + } // The server is implemented in node let serverModule = context.asAbsolutePath(path.join('server', 'out', 'htmlServerMain.js')); @@ -64,7 +72,7 @@ export function activate(context: ExtensionContext) { client.registerFeature(new ConfigurationFeature(client)); let disposable = client.start(); - context.subscriptions.push(disposable); + toDispose.push(disposable); client.onReady().then(() => { let colorRequestor = (uri: string) => { return client.sendRequest(ColorSymbolRequest.type, uri).then(ranges => ranges.map(client.protocol2CodeConverter.asRange)); @@ -73,12 +81,21 @@ export function activate(context: ExtensionContext) { return workspace.getConfiguration().get('css.colorDecorators.enable'); }; let disposable = activateColorDecorations(colorRequestor, { html: true, handlebars: true, razor: true }, isDecoratorEnabled); - context.subscriptions.push(disposable); - client.onTelemetry(e => { + toDispose.push(disposable); + + let tagRequestor = (document: TextDocument, position: Position) => { + let param = client.code2ProtocolConverter.asTextDocumentPositionParams(document, position); + return client.sendRequest(TagCloseRequest.type, param); + }; + disposable = activateTagClosing(tagRequestor, { html: true, handlebars: true, razor: true }, 'html.autoClosingTags.enable'); + toDispose.push(disposable); + + disposable = client.onTelemetry(e => { if (telemetryReporter) { telemetryReporter.sendTelemetryEvent(e.key, e.data); } }); + toDispose.push(disposable); }); languages.setLanguageConfiguration('html', { diff --git a/extensions/html/client/src/tagClosing.ts b/extensions/html/client/src/tagClosing.ts new file mode 100644 index 00000000000..45434196834 --- /dev/null +++ b/extensions/html/client/src/tagClosing.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import { window, workspace, Disposable, TextDocumentContentChangeEvent, TextDocument, Position, SnippetString } from 'vscode'; + +export function activateTagClosing(tagProvider: (document: TextDocument, position: Position) => Thenable, supportedLanguages: { [id: string]: boolean }, configName: string): Disposable { + + let disposables: Disposable[] = []; + workspace.onDidChangeTextDocument(event => onDidChangeTextDocument(event.document, event.contentChanges), null, disposables); + + let isEnabled = false; + updateEnabledState(); + window.onDidChangeActiveTextEditor(updateEnabledState, null, disposables); + + let timeout: NodeJS.Timer = void 0; + + function updateEnabledState() { + isEnabled = false; + let editor = window.activeTextEditor; + if (!editor) { + return; + } + let document = editor.document; + if (!supportedLanguages[document.languageId]) { + return; + } + if (!workspace.getConfiguration(void 0, document.uri).get(configName)) { + return; + } + isEnabled = true; + } + + function onDidChangeTextDocument(document: TextDocument, changes: TextDocumentContentChangeEvent[]) { + if (!isEnabled) { + return; + } + let activeDocument = window.activeTextEditor && window.activeTextEditor.document; + if (document !== activeDocument || changes.length === 0) { + return; + } + if (typeof timeout !== 'undefined') { + clearTimeout(timeout); + } + let lastChange = changes[changes.length - 1]; + let lastCharacter = lastChange.text[lastChange.text.length - 1]; + if (lastChange.rangeLength > 0 || lastCharacter !== '>' && lastCharacter !== '/') { + return; + } + let rangeStart = lastChange.range.start; + let version = document.version; + timeout = setTimeout(() => { + let position = new Position(rangeStart.line, rangeStart.character + lastChange.text.length); + tagProvider(document, position).then(text => { + if (text && isEnabled) { + let activeDocument = window.activeTextEditor && window.activeTextEditor.document; + if (document === activeDocument && activeDocument.version === version) { + window.activeTextEditor.insertSnippet(new SnippetString(text), position); + } + } + }); + }, 100); + } + return Disposable.from(...disposables); +} \ No newline at end of file diff --git a/extensions/html/package.json b/extensions/html/package.json index a06789f08a2..fdbdc533d84 100644 --- a/extensions/html/package.json +++ b/extensions/html/package.json @@ -193,6 +193,12 @@ "default": true, "description": "%html.validate.styles%" }, + "html.autoClosingTags.enable": { + "type": "boolean", + "scope": "resource", + "default": true, + "description": "%html.autoClosingTags.enable%" + }, "html.trace.server": { "type": "string", "scope": "window", diff --git a/extensions/html/package.nls.json b/extensions/html/package.nls.json index b27892fe954..757a2f57b3b 100644 --- a/extensions/html/package.nls.json +++ b/extensions/html/package.nls.json @@ -1,5 +1,5 @@ { - "html.format.enable.desc": "Enable/disable default HTML formatter (requires restart)", + "html.format.enable.desc": "Enable/disable default HTML formatter", "html.format.wrapLineLength.desc": "Maximum amount of characters per line (0 = disable).", "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.", "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 @@ "html.suggest.ionic.desc": "Configures if the built-in HTML language support suggests Ionic tags, properties and values.", "html.suggest.html5.desc":"Configures if the built-in HTML language support suggests HTML5 tags, properties and values.", "html.validate.scripts": "Configures if the built-in HTML language support validates embedded scripts.", - "html.validate.styles": "Configures if the built-in HTML language support validates embedded styles." + "html.validate.styles": "Configures if the built-in HTML language support validates embedded styles.", + "html.autoClosingTags.enable": "Enable/disable autoclosing of HTML tags." } \ No newline at end of file diff --git a/extensions/html/server/npm-shrinkwrap.json b/extensions/html/server/npm-shrinkwrap.json index 8853dfc48b4..18dcf812c0b 100644 --- a/extensions/html/server/npm-shrinkwrap.json +++ b/extensions/html/server/npm-shrinkwrap.json @@ -8,9 +8,9 @@ "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-2.1.3.tgz" }, "vscode-html-languageservice": { - "version": "2.0.5", + "version": "2.0.7", "from": "vscode-html-languageservice@next", - "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-2.0.5.tgz" + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-2.0.7.tgz" }, "vscode-jsonrpc": { "version": "3.3.1", diff --git a/extensions/html/server/package.json b/extensions/html/server/package.json index 3fe6263139d..deba35c6b2a 100644 --- a/extensions/html/server/package.json +++ b/extensions/html/server/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "vscode-css-languageservice": "^2.1.3", - "vscode-html-languageservice": "^2.0.5", + "vscode-html-languageservice": "^2.0.7", "vscode-languageserver": "3.4.0-next.4", "vscode-languageserver-types": "^3.3.0", "vscode-nls": "^2.0.2", diff --git a/extensions/html/server/src/htmlServerMain.ts b/extensions/html/server/src/htmlServerMain.ts index 1ea94f267a3..425127c6c9f 100644 --- a/extensions/html/server/src/htmlServerMain.ts +++ b/extensions/html/server/src/htmlServerMain.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import { createConnection, IConnection, TextDocuments, InitializeParams, InitializeResult, RequestType, DocumentRangeFormattingRequest, Disposable, DocumentSelector, GetConfigurationParams } from 'vscode-languageserver'; +import { createConnection, IConnection, TextDocuments, InitializeParams, InitializeResult, RequestType, DocumentRangeFormattingRequest, Disposable, DocumentSelector, GetConfigurationParams, TextDocumentPositionParams } from 'vscode-languageserver'; import { DocumentContext } from 'vscode-html-languageservice'; import { TextDocument, Diagnostic, DocumentLink, Range, SymbolInformation } from 'vscode-languageserver-types'; import { getLanguageModes, LanguageModes, Settings } from './modes/languageModes'; @@ -22,9 +22,14 @@ import * as nls from 'vscode-nls'; nls.config(process.env['VSCODE_NLS_CONFIG']); namespace ColorSymbolRequest { - export const type: RequestType = new RequestType('css/colorSymbols'); + export const type: RequestType = new RequestType('html/colorSymbols'); } +namespace TagCloseRequest { + export const type: RequestType = new RequestType('html/tag'); +} + + // Create a connection for the server let connection: IConnection = createConnection(); @@ -96,7 +101,7 @@ connection.onInitialize((params: InitializeParams): InitializeResult => { capabilities: { // Tell the client that the server works in FULL text document sync mode textDocumentSync: documents.syncKind, - completionProvider: clientSnippetSupport ? { resolveProvider: true, triggerCharacters: ['.', ':', '<', '"', '=', '/'] } : null, + completionProvider: clientSnippetSupport ? { resolveProvider: true, triggerCharacters: ['.', ':', '<', '"', '=', '/', '>'] } : null, hoverProvider: true, documentHighlightProvider: true, documentRangeFormattingProvider: false, @@ -321,5 +326,17 @@ connection.onRequest(ColorSymbolRequest.type, uri => { return ranges; }); +connection.onRequest(TagCloseRequest.type, params => { + let document = documents.get(params.textDocument.uri); + if (document) { + let mode = languageModes.getModeAtPosition(document, params.position); + if (mode && mode.doAutoClose) { + return mode.doAutoClose(document, params.position); + } + } + return null; +}); + + // Listen on the connection connection.listen(); \ No newline at end of file diff --git a/extensions/html/server/src/modes/htmlMode.ts b/extensions/html/server/src/modes/htmlMode.ts index 186df1217a2..c4471fddc25 100644 --- a/extensions/html/server/src/modes/htmlMode.ts +++ b/extensions/html/server/src/modes/htmlMode.ts @@ -21,6 +21,10 @@ export function getHTMLMode(htmlLanguageService: HTMLLanguageService): LanguageM }, doComplete(document: TextDocument, position: Position, settings: Settings = globalSettings) { let options = settings && settings.html && settings.html.suggest; + let doAutoComplete = settings && settings.html && settings.html.autoClosingTags.enable; + if (doAutoComplete) { + options.hideAutoCompleteProposals = true; + } return htmlLanguageService.doComplete(document, position, htmlDocuments.get(document), options); }, doHover(document: TextDocument, position: Position) { @@ -44,6 +48,14 @@ export function getHTMLMode(htmlLanguageService: HTMLLanguageService): LanguageM } return htmlLanguageService.format(document, range, formatSettings); }, + doAutoClose(document: TextDocument, position: Position) { + let offset = document.offsetAt(position); + let text = document.getText(); + if (offset > 0 && text.charAt(offset - 1).match(/[>\/]/g)) { + return htmlLanguageService.doTagComplete(document, position, htmlDocuments.get(document)); + } + return null; + }, onDocumentRemoved(document: TextDocument) { htmlDocuments.onDocumentRemoved(document); }, diff --git a/extensions/html/server/src/modes/languageModes.ts b/extensions/html/server/src/modes/languageModes.ts index fb6f90b3cc3..a12348d7eb1 100644 --- a/extensions/html/server/src/modes/languageModes.ts +++ b/extensions/html/server/src/modes/languageModes.ts @@ -41,6 +41,7 @@ export interface LanguageMode { findReferences?: (document: TextDocument, position: Position) => Location[]; format?: (document: TextDocument, range: Range, options: FormattingOptions, settings: Settings) => TextEdit[]; findColorSymbols?: (document: TextDocument) => Range[]; + doAutoClose?: (document: TextDocument, position: Position) => string; onDocumentRemoved(document: TextDocument): void; dispose(): void; }