diff --git a/package.json b/package.json index fd514876..2117707b 100644 --- a/package.json +++ b/package.json @@ -430,6 +430,12 @@ "markdownDescription": "Enable/disable autoclosing of XML tags. Default is `true`. \n\nIMPORTANT: Turn off `#editor.autoClosingTags#` for this to work.", "scope": "window" }, + "xml.completion.autoCreateQuotes": { + "type": "boolean", + "default": true, + "markdownDescription": "Enable/disable automatic creation of quotes for XML attribute values when typing `=`. Default is `true`.", + "scope": "window" + }, "xml.completion.autoCloseRemovesContent": { "type": "boolean", "default": true, diff --git a/src/client/autoInsertion.ts b/src/client/autoInsertion.ts new file mode 100644 index 00000000..1b95ca11 --- /dev/null +++ b/src/client/autoInsertion.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * + * Adapted from: https://github.com/microsoft/vscode/blob/main/extensions/html-language-features/client/src/autoInsertion.ts + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import { window, workspace, Disposable, TextDocumentContentChangeEvent, TextDocument, Position, SnippetString, Range, TextDocumentChangeEvent, TextDocumentChangeReason } from 'vscode'; + +export interface AutoInsertResult { + snippet: string, + range?: Range +} + +export function activateAutoInsertion( + provider: (kind: 'autoQuote' | 'autoClose', document: TextDocument, position: Position) => Thenable, + supportedLanguages: { [id: string]: boolean }, + autoCloseConfigName: string, + autoQuoteConfigName: string +): Disposable { + + const disposables: Disposable[] = []; + workspace.onDidChangeTextDocument(onDidChangeTextDocument, null, disposables); + + let anyIsEnabled = false; + const isEnabled = { + 'autoQuote': false, + 'autoClose': false + }; + updateEnabledState(); + window.onDidChangeActiveTextEditor(updateEnabledState, null, disposables); + + let timeout: NodeJS.Timeout | undefined = undefined; + + disposables.push({ + dispose: () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + } + }); + + function updateEnabledState() { + anyIsEnabled = false; + const editor = window.activeTextEditor; + if (!editor) { + return; + } + const document = editor.document; + if (!supportedLanguages[document.languageId]) { + return; + } + const configurations = workspace.getConfiguration(undefined, document.uri); + isEnabled['autoClose'] = configurations.get(autoCloseConfigName) ?? false; + isEnabled['autoQuote'] = configurations.get(autoQuoteConfigName) ?? false; + anyIsEnabled = isEnabled['autoClose'] || isEnabled['autoQuote']; + } + + function onDidChangeTextDocument({ document, contentChanges, reason }: TextDocumentChangeEvent) { + if (!anyIsEnabled || contentChanges.length === 0 || reason === TextDocumentChangeReason.Undo || reason === TextDocumentChangeReason.Redo) { + return; + } + const activeDocument = window.activeTextEditor && window.activeTextEditor.document; + if (document !== activeDocument) { + return; + } + if (typeof timeout !== 'undefined') { + clearTimeout(timeout); + } + const lastChange = contentChanges[contentChanges.length - 1]; + + if (lastChange.rangeLength === 0 && isSingleLine(lastChange.text)) { + // Insertion case + const lastCharacter = lastChange.text[lastChange.text.length - 1]; + if (isEnabled['autoQuote'] && lastCharacter === '=') { + doAutoInsert('autoQuote', document, lastChange); + } else if (isEnabled['autoClose'] && (lastCharacter === '>' || lastCharacter === '/')) { + doAutoInsert('autoClose', document, lastChange); + } + } else if (isEnabled['autoClose'] && lastChange.rangeLength > 0 && lastChange.text === '') { + // Deletion case: check if the character at the deletion position is now '>' + // This handles the case where '/' is removed from leaving + const position = lastChange.range.start; + const lineText = document.lineAt(position.line).text; + if (position.character < lineText.length) { + const charAtCursor = lineText.charAt(position.character); + if (charAtCursor === '>') { + doAutoInsertAtPosition('autoClose', document, new Position(position.line, position.character + 1)); + } + } + } + } + + function isSingleLine(text: string): boolean { + return !/\n/.test(text); + } + + function doAutoInsert(kind: 'autoQuote' | 'autoClose', document: TextDocument, lastChange: TextDocumentContentChangeEvent) { + const rangeStart = lastChange.range.start; + const position = new Position(rangeStart.line, rangeStart.character + lastChange.text.length); + doAutoInsertAtPosition(kind, document, position); + } + + function doAutoInsertAtPosition(kind: 'autoQuote' | 'autoClose', document: TextDocument, position: Position) { + const version = document.version; + timeout = setTimeout(() => { + provider(kind, document, position).then(result => { + const text = result?.snippet; + if (text && isEnabled[kind]) { + const activeEditor = window.activeTextEditor; + if (activeEditor) { + const activeDocument = activeEditor.document; + if (document === activeDocument && activeDocument.version === version) { + const selections = activeEditor.selections; + if (selections.length > 1 && selections.some(s => s.active.isEqual(position))) { + activeEditor.insertSnippet(new SnippetString(text), selections.map(s => s.active)); + } else { + activeEditor.insertSnippet(new SnippetString(text), getReplaceLocation(result.range, position)); + } + } + } + } + }, (_reason: any) => { + console.log('xml/autoInsert request has been cancelled'); + }); + timeout = undefined; + }, 100); + } + + return Disposable.from(...disposables); +} + +function getReplaceLocation(range: Range | undefined, position: Position): Range | Position { + if (range != null) { + return new Range( + new Position(range.start.line, range.start.character), + new Position(range.end.line, range.end.character) + ); + } + return position; +} diff --git a/src/client/xmlClient.ts b/src/client/xmlClient.ts index 15da34ea..77623a5c 100644 --- a/src/client/xmlClient.ts +++ b/src/client/xmlClient.ts @@ -1,6 +1,6 @@ import { TelemetryEvent } from '@redhat-developer/vscode-redhat-telemetry/lib'; import { commands, ExtensionContext, extensions, Position, TextDocument, TextEditor, Uri, window, workspace } from 'vscode'; -import { Command, ConfigurationParams, ConfigurationRequest, DidChangeConfigurationNotification, DocumentFilter, DocumentSelector, ExecuteCommandParams, LanguageClientOptions, MessageType, NotificationType, RequestType, RevealOutputChannelOn, State, TextDocumentPositionParams } from "vscode-languageclient"; +import { Command, ConfigurationParams, ConfigurationRequest, DidChangeConfigurationNotification, DocumentFilter, ExecuteCommandParams, LanguageClientOptions, MessageType, NotificationType, RequestType, RevealOutputChannelOn, State } from "vscode-languageclient"; import { Executable, LanguageClient } from 'vscode-languageclient/node'; import { XMLFileAssociation } from '../api/xmlExtensionApi'; import { registerClientServerCommands } from '../commands/registerCommands'; @@ -13,7 +13,7 @@ import { containsVariableReferenceToCurrentFile } from '../settings/variableSubs import * as Telemetry from '../telemetry'; import { ClientErrorHandler } from './clientErrorHandler'; import { getLanguageParticipants } from './languageParticipants'; -import { activateTagClosing, AutoCloseResult } from './tagClosing'; +import { activateAutoInsertion, AutoInsertResult } from './autoInsertion'; const languageParticipants = getLanguageParticipants(); @@ -28,7 +28,13 @@ export const XML_SUPPORTED_LANGUAGE_IDS: string[] = XML_SUPPORTED_DOCUMENT_SELEC const ExecuteClientCommandRequest: RequestType = new RequestType('xml/executeClientCommand'); -const TagCloseRequest: RequestType = new RequestType('xml/closeTag'); +interface AutoInsertParams { + kind: 'autoQuote' | 'autoClose'; + textDocument: { uri: string }; + position: { line: number; character: number }; +} + +const AutoInsertRequest: RequestType = new RequestType('xml/autoInsert'); interface ActionableMessage { severity: MessageType; @@ -80,13 +86,16 @@ export async function startLanguageClient(context: ExtensionContext, executable: registerClientServerCommands(context, languageClient); - // Setup autoCloseTags - const tagProvider = (document: TextDocument, position: Position) => { - const param = languageClient.code2ProtocolConverter.asTextDocumentPositionParams(document, position); - const text = languageClient.sendRequest(TagCloseRequest, param); - return text; + // Setup auto-insertion (autoClose tags + autoQuote attributes) + const autoInsertProvider = (kind: 'autoQuote' | 'autoClose', document: TextDocument, position: Position) => { + const param: AutoInsertParams = { + kind, + textDocument: languageClient.code2ProtocolConverter.asTextDocumentIdentifier(document), + position: languageClient.code2ProtocolConverter.asPosition(position) + }; + return languageClient.sendRequest(AutoInsertRequest, param); }; - context.subscriptions.push(activateTagClosing(tagProvider, { xml: true, xsl: true }, ServerCommandConstants.AUTO_CLOSE_TAGS)); + context.subscriptions.push(activateAutoInsertion(autoInsertProvider, { xml: true, xsl: true }, ServerCommandConstants.AUTO_CLOSE_TAGS, ServerCommandConstants.AUTO_CREATE_QUOTES)); if (extensions.onDidChange) {// Theia doesn't support this API yet context.subscriptions.push(extensions.onDidChange(() => { diff --git a/src/commands/serverCommandConstants.ts b/src/commands/serverCommandConstants.ts index bb5352f5..669788e0 100644 --- a/src/commands/serverCommandConstants.ts +++ b/src/commands/serverCommandConstants.ts @@ -9,6 +9,11 @@ import * as ClientCommandConstants from "./clientCommandConstants"; */ export const AUTO_CLOSE_TAGS = 'xml.completion.autoCloseTags'; +/** + * Auto create quotes after attribute = + */ +export const AUTO_CREATE_QUOTES = 'xml.completion.autoCreateQuotes'; + /** * Commands to revalidate files with an LSP command on the XML Language Server */