diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 841024fd02bdb..bd31ca0764d7c 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -505,6 +505,16 @@ "default": "prompt", "description": "%typescript.updateImportsOnFileMove.enabled%", "scope": "resource" + }, + "typescript.autoClosingTags": { + "type": "boolean", + "default": true, + "description": "%typescript.autoClosingTags%" + }, + "javascript.autoClosingTags": { + "type": "boolean", + "default": true, + "description": "%typescript.autoClosingTags%" } } }, diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index b221d624d26ae..6876a3706abcf 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -55,5 +55,6 @@ "typescript.preferences.quoteStyle": "Preferred quote style to use for quick fixes: 'single' quotes, 'double' quotes, or 'auto' infer quote type from existing imports. Requires using TypeScript 2.9 or newer in the workspace.", "typescript.preferences.importModuleSpecifier": "Preferred path style for auto imports:\n- \"relative\" to the file location.\n- \"non-relative\" based on the 'baseUrl' configured in your 'jsconfig.json' / 'tsconfig.json'.\n- \"auto\" infer the shortest path type.\nRequires using TypeScript 2.9 or newer in the workspace.", "typescript.showUnused": "Enable/disable highlighting of unused variables in code. Requires using TypeScript 2.9 or newer in the workspace.", - "typescript.updateImportsOnFileMove.enabled": "Enable/disable automatic updating of import paths when you rename or move a file in VS Code. Possible values are: 'prompt' on each rename, 'always' update paths automatically, and 'never' rename paths and don't prompt me. Requires using TypeScript 2.9 or newer in the workspace." + "typescript.updateImportsOnFileMove.enabled": "Enable/disable automatic updating of import paths when you rename or move a file in VS Code. Possible values are: 'prompt' on each rename, 'always' update paths automatically, and 'never' rename paths and don't prompt me. Requires using TypeScript 2.9 or newer in the workspace.", + "typescript.autoClosingTags": "Enable/disable automatic closing of JSX tags. Requires using TypeScript 3.0 or newer in the workspace." } diff --git a/extensions/typescript-language-features/src/features/tagCompletion.ts b/extensions/typescript-language-features/src/features/tagCompletion.ts index 8a8495180ca37..bcc145d9089b3 100644 --- a/extensions/typescript-language-features/src/features/tagCompletion.ts +++ b/extensions/typescript-language-features/src/features/tagCompletion.ts @@ -7,44 +7,104 @@ import * as vscode from 'vscode'; import * as Proto from '../protocol'; import { ITypeScriptServiceClient } from '../typescriptService'; import API from '../utils/api'; -import { VersionDependentRegistration } from '../utils/dependentRegistration'; +import { VersionDependentRegistration, ConfigurationDependentRegistration, ConditionalRegistration } from '../utils/dependentRegistration'; +import { disposeAll } from '../utils/dispose'; import * as typeConverters from '../utils/typeConverters'; -class TypeScriptTagCompletion implements vscode.CompletionItemProvider { +class TagClosing { + + private _disposed = false; + private timeout: NodeJS.Timer | undefined = undefined; + + private readonly disposables: vscode.Disposable[] = []; + constructor( private readonly client: ITypeScriptServiceClient - ) { } + ) { + vscode.workspace.onDidChangeTextDocument( + event => this.onDidChangeTextDocument(event.document, event.contentChanges), + null, this.disposables); + + vscode.window.onDidChangeActiveTextEditor( + () => this.updateEnabledState(), + null, this.disposables); + + this.updateEnabledState(); + } + + public dispose() { + disposeAll(this.disposables); + this._disposed = true; + this.timeout = undefined; + } + + private updateEnabledState() { - async provideCompletionItems( + } + + private onDidChangeTextDocument( document: vscode.TextDocument, - position: vscode.Position, - token: vscode.CancellationToken, - _context: vscode.CompletionContext - ): Promise { + changes: vscode.TextDocumentContentChangeEvent[] + ) { + const activeDocument = vscode.window.activeTextEditor && vscode.window.activeTextEditor.document; + if (document !== activeDocument || changes.length === 0) { + return; + } + const filepath = this.client.toPath(document.uri); if (!filepath) { - return undefined; + return; } - const args: Proto.JsxClosingTagRequestArgs = typeConverters.Position.toFileLocationRequestArgs(filepath, position); - let body: Proto.TextInsertion | undefined = undefined; - try { - const response = await this.client.execute('jsxClosingTag', args, token); - body = response && response.body; - if (!body) { - return undefined; - } - } catch { - return undefined; + if (typeof this.timeout !== 'undefined') { + clearTimeout(this.timeout); } - return [this.getCompletion(body)]; - } + const lastChange = changes[changes.length - 1]; + const lastCharacter = lastChange.text[lastChange.text.length - 1]; + if (lastChange.rangeLength > 0 || lastCharacter !== '>' && lastCharacter !== '/') { + return; + } + + const rangeStart = lastChange.range.start; + const version = document.version; + this.timeout = setTimeout(async () => { + if (this._disposed) { + return; + } + + let position = new vscode.Position(rangeStart.line, rangeStart.character + lastChange.text.length); + let body: Proto.TextInsertion | undefined = undefined; + const args: Proto.JsxClosingTagRequestArgs = typeConverters.Position.toFileLocationRequestArgs(filepath, position); - private getCompletion(body: Proto.TextInsertion) { - const completion = new vscode.CompletionItem(body.newText); - completion.insertText = this.getTagSnippet(body); - return completion; + try { + const response = await this.client.execute('jsxClosingTag', args, null as any); + body = response && response.body; + if (!body) { + return; + } + } catch { + return; + } + + if (!this._disposed) { + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + const activeDocument = activeEditor.document; + if (document === activeDocument && activeDocument.version === version) { + const selections = activeEditor.selections; + const snippet = this.getTagSnippet(body); + if (selections.length && selections.some(s => s.active.isEqual(position))) { + activeEditor.insertSnippet(snippet, selections.map(s => s.active)); + } else { + activeEditor.insertSnippet(snippet, position); + } + } + } + } + + this.timeout = void 0; + }, 100); } private getTagSnippet(closingTag: Proto.TextInsertion): vscode.SnippetString { @@ -55,12 +115,42 @@ class TypeScriptTagCompletion implements vscode.CompletionItemProvider { } } +export class ActiveDocumentDependentRegistration { + private readonly _registration: ConditionalRegistration; + private readonly _disposables: vscode.Disposable[] = []; + + constructor( + private readonly selector: vscode.DocumentSelector, + register: () => vscode.Disposable, + ) { + this._registration = new ConditionalRegistration(register); + + this.update(); + + vscode.window.onDidChangeActiveTextEditor(() => { + this.update(); + }, null, this._disposables); + } + + public dispose() { + disposeAll(this._disposables); + this._registration.dispose(); + } + + private update() { + const editor = vscode.window.activeTextEditor; + const enabled = !!(editor && vscode.languages.match(this.selector, editor.document)); + this._registration.update(enabled); + } +} + export function register( selector: vscode.DocumentSelector, + modeId: string, client: ITypeScriptServiceClient, ) { return new VersionDependentRegistration(client, API.v300, () => - vscode.languages.registerCompletionItemProvider(selector, - new TypeScriptTagCompletion(client), - '>')); + new ConfigurationDependentRegistration(modeId, 'autoClosingTags', () => + new ActiveDocumentDependentRegistration(selector, () => + new TagClosing(client)))); } diff --git a/extensions/typescript-language-features/src/languageProvider.ts b/extensions/typescript-language-features/src/languageProvider.ts index 2d7cde5e693fb..c963ef6586c9b 100644 --- a/extensions/typescript-language-features/src/languageProvider.ts +++ b/extensions/typescript-language-features/src/languageProvider.ts @@ -91,7 +91,7 @@ export default class LanguageProvider { this.disposables.push((await import('./features/referencesCodeLens')).register(selector, this.description.id, this.client, cachedResponse)); this.disposables.push((await import('./features/rename')).register(selector, this.client)); this.disposables.push((await import('./features/signatureHelp')).register(selector, this.client)); - this.disposables.push((await import('./features/tagCompletion')).register(selector, this.client)); + this.disposables.push((await import('./features/tagCompletion')).register(selector, this.description.id, this.client)); this.disposables.push((await import('./features/typeDefinitions')).register(selector, this.client)); this.disposables.push((await import('./features/workspaceSymbols')).register(this.client, this.description.modeIds)); } diff --git a/extensions/typescript-language-features/src/utils/dependentRegistration.ts b/extensions/typescript-language-features/src/utils/dependentRegistration.ts index 9013dd28bc972..7ffe3c4195b00 100644 --- a/extensions/typescript-language-features/src/utils/dependentRegistration.ts +++ b/extensions/typescript-language-features/src/utils/dependentRegistration.ts @@ -8,7 +8,7 @@ import { ITypeScriptServiceClient } from '../typescriptService'; import API from './api'; import { disposeAll } from './dispose'; -class ConditionalRegistration { +export class ConditionalRegistration { private registration: vscode.Disposable | undefined = undefined; public constructor(