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

readline: undo previous edit when get key code 0x1F #41392

Merged
merged 2 commits into from
Jan 21, 2022
Merged
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
6 changes: 6 additions & 0 deletions doc/api/readline.md
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,12 @@ const { createInterface } = require('readline');
<td>Previous history item</td>
<td></td>
</tr>
<tr>
<td><kbd>Ctrl</kbd>+<kbd>-</kbd></td>
<td>Undo previous change</td>
<td>Any keystroke emits key code <code>0x1F</code> would do this action.</td>
<td></td>
</tr>
<tr>
<td><kbd>Ctrl</kbd>+<kbd>Z</kbd></td>
<td>Moves running process into background. Type
Expand Down
66 changes: 66 additions & 0 deletions lib/internal/readline/interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ const {
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypePop,
ArrayPrototypePush,
ArrayPrototypeReverse,
ArrayPrototypeSplice,
ArrayPrototypeShift,
ArrayPrototypeUnshift,
DateNow,
FunctionPrototypeCall,
Expand Down Expand Up @@ -68,6 +70,7 @@ const { StringDecoder } = require('string_decoder');
let Readable;

const kHistorySize = 30;
const kMaxUndoRedoStackSize = 2048;
const kMincrlfDelay = 100;
// \r\n, \n, or \r followed by something other than \n
const lineEnding = /\r?\n|\r(?!\n)/;
Expand All @@ -79,6 +82,7 @@ const kQuestionCancel = Symbol('kQuestionCancel');
const ESCAPE_CODE_TIMEOUT = 500;

const kAddHistory = Symbol('_addHistory');
const kBeforeEdit = Symbol('_beforeEdit');
const kDecoder = Symbol('_decoder');
const kDeleteLeft = Symbol('_deleteLeft');
const kDeleteLineLeft = Symbol('_deleteLineLeft');
Expand All @@ -98,14 +102,19 @@ const kOldPrompt = Symbol('_oldPrompt');
const kOnLine = Symbol('_onLine');
const kPreviousKey = Symbol('_previousKey');
const kPrompt = Symbol('_prompt');
const kPushToUndoStack = Symbol('_pushToUndoStack');
const kQuestionCallback = Symbol('_questionCallback');
const kRedo = Symbol('_redo');
const kRedoStack = Symbol('_redoStack');
const kRefreshLine = Symbol('_refreshLine');
const kSawKeyPress = Symbol('_sawKeyPress');
const kSawReturnAt = Symbol('_sawReturnAt');
const kSetRawMode = Symbol('_setRawMode');
const kTabComplete = Symbol('_tabComplete');
const kTabCompleter = Symbol('_tabCompleter');
const kTtyWrite = Symbol('_ttyWrite');
const kUndo = Symbol('_undo');
const kUndoStack = Symbol('_undoStack');
const kWordLeft = Symbol('_wordLeft');
const kWordRight = Symbol('_wordRight');
const kWriteToOutput = Symbol('_writeToOutput');
Expand Down Expand Up @@ -198,6 +207,8 @@ function InterfaceConstructor(input, output, completer, terminal) {
this[kSubstringSearch] = null;
this.output = output;
this.input = input;
this[kUndoStack] = [];
this[kRedoStack] = [];
this.history = history;
this.historySize = historySize;
this.removeHistoryDuplicates = !!removeHistoryDuplicates;
Expand Down Expand Up @@ -390,6 +401,10 @@ class Interface extends InterfaceConstructor {
}
}

[kBeforeEdit](oldText, oldCursor) {
this[kPushToUndoStack](oldText, oldCursor);
}

[kQuestionCancel]() {
if (this[kQuestionCallback]) {
this[kQuestionCallback] = null;
Expand Down Expand Up @@ -579,6 +594,7 @@ class Interface extends InterfaceConstructor {
}

[kInsertString](c) {
this[kBeforeEdit](this.line, this.cursor);
if (this.cursor < this.line.length) {
const beg = StringPrototypeSlice(this.line, 0, this.cursor);
const end = StringPrototypeSlice(
Expand Down Expand Up @@ -648,6 +664,8 @@ class Interface extends InterfaceConstructor {
return;
}

this[kBeforeEdit](this.line, this.cursor);

// Apply/show completions.
const completionsWidth = ArrayPrototypeMap(completions, (e) =>
getStringWidth(e)
Expand Down Expand Up @@ -708,6 +726,7 @@ class Interface extends InterfaceConstructor {

[kDeleteLeft]() {
if (this.cursor > 0 && this.line.length > 0) {
this[kBeforeEdit](this.line, this.cursor);
// The number of UTF-16 units comprising the character to the left
const charSize = charLengthLeft(this.line, this.cursor);
this.line =
Expand All @@ -721,6 +740,7 @@ class Interface extends InterfaceConstructor {

[kDeleteRight]() {
if (this.cursor < this.line.length) {
this[kBeforeEdit](this.line, this.cursor);
// The number of UTF-16 units comprising the character to the left
const charSize = charLengthAt(this.line, this.cursor);
this.line =
Expand All @@ -736,6 +756,7 @@ class Interface extends InterfaceConstructor {

[kDeleteWordLeft]() {
if (this.cursor > 0) {
this[kBeforeEdit](this.line, this.cursor);
// Reverse the string and match a word near beginning
// to avoid quadratic time complexity
let leading = StringPrototypeSlice(this.line, 0, this.cursor);
Expand All @@ -759,6 +780,7 @@ class Interface extends InterfaceConstructor {

[kDeleteWordRight]() {
if (this.cursor < this.line.length) {
this[kBeforeEdit](this.line, this.cursor);
const trailing = StringPrototypeSlice(this.line, this.cursor);
const match = StringPrototypeMatch(trailing, /^(?:\s+|\W+|\w+)\s*/);
this.line =
Expand All @@ -769,12 +791,14 @@ class Interface extends InterfaceConstructor {
}

[kDeleteLineLeft]() {
this[kBeforeEdit](this.line, this.cursor);
this.line = StringPrototypeSlice(this.line, this.cursor);
this.cursor = 0;
this[kRefreshLine]();
}

[kDeleteLineRight]() {
this[kBeforeEdit](this.line, this.cursor);
this.line = StringPrototypeSlice(this.line, 0, this.cursor);
this[kRefreshLine]();
}
Expand All @@ -789,10 +813,43 @@ class Interface extends InterfaceConstructor {

[kLine]() {
const line = this[kAddHistory]();
this[kUndoStack] = [];
this[kRedoStack] = [];
this.clearLine();
this[kOnLine](line);
}

[kPushToUndoStack](text, cursor) {
if (ArrayPrototypePush(this[kUndoStack], { text, cursor }) >
kMaxUndoRedoStackSize) {
ArrayPrototypeShift(this[kUndoStack]);
}
}

[kUndo]() {
if (this[kUndoStack].length <= 0) return;

const entry = this[kUndoStack].pop();

this.line = entry.text;
this.cursor = entry.cursor;

ArrayPrototypePush(this[kRedoStack], entry);
this[kRefreshLine]();
}

[kRedo]() {
rayw000 marked this conversation as resolved.
Show resolved Hide resolved
if (this[kRedoStack].length <= 0) return;

const entry = this[kRedoStack].pop();

this.line = entry.text;
this.cursor = entry.cursor;

ArrayPrototypePush(this[kUndoStack], entry);
this[kRefreshLine]();
}

// TODO(BridgeAR): Add underscores to the search part and a red background in
// case no match is found. This should only be the visual part and not the
// actual line content!
Expand All @@ -802,6 +859,7 @@ class Interface extends InterfaceConstructor {
// one.
[kHistoryNext]() {
if (this.historyIndex >= 0) {
this[kBeforeEdit](this.line, this.cursor);
const search = this[kSubstringSearch] || '';
let index = this.historyIndex - 1;
while (
Expand All @@ -824,6 +882,7 @@ class Interface extends InterfaceConstructor {

[kHistoryPrev]() {
if (this.historyIndex < this.history.length && this.history.length) {
this[kBeforeEdit](this.line, this.cursor);
const search = this[kSubstringSearch] || '';
let index = this.historyIndex + 1;
while (
Expand Down Expand Up @@ -947,6 +1006,13 @@ class Interface extends InterfaceConstructor {
}
}

// Undo
if (typeof key.sequence === 'string' &&
StringPrototypeCodePointAt(key.sequence, 0) === 0x1f) {
this[kUndo]();
return;
}

// Ignore escape key, fixes
// https://github.com/nodejs/node-v0.x-archive/issues/2876.
if (key.name === 'escape') return;
Expand Down
22 changes: 22 additions & 0 deletions test/parallel/test-readline-interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,28 @@ function assertCursorRowsAndCols(rli, rows, cols) {
rli.close();
}

// Undo
{
const [rli, fi] = getInterface({ terminal: true, prompt: '' });
fi.emit('data', 'the quick brown fox');
assertCursorRowsAndCols(rli, 0, 19);

// Delete right line from the 5th char
fi.emit('keypress', '.', { ctrl: true, shift: false, name: 'b' });
fi.emit('keypress', '.', { ctrl: true, shift: false, name: 'b' });
fi.emit('keypress', '.', { ctrl: true, shift: false, name: 'b' });
fi.emit('keypress', '.', { ctrl: true, shift: false, name: 'b' });
fi.emit('keypress', ',', { ctrl: true, shift: false, name: 'k' });
fi.emit('keypress', ',', { ctrl: true, shift: false, name: 'u' });
assertCursorRowsAndCols(rli, 0, 0);
fi.emit('keypress', ',', { sequence: '\x1F' });
assert.strictEqual(rli.line, 'the quick brown');
fi.emit('keypress', ',', { sequence: '\x1F' });
assert.strictEqual(rli.line, 'the quick brown fox');
fi.emit('data', '\n');
rli.close();
}

// Clear the whole screen
{
const [rli, fi] = getInterface({ terminal: true, prompt: '' });
Expand Down