Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Emmet - Wrap with abbreviation with live preview #45092

Merged
merged 17 commits into from
Mar 7, 2018
Merged
Show file tree
Hide file tree
Changes from 7 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
173 changes: 150 additions & 23 deletions extensions/emmet/src/abbreviationActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ interface ExpandAbbreviationInput {
filter?: string;
}

interface RangesAndContent {
currentRange: vscode.Range;
Copy link
Contributor

Choose a reason for hiding this comment

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

The currentRange here will only have the preview ranges correct? If so, let's rename it to previewRange to make it more readable.
Also rename content to originalContent to match with the originalRange and RangesAndContent to PreviewRangesWithContent

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure

originalRange: vscode.Range;
content: string;
}

export function wrapWithAbbreviation(args: any) {
if (!validate(false) || !vscode.window.activeTextEditor) {
return;
Expand All @@ -37,45 +43,166 @@ export function wrapWithAbbreviation(args: any) {
return;
}

const abbreviationPromise = (args && args['abbreviation']) ? Promise.resolve(args['abbreviation']) : vscode.window.showInputBox({ prompt: 'Enter Abbreviation' });
let previewMade = false;

// Fetch general information for the succesive expansions. i.e. the ranges to replace and its contents
let expandAbbrList: ExpandAbbreviationInput[] = [];
let rangesToReplace: RangesAndContent[] = [];

editor.selections.sort((a: vscode.Selection, b: vscode.Selection) => { return a.start.line - b.start.line; }).forEach(selection => {
let rangeToReplace: vscode.Range = selection.isReversed ? new vscode.Range(selection.active, selection.anchor) : selection;
if (rangeToReplace.isEmpty) {
let { active } = selection;
let currentNode = getNode(rootNode, active, true);
if (currentNode && (currentNode.start.line === active.line || currentNode.end.line === active.line)) {
rangeToReplace = new vscode.Range(currentNode.start, currentNode.end);
} else {
rangeToReplace = new vscode.Range(rangeToReplace.start.line, 0, rangeToReplace.start.line, editor.document.lineAt(rangeToReplace.start.line).text.length);
}
}

const firstLineOfSelection = editor.document.lineAt(rangeToReplace.start).text.substr(rangeToReplace.start.character);
const matches = firstLineOfSelection.match(/^(\s*)/);
const preceedingWhiteSpace = matches ? matches[1].length : 0;

rangeToReplace = new vscode.Range(rangeToReplace.start.line, rangeToReplace.start.character + preceedingWhiteSpace, rangeToReplace.end.line, rangeToReplace.end.character);
let textToReplace = editor.document.getText(rangeToReplace);
rangesToReplace.push({ currentRange: rangeToReplace, originalRange: rangeToReplace, content: textToReplace });
});

let abbreviationPromise;
let currentValue = '';

function inputChanged(value: string): string {
if (value !== currentValue) {
currentValue = value;
makeChanges(value, previewMade, false).then((out) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

What if inputChanged gets called before the previous makeChanges completes?

if (typeof out === 'boolean') {
previewMade = out;
}
});
}
return '';
}

abbreviationPromise = (args && args['abbreviation']) ? Promise.resolve(args['abbreviation']) : vscode.window.showInputBox({ prompt: 'Enter Abbreviation', validateInput: inputChanged });
const helper = getEmmetHelper();

return abbreviationPromise.then(inputAbbreviation => {
if (!inputAbbreviation || !inputAbbreviation.trim() || !helper.isAbbreviationValid(syntax, inputAbbreviation)) { return false; }
function makeChanges(inputAbbreviation: string | undefined, previewMade?: boolean, definitive?: boolean): Thenable<any> {
if (!inputAbbreviation || !inputAbbreviation.trim() || !helper.isAbbreviationValid(syntax, inputAbbreviation)) {
let returnPromise: Thenable<any> = Promise.resolve();
Copy link
Contributor

Choose a reason for hiding this comment

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

You can condense these 5 lines to return previewMade ? revertPreview(editor, rangesToReplace) : Promise.resolve();

if (previewMade) {
returnPromise = revertPreview(editor, rangesToReplace).then(() => { return false; });
}
return returnPromise;
}

let extractedResults = helper.extractAbbreviationFromText(inputAbbreviation);
Copy link
Contributor

Choose a reason for hiding this comment

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

This might be an exisitng bug... but shouldnt we check for the validity of the abbreviation after it is extracted instead of before?
Also, if extractedResults is empty then shouldnt we revert the previous preview?

I would suggest to move the extraction to be the first thing that we do in makeChanges(). Then,

if (!extractedResults || !helper.isAbbreviationValid(syntax, extractedResults.abbreviation)) {
return previewMade ? revertPreview(editor, rangesToReplace) : Promise.resolve();
}

if (!extractedResults) {
return false;
return Promise.resolve(previewMade);
}
let { abbreviation, filter } = extractedResults;

let expandAbbrList: ExpandAbbreviationInput[] = [];
expandAbbrList = [];

editor.selections.forEach(selection => {
let rangeToReplace: vscode.Range = selection.isReversed ? new vscode.Range(selection.active, selection.anchor) : selection;
if (rangeToReplace.isEmpty) {
let { active } = selection;
let currentNode = getNode(rootNode, active, true);
if (currentNode && (currentNode.start.line === active.line || currentNode.end.line === active.line)) {
rangeToReplace = new vscode.Range(currentNode.start, currentNode.end);
} else {
rangeToReplace = new vscode.Range(rangeToReplace.start.line, 0, rangeToReplace.start.line, editor.document.lineAt(rangeToReplace.start.line).text.length);
}
// we need to apply the previewchanges and get the new ranges
let revertPromise: Thenable<any> = Promise.resolve();
if (definitive) {
if (previewMade) {
revertPromise = revertPreview(editor, rangesToReplace);
}
rangesToReplace.forEach(rangesAndContent => {
Copy link
Contributor

Choose a reason for hiding this comment

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

The currentRange of each rangesToReplace array gets updated inside revertPreview. Since the originalRange doesnt change, it is safe to loop through and use it here before revertPromise is completed. But for the sake of readability, it is better to do this after `revertPromise is completed.

let rangeToReplace = rangesAndContent.originalRange;
let textToWrap = rangeToReplace.isSingleLine ? ['$TM_SELECTED_TEXT'] : ['\n\t$TM_SELECTED_TEXT\n'];
expandAbbrList.push({ syntax: syntax || '', abbreviation, rangeToReplace, textToWrap, filter });
Copy link
Contributor

Choose a reason for hiding this comment

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

why would syntax be null/undefined here?

});
return revertPromise.then(() => {
return expandAbbreviationInRange(editor, expandAbbrList, true).then(() => { return Promise.resolve(); });
});
} else {
rangesToReplace.forEach(rangesAndContent => {
let match = rangesAndContent.content.match(/\n[\s]*/g);
let textToWrap = match ? ['\n\t' + rangesAndContent.content.split(match[match.length - 1]).join('\n\t') + '\n'] : [rangesAndContent.content];
expandAbbrList.push({ syntax: syntax || '', abbreviation, rangeToReplace: rangesAndContent.originalRange, textToWrap, filter });
});
}

const firstLineOfSelection = editor.document.lineAt(rangeToReplace.start).text.substr(rangeToReplace.start.character);
const matches = firstLineOfSelection.match(/^(\s*)/);
const preceedingWhiteSpace = matches ? matches[1].length : 0;

rangeToReplace = new vscode.Range(rangeToReplace.start.line, rangeToReplace.start.character + preceedingWhiteSpace, rangeToReplace.end.line, rangeToReplace.end.character);
let textToWrap = rangeToReplace.isSingleLine ? ['$TM_SELECTED_TEXT'] : ['\n\t$TM_SELECTED_TEXT\n'];
expandAbbrList.push({ syntax, abbreviation, rangeToReplace, textToWrap, filter });
return Promise.resolve().then(() => {
return applyPreview(editor, expandAbbrList, rangesToReplace).then(ranges => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why return the ranges here from applyPreview and the update rangesToReplace?
rangesToReplace is accessible from inside applyPreview and can be updated from there as well.
Just like you did in revertPreview

for (let i = 0; i < ranges.length; i++) {
rangesToReplace[i].currentRange = ranges[i];
}
previewMade = true;
return previewMade;
});
});

return expandAbbreviationInRange(editor, expandAbbrList, true);
}

// On inputBox closing
return abbreviationPromise.then(inputAbbreviation => {
Copy link
Contributor

Choose a reason for hiding this comment

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

The tests are currently failing as the promise returned by makeChanges is not being returned by wrapWithAbbreviation

Since makeChanges does a revertPreview when given abbreivation is null/undefined/invalid, can we not simplify this to return abbreviationPromise.then(inputAbbreviation => makeChanges(inputAbbreviation, previewMade, true)); ?

if (typeof inputAbbreviation === 'string') {
// If succeeded (user pressed enter), remove the previews and add the final snippet
makeChanges(inputAbbreviation, previewMade, true);
} else {
// If not (user pressed esc or changed focus), remove the previews.
Promise.resolve().then(() => {
let revertPromise: Thenable<any> = Promise.resolve();
if (previewMade) {
revertPromise = revertPreview(editor, rangesToReplace);
}
return revertPromise;
});
}
});
}

function revertPreview(editor: vscode.TextEditor, rangesToReplace: RangesAndContent[]): Thenable<any> {
return editor.edit(builder => {
for (let i = 0; i < rangesToReplace.length; i++) {
builder.replace(rangesToReplace[i].currentRange, rangesToReplace[i].content);
rangesToReplace[i].currentRange = rangesToReplace[i].originalRange;
}
}, { undoStopBefore: false, undoStopAfter: false });
}

function applyPreview(editor: vscode.TextEditor, expandAbbrList: ExpandAbbreviationInput[], rangesToReplace: RangesAndContent[]): Thenable<vscode.Range[]> {
const anyExpandAbbrInput = expandAbbrList[0];
let expandedText = expandAbbr(anyExpandAbbrInput);
let rangesObtained: vscode.Range[] = [];
let linesInserted = 0;

if (expandedText) {
return editor.edit(builder => {
for (let i = 0; i < rangesToReplace.length; i++) {
if (i) {
expandedText = expandAbbr(expandAbbrList[i]);
}
let thisRange = rangesToReplace[i].currentRange;
let indentPrefix = '';
let preceedingText = editor.document.getText(new vscode.Range(new vscode.Position(thisRange.start.line, 0), thisRange.start));
// If there is only whitespace before the text to wrap, take that as prefix. If not, take as much whitespace as there is before text appears.
if (!preceedingText.match(/[^\s]/)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of this if/else, why not follow the same logic as preceedingWhiteSpace in https://github.com/Microsoft/vscode/blob/master/extensions/emmet/src/abbreviationActions.ts#L66-L68?

indentPrefix = preceedingText;
} else {
indentPrefix = (preceedingText.match(/(^[\s]*)/) || ['', ''])[1];
}

let newText = expandedText || '';
Copy link
Contributor

Choose a reason for hiding this comment

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

Why would we continue if expandedText is null/undefined/empty?

newText = newText.replace(/\n/g, '\n' + indentPrefix).replace(/\$\{[\d]*\}/g, '|');
Copy link
Contributor

Choose a reason for hiding this comment

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

This will not replace placeholders. Try wrapping with !

let newTextLines = (newText.match(/\n/g) || []).length + 1;
let currentTextLines = thisRange.end.line - thisRange.start.line + 1;
builder.replace(thisRange, newText);
rangesObtained.push(new vscode.Range(thisRange.start.line + linesInserted, thisRange.start.character, thisRange.start.line + linesInserted + newTextLines - 1, 1000)); // TODO: fix 1000! Count characters in the resulting text?
linesInserted += newTextLines - currentTextLines;
}
}, { undoStopBefore: false, undoStopAfter: false }).then(() => {
return rangesObtained;
});
}
return Promise.resolve([]);
}

export function wrapIndividualLinesWithAbbreviation(args: any) {
if (!validate(false) || !vscode.window.activeTextEditor) {
return;
Expand Down
25 changes: 0 additions & 25 deletions extensions/emmet/src/test/wrapWithAbbreviation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,31 +197,6 @@ suite('Tests for Wrap with Abbreviations', () => {
});
});

test('Wrap with multiline abbreviation doesnt add extra spaces', () => {
// Issue #29898
const contents = `
hello
`;
const expectedContents = `
<ul>
<li><a href="">hello</a></li>
</ul>
`;

return withRandomFileEditor(contents, 'html', (editor, doc) => {
editor.selections = [new Selection(1, 2, 1, 2)];
const promise = wrapWithAbbreviation({ abbreviation: 'ul>li>a' });
if (!promise) {
assert.equal(1, 2, 'Wrap returned undefined instead of promise.');
return Promise.resolve();
}
return promise.then(() => {
assert.equal(editor.document.getText(), expectedContents);
return Promise.resolve();
});
});
});

test('Wrap individual lines with abbreviation', () => {
const contents = `
<ul class="nav main">
Expand Down
6 changes: 1 addition & 5 deletions extensions/emmet/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2058,16 +2058,12 @@ vscode-emmet-helper@^1.2.0:
dependencies:
"@emmetio/extract-abbreviation" "0.1.6"
jsonc-parser "^1.0.0"
vscode-languageserver-types "^3.6.0-next.1"
vscode-languageserver-types "^3.5.0"

vscode-languageserver-types@^3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.5.0.tgz#e48d79962f0b8e02de955e3f524908e2b19c0374"

vscode-languageserver-types@^3.6.0-next.1:
version "3.6.0-next.1"
resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.6.0-next.1.tgz#98e488d3f87b666b4ee1a3d89f0023e246d358f3"

vscode-nls@3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-3.2.1.tgz#b1f3e04e8a94a715d5a7bcbc8339c51e6d74ca51"
Expand Down