diff --git a/package.json b/package.json index 7a5966d0..5a3ec083 100644 --- a/package.json +++ b/package.json @@ -192,6 +192,12 @@ "description": "Enable/disable autoclosing of XML tags. \n\nIMPORTANT: Turn off editor.autoClosingTags for this to work", "scope": "window" }, + "xml.mirrorCursorOnMatchingTag": { + "type": "boolean", + "scope": "resource", + "default": false, + "description": "Adds an additional cursor on the matching tag, allows for start/end tag editing." + }, "xml.codeLens.enabled": { "type": "boolean", "default": false, @@ -294,6 +300,14 @@ "fileMatch": "package.json", "url": "./schemas/package.schema.json" } + ], + "keybindings":[ + { + "command": "xml.toggleMatchingTagEdit", + "key": "ctrl+shift+f2", + "mac": "cmd+shift+f2", + "when": "editorFocus" + } ] } } diff --git a/src/extension.ts b/src/extension.ts index 817ebe83..a0def1ca 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,16 +13,17 @@ import { prepareExecutable } from './javaServerStarter'; import { LanguageClientOptions, RevealOutputChannelOn, LanguageClient, DidChangeConfigurationNotification, RequestType, TextDocumentPositionParams, ReferencesRequest } from 'vscode-languageclient'; import * as requirements from './requirements'; -import { languages, IndentAction, workspace, window, commands, ExtensionContext, TextDocument, Position, LanguageConfiguration, Uri, extensions } from "vscode"; +import { languages, ConfigurationTarget, IndentAction, workspace, window, commands, ExtensionContext, TextDocument, Position, LanguageConfiguration, Uri, WorkspaceConfiguration, extensions } from "vscode"; import * as path from 'path'; import * as os from 'os'; import { activateTagClosing, AutoCloseResult } from './tagClosing'; import { Commands } from './commands'; import { onConfigurationChange, subscribeJDKChangeConfiguration } from './settings'; import { collectXmlJavaExtensions, onExtensionChange } from './plugin'; +import { activateMirrorCursor } from './mirrorCursor'; export interface ScopeInfo { - scope : "default" | "global" | "workspace" | "folder"; + scope: "default" | "global" | "workspace" | "folder"; configurationTarget: boolean; } @@ -30,7 +31,9 @@ namespace TagCloseRequest { export const type: RequestType = new RequestType('xml/closeTag'); } - +namespace MatchingTagPositionRequest { + export const type: RequestType = new RequestType('xml/matchingTagPosition'); +} export function activate(context: ExtensionContext) { let storagePath = context.storagePath; @@ -70,7 +73,7 @@ export function activate(context: ExtensionContext) { } } } - }, + }, synchronize: { //preferences starting with these will trigger didChangeConfiguration configurationSection: ['xml', '[xml]'] @@ -114,14 +117,38 @@ export function activate(context: ExtensionContext) { return text; }; + disposable = activateTagClosing(tagProvider, { xml: true, xsl: true }, Commands.AUTO_CLOSE_TAGS); + toDispose.push(disposable); + + //Setup mirrored tag rename request + const matchingTagPositionRequestor = (document: TextDocument, position: Position) => { + let param = languageClient.code2ProtocolConverter.asTextDocumentPositionParams(document, position); + return languageClient.sendRequest(MatchingTagPositionRequest.type, param); + }; + + disposable = activateMirrorCursor(context, matchingTagPositionRequestor, { xml: true }, 'xml.mirrorCursorOnMatchingTag'); + toDispose.push(disposable); + + const matchingTagEditCommand = 'xml.toggleMatchingTagEdit'; + + const matchingTagEditHandler = async () => { + let xmlConfiguration: WorkspaceConfiguration; + if (window.activeTextEditor) { + xmlConfiguration = workspace.getConfiguration('xml', window.activeTextEditor.document.uri); + } else { + xmlConfiguration = workspace.getConfiguration('xml'); + } + const current = xmlConfiguration.mirrorCursorOnMatchingTag; + await updateConfig(xmlConfiguration, 'mirrorCursorOnMatchingTag', !current); + } + + toDispose.push(commands.registerCommand(matchingTagEditCommand, matchingTagEditHandler)); + if (extensions.onDidChange) {// Theia doesn't support this API yet extensions.onDidChange(() => { onExtensionChange(extensions.all); }); } - - disposable = activateTagClosing(tagProvider, { xml: true, xsl: true }, Commands.AUTO_CLOSE_TAGS); - toDispose.push(disposable); }); languages.setLanguageConfiguration('xml', getIndentationRules()); languages.setLanguageConfiguration('xsl', getIndentationRules()); @@ -139,7 +166,7 @@ export function activate(context: ExtensionContext) { let configXML = workspace.getConfiguration().get('xml'); let xml; if (!configXML) { //Set default preferences if not provided - const defaultValue = + const defaultValue = { xml: { trace: { @@ -160,7 +187,7 @@ export function activate(context: ExtensionContext) { xml = defaultValue; } else { let x = JSON.stringify(configXML); //configXML is not a JSON type - xml = { "xml" : JSON.parse(x)}; + xml = { "xml": JSON.parse(x) }; } xml['xml']['logs']['file'] = logfile; xml['xml']['useCache'] = true; @@ -171,7 +198,7 @@ export function activate(context: ExtensionContext) { function getIndentationRules(): LanguageConfiguration { return { - + // indentationRules referenced from: // https://github.com/microsoft/vscode/blob/d00558037359acceea329e718036c19625f91a1a/extensions/html-language-features/client/src/htmlMain.ts#L114-L115 indentationRules: { @@ -192,3 +219,24 @@ function getIndentationRules(): LanguageConfiguration { }; } +/** + * Update config with the following precedence: WorkspaceFolder -> Workspace -> Global + * @param config config containing the section to update + * @param section section to update + * @param value new value + */ +async function updateConfig(config: WorkspaceConfiguration, section: string, value: any): Promise { + try { + await config.update(section, value); + return; + } catch(e) { + // try ConfigurationTarget.Global + } + + try { + await config.update(section, value, ConfigurationTarget.Global); + return; + } catch(e) { + throw 'Failed to update config'; + } +} \ No newline at end of file diff --git a/src/mirrorCursor.ts b/src/mirrorCursor.ts new file mode 100644 index 00000000..56ab721c --- /dev/null +++ b/src/mirrorCursor.ts @@ -0,0 +1,276 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + window, + workspace, + ConfigurationChangeEvent, + ExtensionContext, + Disposable, + TextDocument, + Position, + TextEditor, + Selection, + Range, + WorkspaceEdit +} from 'vscode'; + +export function activateMirrorCursor( + context: ExtensionContext, + matchingTagPositionProvider: (document: TextDocument, position: Position) => Thenable, + supportedLanguages: { [id: string]: boolean }, + configName: string +): Disposable { + const disposables: Disposable[] = []; + + let isEnabled = false; + let prevCursors: readonly Selection[] = []; + let cursors: readonly Selection[] = []; + let inMirrorMode = false; + + updateStateSetting(); + updateCursorsIfActiveEditor(); + + window.onDidChangeTextEditorSelection(event => { + updateCursors(event.selections, event.textEditor) + }, undefined, disposables); + + workspace.onDidChangeConfiguration(updateEnabledState, undefined, disposables); + + function updateEnabledState(event: ConfigurationChangeEvent) { + if (!event.affectsConfiguration(configName)) { + return; + } + updateStateSetting(); + updateCursorsIfActiveEditor(); + promptUpdateMessage(); + } + + function updateStateSetting() { + isEnabled = false; + let editor = window.activeTextEditor; + if (!editor) { + return; + } + let document = editor.document; + if (!supportedLanguages[document.languageId]) { + return; + } + if (!workspace.getConfiguration(undefined, document.uri).get(configName)) { + return; + } + isEnabled = true; + } + + function promptUpdateMessage() { + const key: string = 'matchingTagNotified'; + const wasNotified: boolean | undefined= context.globalState.get(key); + if(!wasNotified) { + window.showInformationMessage('Toggled the `xml.mirrorCursorOnMatchingTag` preference.'); + context.globalState.update(key, true); + } + } + + function exitMirrorMode() { + inMirrorMode = false; + window.activeTextEditor!.selections = [window.activeTextEditor!.selections[0]]; + }; + + function updateCursorsIfActiveEditor() { + const activeEditor: TextEditor | undefined = window.activeTextEditor; + if (activeEditor) { + updateCursors(activeEditor.selections, activeEditor); + } + } + + function updateCursors(selections: ReadonlyArray, textEditor: TextEditor) { + if (!isEnabled) { + if (window.activeTextEditor) { + prevCursors = window.activeTextEditor.selections; + exitMirrorMode(); + cursors = window.activeTextEditor.selections; + } + return; + } + + prevCursors = cursors; + cursors = selections; + + if (cursors.length === 1) { + if (inMirrorMode && prevCursors.length === 2) { + if (cursors[0].isEqual(prevCursors[0]) || cursors[0].isEqual(prevCursors[1])) { + return; + } + } + if (selections[0].isEmpty) { + matchingTagPositionProvider(textEditor.document, selections[0].active).then(matchingTagPosition => { + if (matchingTagPosition && window.activeTextEditor) { + const charBeforeAndAfterPositionsRoughtlyEqual = isCharBeforeAndAfterPositionsRoughtlyEqual( + textEditor.document, + selections[0].anchor, + new Position(matchingTagPosition.line, matchingTagPosition.character) + ); + + if (charBeforeAndAfterPositionsRoughtlyEqual) { + inMirrorMode = true; + const newCursor = new Selection( + matchingTagPosition.line, + matchingTagPosition.character, + matchingTagPosition.line, + matchingTagPosition.character + ); + window.activeTextEditor.selections = [...window.activeTextEditor.selections, newCursor]; + } + } + }).then(undefined, err => { + const msg = err.message ; + // mutes "rejected promise not handled within 1 second" + if (msg && !msg.endsWith('has been cancelled')){ + console.log(err); + } + return; + }); + } + } + + if (cursors.length === 2 && inMirrorMode) { + if (selections[0].isEmpty && selections[1].isEmpty) { + if ( + prevCursors.length === 2 && + selections[0].anchor.line !== prevCursors[0].anchor.line && + selections[1].anchor.line !== prevCursors[0].anchor.line + ) { + exitMirrorMode(); + return; + } + + const charBeforeAndAfterPositionsRoughtlyEqual = isCharBeforeAndAfterPositionsRoughtlyEqual( + textEditor.document, + selections[0].anchor, + selections[1].anchor + ); + + if (!charBeforeAndAfterPositionsRoughtlyEqual) { + exitMirrorMode(); + return; + } else { + // Need to cleanup in the case of
+ if ( + shouldDoCleanupForHtmlAttributeInput( + textEditor.document, + selections[0].anchor, + selections[1].anchor + ) + ) { + const cleanupEdit = new WorkspaceEdit(); + const cleanupRange = new Range(selections[1].anchor.translate(0, -1), selections[1].anchor); + cleanupEdit.replace(textEditor.document.uri, cleanupRange, ''); + exitMirrorMode(); + workspace.applyEdit(cleanupEdit); + } + } + } + } + } + + return Disposable.from(...disposables); +} + +function getCharBefore(document: TextDocument, position: Position) { + const offset = document.offsetAt(position); + if (offset === 0) { + return ''; + } + + return document.getText(new Range(document.positionAt(offset - 1), position)); +} + +function getCharAfter(document: TextDocument, position: Position) { + const offset = document.offsetAt(position); + if (offset === document.getText().length) { + return ''; + } + + return document.getText(new Range(position, document.positionAt(offset + 1))); +} + +// Check if chars before and after the two positions are equal +// For the chars before, `<` and `/` are consiered equal to handle the case of `<|>` +function isCharBeforeAndAfterPositionsRoughtlyEqual(document: TextDocument, firstPos: Position, secondPos: Position) { + const charBeforePrimarySelection = getCharBefore(document, firstPos); + const charAfterPrimarySelection = getCharAfter(document, firstPos); + const charBeforeSecondarySelection = getCharBefore(document, secondPos); + const charAfterSecondarySelection = getCharAfter(document, secondPos); + + /** + * Special case for exiting + * |
+ * |
+ */ + if ( + charBeforePrimarySelection === ' ' && + charBeforeSecondarySelection === ' ' && + charAfterPrimarySelection === '<' && + charAfterSecondarySelection === '<' + ) { + return false; + } + /** + * Special case for exiting + * |
+ * |
+ */ + if (charBeforePrimarySelection === '\n' && charBeforeSecondarySelection === '\n') { + return false; + } + /** + * Special case for exiting + *
| + *
| + */ + if (charAfterPrimarySelection === '\n' && charAfterSecondarySelection === '\n') { + return false; + } + + // Exit mirror mode when cursor position no longer mirror + // Unless it's in the case of `<|>` + const charBeforeBothPositionRoughlyEqual = + charBeforePrimarySelection === charBeforeSecondarySelection || + (charBeforePrimarySelection === '/' && charBeforeSecondarySelection === '<') || + (charBeforeSecondarySelection === '/' && charBeforePrimarySelection === '<'); + const charAfterBothPositionRoughlyEqual = + charAfterPrimarySelection === charAfterSecondarySelection || + (charAfterPrimarySelection === ' ' && charAfterSecondarySelection === '>') || + (charAfterSecondarySelection === ' ' && charAfterPrimarySelection === '>'); + + return charBeforeBothPositionRoughlyEqual && charAfterBothPositionRoughlyEqual; +} + +function shouldDoCleanupForHtmlAttributeInput(document: TextDocument, firstPos: Position, secondPos: Position) { + // Need to cleanup in the case of
+ const charBeforePrimarySelection = getCharBefore(document, firstPos); + const charAfterPrimarySelection = getCharAfter(document, firstPos); + const charBeforeSecondarySelection = getCharBefore(document, secondPos); + const charAfterSecondarySelection = getCharAfter(document, secondPos); + + const primaryBeforeSecondary = document.offsetAt(firstPos) < document.offsetAt(secondPos); + + /** + * Check two cases + *
+ *
+ * Before 1st cursor: ` ` + * After 1st cursor: `>` or ` ` + * Before 2nd cursor: ` ` + * After 2nd cursor: `>` + */ + return ( + primaryBeforeSecondary && + charBeforePrimarySelection === ' ' && + (charAfterPrimarySelection === '>' || charAfterPrimarySelection === ' ') && + charBeforeSecondarySelection === ' ' && + charAfterSecondarySelection === '>' + ); +} \ No newline at end of file