diff --git a/doc/api/readline.md b/doc/api/readline.md
index ab93b59eb2ee9c..4c64678fb467b2 100644
--- a/doc/api/readline.md
+++ b/doc/api/readline.md
@@ -1348,6 +1348,12 @@ const { createInterface } = require('readline');
Ctrl+Z |
Moves running process into background. Type
diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js
index e50172f5628ccc..4ef4fe2ffa6846 100644
--- a/lib/internal/readline/interface.js
+++ b/lib/internal/readline/interface.js
@@ -7,8 +7,10 @@ const {
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypePop,
+ ArrayPrototypePush,
ArrayPrototypeReverse,
ArrayPrototypeSplice,
+ ArrayPrototypeShift,
ArrayPrototypeUnshift,
DateNow,
FunctionPrototypeCall,
@@ -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)/;
@@ -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');
@@ -98,7 +102,10 @@ 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');
@@ -106,6 +113,8 @@ 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');
@@ -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;
@@ -390,6 +401,10 @@ class Interface extends InterfaceConstructor {
}
}
+ [kBeforeEdit](oldText, oldCursor) {
+ this[kPushToUndoStack](oldText, oldCursor);
+ }
+
[kQuestionCancel]() {
if (this[kQuestionCallback]) {
this[kQuestionCallback] = null;
@@ -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(
@@ -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)
@@ -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 =
@@ -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 =
@@ -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);
@@ -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 =
@@ -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]();
}
@@ -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!
@@ -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 (
@@ -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 (
@@ -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;
diff --git a/test/parallel/test-readline-interface.js b/test/parallel/test-readline-interface.js
index f253a443c05884..ff0c83efc9fef4 100644
--- a/test/parallel/test-readline-interface.js
+++ b/test/parallel/test-readline-interface.js
@@ -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: '' });
|