diff --git a/extensions/html-language-features/client/src/tagClosing.ts b/extensions/html-language-features/client/src/autoInsertion.ts similarity index 59% rename from extensions/html-language-features/client/src/tagClosing.ts rename to extensions/html-language-features/client/src/autoInsertion.ts index 0d0ef10f804..9eb7a358e42 100644 --- a/extensions/html-language-features/client/src/tagClosing.ts +++ b/extensions/html-language-features/client/src/autoInsertion.ts @@ -3,15 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { window, workspace, Disposable, TextDocument, Position, SnippetString, TextDocumentChangeEvent, TextDocumentChangeReason } from 'vscode'; +import { window, workspace, Disposable, TextDocument, Position, SnippetString, TextDocumentChangeEvent, TextDocumentChangeReason, Selection, TextDocumentContentChangeEvent } from 'vscode'; import { Runtime } from './htmlClient'; -export function activateTagClosing(tagProvider: (document: TextDocument, position: Position) => Thenable, supportedLanguages: { [id: string]: boolean }, configName: string, runtime: Runtime): Disposable { - +export function activateAutoInsertion(provider: (kind: 'autoQuote' | 'autoClose', document: TextDocument, position: Position) => Thenable, supportedLanguages: { [id: string]: boolean }, runtime: Runtime): Disposable { const disposables: Disposable[] = []; workspace.onDidChangeTextDocument(onDidChangeTextDocument, null, disposables); - let isEnabled = false; + let anyIsEnabled = false; + const isEnabled = { + 'autoQuote': false, + 'autoClose': false + }; updateEnabledState(); window.onDidChangeActiveTextEditor(updateEnabledState, null, disposables); @@ -24,7 +27,7 @@ export function activateTagClosing(tagProvider: (document: TextDocument, positio }); function updateEnabledState() { - isEnabled = false; + anyIsEnabled = false; const editor = window.activeTextEditor; if (!editor) { return; @@ -33,14 +36,13 @@ export function activateTagClosing(tagProvider: (document: TextDocument, positio if (!supportedLanguages[document.languageId]) { return; } - if (!workspace.getConfiguration(undefined, document.uri).get(configName)) { - return; - } - isEnabled = true; + isEnabled['autoQuote'] = workspace.getConfiguration(undefined, document.uri).get('html.autoCreateQuotes') ?? false; + isEnabled['autoClose'] = workspace.getConfiguration(undefined, document.uri).get('html.autoClosingTags') ?? false; + anyIsEnabled = isEnabled['autoQuote'] || isEnabled['autoClose']; } function onDidChangeTextDocument({ document, contentChanges, reason }: TextDocumentChangeEvent) { - if (!isEnabled || contentChanges.length === 0 || reason === TextDocumentChangeReason.Undo || reason === TextDocumentChangeReason.Redo) { + if (!anyIsEnabled || contentChanges.length === 0 || reason === TextDocumentChangeReason.Undo || reason === TextDocumentChangeReason.Redo) { return; } const activeDocument = window.activeTextEditor && window.activeTextEditor.document; @@ -53,15 +55,20 @@ export function activateTagClosing(tagProvider: (document: TextDocument, positio const lastChange = contentChanges[contentChanges.length - 1]; const lastCharacter = lastChange.text[lastChange.text.length - 1]; - if (lastChange.rangeLength > 0 || lastCharacter !== '>' && lastCharacter !== '/') { - return; + if (isEnabled['autoQuote'] && lastChange.rangeLength === 0 && lastCharacter === '=') { + doAutoInsert('autoQuote', document, lastChange); + } else if (isEnabled['autoClose'] && lastChange.rangeLength === 0 && (lastCharacter === '>' || lastCharacter === '/')) { + doAutoInsert('autoClose', document, lastChange); } + } + + function doAutoInsert(kind: 'autoQuote' | 'autoClose', document: TextDocument, lastChange: TextDocumentContentChangeEvent) { const rangeStart = lastChange.range.start; const version = document.version; timeout = runtime.timer.setTimeout(() => { const position = new Position(rangeStart.line, rangeStart.character + lastChange.text.length); - tagProvider(document, position).then(text => { - if (text && isEnabled) { + provider(kind, document, position).then(text => { + if (text && isEnabled[kind]) { const activeEditor = window.activeTextEditor; if (activeEditor) { const activeDocument = activeEditor.document; @@ -69,6 +76,10 @@ export function activateTagClosing(tagProvider: (document: TextDocument, positio const selections = activeEditor.selections; if (selections.length && selections.some(s => s.active.isEqual(position))) { activeEditor.insertSnippet(new SnippetString(text), selections.map(s => s.active)); + if (kind === 'autoQuote') { + // Move all cursors one forward + activeEditor.selections = selections.map(s => new Selection(s.active.translate(0, 1), s.active.translate(0, 1))); + } } else { activeEditor.insertSnippet(new SnippetString(text), position); } diff --git a/extensions/html-language-features/client/src/htmlClient.ts b/extensions/html-language-features/client/src/htmlClient.ts index 472977e652f..896382a5831 100644 --- a/extensions/html-language-features/client/src/htmlClient.ts +++ b/extensions/html-language-features/client/src/htmlClient.ts @@ -15,9 +15,9 @@ import { LanguageClientOptions, RequestType, TextDocumentPositionParams, DocumentRangeFormattingParams, DocumentRangeFormattingRequest, ProvideCompletionItemsSignature, TextDocumentIdentifier, RequestType0, Range as LspRange, NotificationType, CommonLanguageClient } from 'vscode-languageclient'; -import { activateTagClosing } from './tagClosing'; import { FileSystemProvider, serveFileSystemRequests } from './requests'; import { getCustomDataSource } from './customData'; +import { activateAutoInsertion } from './autoInsertion'; namespace CustomDataChangedNotification { export const type: NotificationType = new NotificationType('html/customDataChanged'); @@ -27,9 +27,6 @@ namespace CustomDataContent { export const type: RequestType = new RequestType('html/customDataContent'); } -namespace TagCloseRequest { - export const type: RequestType = new RequestType('html/tag'); -} // experimental: semantic tokens interface SemanticTokenParams { textDocument: TextDocumentIdentifier; @@ -133,11 +130,20 @@ export function startClient(context: ExtensionContext, newLanguageClient: Langua client.onRequest(CustomDataContent.type, customDataSource.getContent); - let tagRequestor = (document: TextDocument, position: Position) => { + let insertRequestor = (kind: 'autoQuote' | 'autoClose', document: TextDocument, position: Position) => { let param = client.code2ProtocolConverter.asTextDocumentPositionParams(document, position); - return client.sendRequest(TagCloseRequest.type, param); + let request: RequestType; + switch (kind) { + case 'autoQuote': + request = new RequestType('html/quote'); + break; + case 'autoClose': + request = new RequestType('html/tag'); + break; + } + return client.sendRequest(request, param); }; - disposable = activateTagClosing(tagRequestor, { html: true, handlebars: true }, 'html.autoClosingTags', runtime); + let disposable = activateAutoInsertion(insertRequestor, { html: true, handlebars: true }, runtime); toDispose.push(disposable); disposable = client.onTelemetry(e => { diff --git a/extensions/html-language-features/package.json b/extensions/html-language-features/package.json index 1a8722e0299..b1bd5b5bafa 100644 --- a/extensions/html-language-features/package.json +++ b/extensions/html-language-features/package.json @@ -197,6 +197,12 @@ "default": true, "description": "%html.validate.styles%" }, + "html.autoCreateQuotes": { + "type": "boolean", + "scope": "resource", + "default": true, + "description": "%html.autoCreateQuotes%" + }, "html.autoClosingTags": { "type": "boolean", "scope": "resource", diff --git a/extensions/html-language-features/package.nls.json b/extensions/html-language-features/package.nls.json index 00a218de43e..702ba050541 100644 --- a/extensions/html-language-features/package.nls.json +++ b/extensions/html-language-features/package.nls.json @@ -27,6 +27,7 @@ "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.autoCreateQuotes": "Enable/disable auto creation of quotes for HTML attribute assignment.", "html.autoClosingTags": "Enable/disable autoclosing of HTML tags.", "html.completion.attributeDefaultValue": "Controls the default value for attributes when completion is accepted.", "html.completion.attributeDefaultValue.doublequotes": "Attribute value is set to \"\".", diff --git a/extensions/html-language-features/server/src/htmlServer.ts b/extensions/html-language-features/server/src/htmlServer.ts index 8a053592ba0..150fdb77d18 100644 --- a/extensions/html-language-features/server/src/htmlServer.ts +++ b/extensions/html-language-features/server/src/htmlServer.ts @@ -34,6 +34,10 @@ namespace CustomDataContent { export const type: RequestType = new RequestType('html/customDataContent'); } +namespace QuoteCreateRequest { + export const type: RequestType = new RequestType('html/quote'); +} + namespace TagCloseRequest { export const type: RequestType = new RequestType('html/tag'); } @@ -83,7 +87,7 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) let workspaceFoldersSupport = false; let foldingRangeLimit = Number.MAX_VALUE; - const customDataRequestService : CustomDataRequestService = { + const customDataRequestService: CustomDataRequestService = { getContent(uri: string) { return connection.sendRequest(CustomDataContent.type, uri); } @@ -483,6 +487,22 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) }, [], `Error while computing color presentations for ${params.textDocument.uri}`, token); }); + connection.onRequest(QuoteCreateRequest.type, (params, token) => { + return runSafe(runtime, async () => { + 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.doAutoQuote) { + return mode.doAutoQuote(document, pos); + } + } + } + return null; + }, null, `Error while computing tag close actions for ${params.textDocument.uri}`, token); + }); + connection.onRequest(TagCloseRequest.type, (params, token) => { return runSafe(runtime, async () => { const document = documents.get(params.textDocument.uri); diff --git a/extensions/html-language-features/server/src/modes/htmlMode.ts b/extensions/html-language-features/server/src/modes/htmlMode.ts index 9c75531b3d0..87d9ded6bc8 100644 --- a/extensions/html-language-features/server/src/modes/htmlMode.ts +++ b/extensions/html-language-features/server/src/modes/htmlMode.ts @@ -55,6 +55,18 @@ export function getHTMLMode(htmlLanguageService: HTMLLanguageService, workspace: async getFoldingRanges(document: TextDocument): Promise { return htmlLanguageService.getFoldingRanges(document); }, + async doAutoQuote(document: TextDocument, position: Position, settings = workspace.settings) { + const htmlSettings = settings?.html; + const options = merge(htmlSettings?.suggest, {}); + options.attributeDefaultValue = htmlSettings?.completion?.attributeDefaultValue ?? 'doublequotes'; + + const offset = document.offsetAt(position); + const text = document.getText(); + if (offset > 0 && text.charAt(offset - 1) === '=') { + return htmlLanguageService.doQuoteComplete(document, position, htmlDocuments.get(document), options); + } + return null; + }, async doAutoClose(document: TextDocument, position: Position) { const offset = document.offsetAt(position); const text = document.getText(); diff --git a/extensions/html-language-features/server/src/modes/languageModes.ts b/extensions/html-language-features/server/src/modes/languageModes.ts index 1e188e7c0b7..f0b3370a7d8 100644 --- a/extensions/html-language-features/server/src/modes/languageModes.ts +++ b/extensions/html-language-features/server/src/modes/languageModes.ts @@ -72,6 +72,7 @@ export interface LanguageMode { format?: (document: TextDocument, range: Range, options: FormattingOptions, settings?: Settings) => Promise; findDocumentColors?: (document: TextDocument) => Promise; getColorPresentations?: (document: TextDocument, color: Color, range: Range) => Promise; + doAutoQuote?: (document: TextDocument, position: Position) => Promise; doAutoClose?: (document: TextDocument, position: Position) => Promise; findMatchingTagPosition?: (document: TextDocument, position: Position) => Promise; getFoldingRanges?: (document: TextDocument) => Promise;