Skip to content

Commit

Permalink
The newLine command with a single edit history (#1733)
Browse files Browse the repository at this point in the history
* The `newLine` command with a single edit history

* Update the test

* Fix

* Fix the impl to deal with multi-line comments in JS

* Fix implementation

* Fix the adhoc delay value to pass the CI

* Fix

* Update tests
  • Loading branch information
whitphx authored Nov 5, 2023
1 parent d727174 commit a9d39b8
Show file tree
Hide file tree
Showing 2 changed files with 191 additions and 7 deletions.
108 changes: 103 additions & 5 deletions src/commands/edit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as vscode from "vscode";
import { Selection, TextEditor } from "vscode";
import { Range, Selection, TextEditor } from "vscode";
import { createParallel, EmacsCommand } from ".";
import { revealPrimaryActive } from "./helpers/reveal";
import { delay } from "../utils";

export class DeleteBackwardChar extends EmacsCommand {
public readonly id = "deleteBackwardChar";
Expand All @@ -25,14 +27,110 @@ export class DeleteForwardChar extends EmacsCommand {
export class NewLine extends EmacsCommand {
public readonly id = "newLine";

public execute(textEditor: TextEditor, isInMarkMode: boolean, prefixArgument: number | undefined): Thenable<void> {
public async execute(
textEditor: TextEditor,
isInMarkMode: boolean,
prefixArgument: number | undefined,
): Promise<void> {
this.emacsController.exitMarkMode();

textEditor.selections = textEditor.selections.map((selection) => new Selection(selection.active, selection.active));

const repeat = prefixArgument === undefined ? 1 : prefixArgument;
return createParallel(repeat, () =>
vscode.commands.executeCommand<void>("default:type", { text: "\n" }),
) as Thenable<unknown> as Thenable<void>;

if (repeat <= 0) {
return;
}
if (repeat === 1) {
return vscode.commands.executeCommand<void>("default:type", { text: "\n" });
}

// We don't use a combination of `createParallel` and `vscode.commands.executeCommand("default:type", { text: "\n" })`
// here because it doesn't work well with undo/redo pushing multiple edits into the undo stack.
// Instead, we use `textEditor.edit` to push a single edit into the undo stack.
// To do so, we first call the `default:type` command twice to insert two new lines
// and record the inserted texts.
// Then undo these two edits and call `textEditor.edit` to insert the repeated texts at once.

const initCursorsAtEndOfLine = textEditor.selections.map((selection) => {
return selection.active.isEqual(textEditor.document.lineAt(selection.active.line).range.end);
});

await vscode.commands.executeCommand<void>("default:type", { text: "\n" });
await delay(33); // Wait for code completion to finish. The value is ad-hoc.
await vscode.commands.executeCommand<void>("default:type", { text: "\n" });

// The first inserted lines can be affected by the second ones.
// We need to capture its final content after the second insertion to achieve the desired result.
const firstInsertedTexts = textEditor.selections.map((selection) => {
const from = textEditor.document.lineAt(selection.active.line - 2).range.end;
const to = textEditor.document.lineAt(selection.active.line - 1).range.end;
return textEditor.document.getText(new Range(from, to));
});
const secondInsertedTexts = textEditor.selections.map((selection) => {
const from = textEditor.document.lineAt(selection.active.line - 1).range.end;
const to = textEditor.document.lineAt(selection.active.line - 0).range.end;
return textEditor.document.getText(new Range(from, to));
});

// Trailing new lines can be inserted for example
// when the cursor is inside a multi-line comment in JS like below.
// /**| */
// ↓
// /**
// * |
// */
// The `trailingNewLinesInserted` flag list represents whether such trailing new lines are inserted or not.
// `trailingLineTexts` contains the texts of such trailing new lines.
const trailingNewLinesInserted = textEditor.selections.map((selection, index) => {
const initCursorAtEndOfLine = initCursorsAtEndOfLine[index];
if (initCursorAtEndOfLine == null || initCursorAtEndOfLine === true) {
return false;
}
const cursorAtEndOfLine = selection.active.isEqual(textEditor.document.lineAt(selection.active.line).range.end);
return cursorAtEndOfLine;
});
const trailingLineTexts = textEditor.selections.map((selection, index) => {
const trailingNewLineInserted = trailingNewLinesInserted[index];
if (trailingNewLineInserted == null || trailingNewLineInserted === false) {
return "";
}
const nextLineStart = textEditor.document.lineAt(selection.active.line + 1).range.start;
return textEditor.document.getText(new Range(selection.active, nextLineStart));
});

await vscode.commands.executeCommand<void>("undo");
await vscode.commands.executeCommand<void>("undo");

await textEditor.edit((editBuilder) => {
textEditor.selections.forEach((selection, index) => {
const firstInsertedLineText = firstInsertedTexts[index];
const secondInsertedLineText = secondInsertedTexts[index];
const trailingLineText = trailingLineTexts[index];
if (firstInsertedLineText == null) {
throw new Error("firstInsertedLineText is null");
}
if (secondInsertedLineText == null) {
throw new Error("secondInsertedLineText is null");
}
if (trailingLineText == null) {
throw new Error("trailingLineText is null");
}
editBuilder.insert(
selection.active,
firstInsertedLineText.repeat(repeat - 1) + secondInsertedLineText + trailingLineText,
);
});
});
textEditor.selections = textEditor.selections.map((selection, index) => {
const trailingNewLineInserted = trailingNewLinesInserted[index];
if (trailingNewLineInserted) {
const newActive = textEditor.document.lineAt(selection.active.line - 1).range.end;
return new Selection(newActive, newActive);
}
return selection;
});

revealPrimaryActive(textEditor);
}
}
90 changes: 88 additions & 2 deletions src/test/suite/commands/new-line.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ suite("newLine", () => {
];

eols.forEach(([eol, eolStr]) => {
suite(`with ${eolStr}`, () => {
suite(`with ${JSON.stringify(eolStr)}`, () => {
suite("basic behaviors", () => {
setup(async () => {
const initialText = `0123456789${eolStr}abcdefghij${eolStr}ABCDEFGHIJ`;
Expand Down Expand Up @@ -180,7 +180,7 @@ suite("newLine", () => {
});
});

test("working with prefix argument", async () => {
test("working with a prefix argument and undo/redo", async () => {
const initialText = "";
activeTextEditor = await setupWorkspace(initialText, { eol });
emulator = new EmacsEmulator(activeTextEditor);
Expand All @@ -193,6 +193,92 @@ suite("newLine", () => {
assertTextEqual(activeTextEditor, `${eolStr}${eolStr}${eolStr}${eolStr}`);
assert.strictEqual(activeTextEditor.selection.active.line, 4);
assert.strictEqual(activeTextEditor.selection.active.character, 0);

await vscode.commands.executeCommand<void>("undo");

assertTextEqual(activeTextEditor, initialText);

await vscode.commands.executeCommand<void>("redo");

assertTextEqual(activeTextEditor, `${eolStr}${eolStr}${eolStr}${eolStr}`);
});

suite("with auto-indentation with a prefix argument", () => {
test("newLine preserves the indent", async () => {
const initialText = "()";
activeTextEditor = await setupWorkspace(initialText, { eol });
activeTextEditor.options.tabSize = 4;
emulator = new EmacsEmulator(activeTextEditor);

setEmptyCursors(activeTextEditor, [0, 1]);

await emulator.universalArgument();
await emulator.runCommand("newLine");

assertTextEqual(activeTextEditor, `(${eolStr}${eolStr}${eolStr}${eolStr} ${eolStr})`);
assert.strictEqual(activeTextEditor.selection.active.line, 4);
assert.strictEqual(activeTextEditor.selection.active.character, 4);

await vscode.commands.executeCommand<void>("undo");

assertTextEqual(activeTextEditor, initialText);

await vscode.commands.executeCommand<void>("redo");

assertTextEqual(activeTextEditor, `(${eolStr}${eolStr}${eolStr}${eolStr} ${eolStr})`);
});

const languagesAutoDoc = [
// "c", "cpp" // Auto-indent for doc comments does not work with these languages in test env while I don't know why...
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
];
languagesAutoDoc.forEach((language) => {
test(`newLine does not disable the language specific control in case of ${language}`, async function () {
const initialText = "/** */";

// XXX: First, trigger the language's auto-indent feature without any assertion before the main test execution.
// This is necessary for the test to be successful at VSCode 1.50.
// It may be because the first execution warms up the language server.
// TODO: Remove this workaround with later versions of VSCode
activeTextEditor = await setupWorkspace(initialText, {
eol,
language,
});
emulator = new EmacsEmulator(activeTextEditor);

setEmptyCursors(activeTextEditor, [0, 3]);

await vscode.commands.executeCommand("default:type", { text: "\n" });
await clearTextEditor(activeTextEditor);
// XXX: (end of the workaround)

activeTextEditor = await setupWorkspace(initialText, {
eol,
language,
});
emulator = new EmacsEmulator(activeTextEditor);

setEmptyCursors(activeTextEditor, [0, 3]);

await emulator.universalArgument();
await emulator.runCommand("newLine");

assertTextEqual(activeTextEditor, `/**${eolStr} * ${eolStr} * ${eolStr} * ${eolStr} * ${eolStr} */`);
assert.strictEqual(activeTextEditor.selection.active.line, 4);
assert.strictEqual(activeTextEditor.selection.active.character, 3);

await vscode.commands.executeCommand<void>("undo");

assertTextEqual(activeTextEditor, initialText);

await vscode.commands.executeCommand<void>("redo");

assertTextEqual(activeTextEditor, `/**${eolStr} * ${eolStr} * ${eolStr} * ${eolStr} * ${eolStr} */`);
});
});
});
});
});
Expand Down

0 comments on commit a9d39b8

Please sign in to comment.