Skip to content

Commit

Permalink
readline: undo previous edit when get key code 0x1F
Browse files Browse the repository at this point in the history
1. Undo previous edit on keystroke `ctrl -` (emit 0x1F)
2. unittests
3. documentation

PR-URL: #41392
Fixes: #41308
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Qingyu Deng <i@ayase-lab.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
  • Loading branch information
rayw000 authored and BethGriggs committed Jan 24, 2022
1 parent 34684a1 commit 5a4c74c
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 0 deletions.
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]() {
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

0 comments on commit 5a4c74c

Please sign in to comment.