Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@
"description": "Enable/disable autoclosing of XML tags. \n\nIMPORTANT: Turn off editor.autoClosingTags for this to work",
"scope": "window"
},
"xml.matchingTagEditing": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about using the same name than vscode mirrorCursorOnMatchingTag ?

"type": "boolean",
"scope": "window",
"default": true,
"description": "Adds an additional cursor on the matching tag, allows for start/end tag editing."
},
"xml.codeLens.enabled": {
"type": "boolean",
"default": false,
Expand Down Expand Up @@ -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"
}
]
}
}
41 changes: 32 additions & 9 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,21 @@ import { activateTagClosing, AutoCloseResult } from './tagClosing';
import { Commands } from './commands';
import { onConfigurationChange, subscribeJDKChangeConfiguration } from './settings';
import { collectXmlJavaExtensions, onExtensionChange } from './plugin';
import { activateMirrorCursor } from './mirrorCursor';
import { error } from 'util';

export interface ScopeInfo {
scope : "default" | "global" | "workspace" | "folder";
scope: "default" | "global" | "workspace" | "folder";
configurationTarget: boolean;
}

namespace TagCloseRequest {
export const type: RequestType<TextDocumentPositionParams, AutoCloseResult, any, any> = new RequestType('xml/closeTag');
}


namespace MatchingTagPositionRequest {
export const type: RequestType<TextDocumentPositionParams, Position | null, any, any> = new RequestType('xml/matchingTagPosition');
}

export function activate(context: ExtensionContext) {
let storagePath = context.storagePath;
Expand Down Expand Up @@ -70,7 +74,7 @@ export function activate(context: ExtensionContext) {
}
}
}
},
},
synchronize: {
//preferences starting with these will trigger didChangeConfiguration
configurationSection: ['xml', '[xml]']
Expand Down Expand Up @@ -114,14 +118,33 @@ 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(matchingTagPositionRequestor, { xml: true }, 'xml.matchingTagEditing');
toDispose.push(disposable);

const matchingTagEditCommand = 'xml.toggleMatchingTagEdit';

const matchingTagEditHandler = () => {
const xmlConfiguration = workspace.getConfiguration('xml');
const current = xmlConfiguration.matchingTagEditing;
xmlConfiguration.update("matchingTagEditing", !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());
Expand All @@ -139,7 +162,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: {
Expand All @@ -160,7 +183,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;
Expand All @@ -170,7 +193,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: {
Expand Down
254 changes: 254 additions & 0 deletions src/mirrorCursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
/*---------------------------------------------------------------------------------------------
* 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,
Disposable,
TextDocument,
Position,
TextEditorSelectionChangeEvent,
Selection,
Range,
WorkspaceEdit
} from 'vscode';

export function activateMirrorCursor(
matchingTagPositionProvider: (document: TextDocument, position: Position) => Thenable<Position | null>,
supportedLanguages: { [id: string]: boolean },
configName: string
): Disposable {
let disposables: Disposable[] = [];

window.onDidChangeTextEditorSelection(event => onDidChangeTextEditorSelection(event), null, disposables);
let previousState = workspace.getConfiguration().get<boolean>(configName);
let wasNotified = false;
let isEnabled = false;
updateEnabledState();

workspace.onDidChangeConfiguration(updateEnabledState, null, disposables);

function updateEnabledState() {
updateStateSetting();
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<boolean>(configName)) {
return;
}
isEnabled = true;
}

function promptUpdateMessage() {
if(!wasNotified && previousState != isEnabled) {
window.showInformationMessage("Toggled the `xml.matchingTagEditing` preference in the Workspace settings.")
wasNotified = true;
}
previousState = isEnabled;
}

let prevCursors: readonly Selection[] = [];
let cursors: readonly Selection[] = [];
let inMirrorMode = false;

function onDidChangeTextEditorSelection(event: TextEditorSelectionChangeEvent) {
if (!isEnabled) {
return;
}

prevCursors = cursors;
cursors = event.selections;

if (cursors.length === 1) {
if (inMirrorMode && prevCursors.length === 2) {
if (cursors[0].isEqual(prevCursors[0]) || cursors[0].isEqual(prevCursors[1])) {
return;
}
}
if (event.selections[0].isEmpty) {
matchingTagPositionProvider(event.textEditor.document, event.selections[0].active).then(matchingTagPosition => {
if (matchingTagPosition && window.activeTextEditor) {
const charBeforeAndAfterPositionsRoughtlyEqual = isCharBeforeAndAfterPositionsRoughtlyEqual(
event.textEditor.document,
event.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;
});
}
}

const exitMirrorMode = () => {
inMirrorMode = false;
window.activeTextEditor!.selections = [window.activeTextEditor!.selections[0]];
};

if (cursors.length === 2 && inMirrorMode) {
if (event.selections[0].isEmpty && event.selections[1].isEmpty) {
if (
prevCursors.length === 2 &&
event.selections[0].anchor.line !== prevCursors[0].anchor.line &&
event.selections[1].anchor.line !== prevCursors[0].anchor.line
) {
exitMirrorMode();
return;
}

const charBeforeAndAfterPositionsRoughtlyEqual = isCharBeforeAndAfterPositionsRoughtlyEqual(
event.textEditor.document,
event.selections[0].anchor,
event.selections[1].anchor
);

if (!charBeforeAndAfterPositionsRoughtlyEqual) {
exitMirrorMode();
return;
} else {
// Need to cleanup in the case of <div |></div |>
if (
shouldDoCleanupForHtmlAttributeInput(
event.textEditor.document,
event.selections[0].anchor,
event.selections[1].anchor
)
) {
const cleanupEdit = new WorkspaceEdit();
const cleanupRange = new Range(event.selections[1].anchor.translate(0, -1), event.selections[1].anchor);
cleanupEdit.replace(event.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
* |<div>
* |</div>
*/
if (
charBeforePrimarySelection === ' ' &&
charBeforeSecondarySelection === ' ' &&
charAfterPrimarySelection === '<' &&
charAfterSecondarySelection === '<'
) {
return false;
}
/**
* Special case for exiting
* | <div>
* | </div>
*/
if (charBeforePrimarySelection === '\n' && charBeforeSecondarySelection === '\n') {
return false;
}
/**
* Special case for exiting
* <div>|
* </div>|
*/
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 <div |></div |>
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
* <div |></div >
* <div | id="a"></div >
* Before 1st cursor: ` `
* After 1st cursor: `>` or ` `
* Before 2nd cursor: ` `
* After 2nd cursor: `>`
*/
return (
primaryBeforeSecondary &&
charBeforePrimarySelection === ' ' &&
(charAfterPrimarySelection === '>' || charAfterPrimarySelection === ' ') &&
charBeforeSecondarySelection === ' ' &&
charAfterSecondarySelection === '>'
);
}