Skip to content

Commit 42c178a

Browse files
rayw000Linkgoron
authored andcommitted
readline: undo previous edit when get key code 0x1F
1. Undo previous edit on keystroke `ctrl -` (emit 0x1F) 2. unittests 3. documentation PR-URL: nodejs#41392 Fixes: nodejs#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>
1 parent 856628c commit 42c178a

File tree

3 files changed

+94
-0
lines changed

3 files changed

+94
-0
lines changed

doc/api/readline.md

+6
Original file line numberDiff line numberDiff line change
@@ -1348,6 +1348,12 @@ const { createInterface } = require('readline');
13481348
<td>Previous history item</td>
13491349
<td></td>
13501350
</tr>
1351+
<tr>
1352+
<td><kbd>Ctrl</kbd>+<kbd>-</kbd></td>
1353+
<td>Undo previous change</td>
1354+
<td>Any keystroke emits key code <code>0x1F</code> would do this action.</td>
1355+
<td></td>
1356+
</tr>
13511357
<tr>
13521358
<td><kbd>Ctrl</kbd>+<kbd>Z</kbd></td>
13531359
<td>Moves running process into background. Type

lib/internal/readline/interface.js

+66
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ const {
77
ArrayPrototypeJoin,
88
ArrayPrototypeMap,
99
ArrayPrototypePop,
10+
ArrayPrototypePush,
1011
ArrayPrototypeReverse,
1112
ArrayPrototypeSplice,
13+
ArrayPrototypeShift,
1214
ArrayPrototypeUnshift,
1315
DateNow,
1416
FunctionPrototypeCall,
@@ -68,6 +70,7 @@ const { StringDecoder } = require('string_decoder');
6870
let Readable;
6971

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

8184
const kAddHistory = Symbol('_addHistory');
85+
const kBeforeEdit = Symbol('_beforeEdit');
8286
const kDecoder = Symbol('_decoder');
8387
const kDeleteLeft = Symbol('_deleteLeft');
8488
const kDeleteLineLeft = Symbol('_deleteLineLeft');
@@ -98,14 +102,19 @@ const kOldPrompt = Symbol('_oldPrompt');
98102
const kOnLine = Symbol('_onLine');
99103
const kPreviousKey = Symbol('_previousKey');
100104
const kPrompt = Symbol('_prompt');
105+
const kPushToUndoStack = Symbol('_pushToUndoStack');
101106
const kQuestionCallback = Symbol('_questionCallback');
107+
const kRedo = Symbol('_redo');
108+
const kRedoStack = Symbol('_redoStack');
102109
const kRefreshLine = Symbol('_refreshLine');
103110
const kSawKeyPress = Symbol('_sawKeyPress');
104111
const kSawReturnAt = Symbol('_sawReturnAt');
105112
const kSetRawMode = Symbol('_setRawMode');
106113
const kTabComplete = Symbol('_tabComplete');
107114
const kTabCompleter = Symbol('_tabCompleter');
108115
const kTtyWrite = Symbol('_ttyWrite');
116+
const kUndo = Symbol('_undo');
117+
const kUndoStack = Symbol('_undoStack');
109118
const kWordLeft = Symbol('_wordLeft');
110119
const kWordRight = Symbol('_wordRight');
111120
const kWriteToOutput = Symbol('_writeToOutput');
@@ -198,6 +207,8 @@ function InterfaceConstructor(input, output, completer, terminal) {
198207
this[kSubstringSearch] = null;
199208
this.output = output;
200209
this.input = input;
210+
this[kUndoStack] = [];
211+
this[kRedoStack] = [];
201212
this.history = history;
202213
this.historySize = historySize;
203214
this.removeHistoryDuplicates = !!removeHistoryDuplicates;
@@ -390,6 +401,10 @@ class Interface extends InterfaceConstructor {
390401
}
391402
}
392403

404+
[kBeforeEdit](oldText, oldCursor) {
405+
this[kPushToUndoStack](oldText, oldCursor);
406+
}
407+
393408
[kQuestionCancel]() {
394409
if (this[kQuestionCallback]) {
395410
this[kQuestionCallback] = null;
@@ -579,6 +594,7 @@ class Interface extends InterfaceConstructor {
579594
}
580595

581596
[kInsertString](c) {
597+
this[kBeforeEdit](this.line, this.cursor);
582598
if (this.cursor < this.line.length) {
583599
const beg = StringPrototypeSlice(this.line, 0, this.cursor);
584600
const end = StringPrototypeSlice(
@@ -648,6 +664,8 @@ class Interface extends InterfaceConstructor {
648664
return;
649665
}
650666

667+
this[kBeforeEdit](this.line, this.cursor);
668+
651669
// Apply/show completions.
652670
const completionsWidth = ArrayPrototypeMap(completions, (e) =>
653671
getStringWidth(e)
@@ -708,6 +726,7 @@ class Interface extends InterfaceConstructor {
708726

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

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

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

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

771793
[kDeleteLineLeft]() {
794+
this[kBeforeEdit](this.line, this.cursor);
772795
this.line = StringPrototypeSlice(this.line, this.cursor);
773796
this.cursor = 0;
774797
this[kRefreshLine]();
775798
}
776799

777800
[kDeleteLineRight]() {
801+
this[kBeforeEdit](this.line, this.cursor);
778802
this.line = StringPrototypeSlice(this.line, 0, this.cursor);
779803
this[kRefreshLine]();
780804
}
@@ -789,10 +813,43 @@ class Interface extends InterfaceConstructor {
789813

790814
[kLine]() {
791815
const line = this[kAddHistory]();
816+
this[kUndoStack] = [];
817+
this[kRedoStack] = [];
792818
this.clearLine();
793819
this[kOnLine](line);
794820
}
795821

822+
[kPushToUndoStack](text, cursor) {
823+
if (ArrayPrototypePush(this[kUndoStack], { text, cursor }) >
824+
kMaxUndoRedoStackSize) {
825+
ArrayPrototypeShift(this[kUndoStack]);
826+
}
827+
}
828+
829+
[kUndo]() {
830+
if (this[kUndoStack].length <= 0) return;
831+
832+
const entry = this[kUndoStack].pop();
833+
834+
this.line = entry.text;
835+
this.cursor = entry.cursor;
836+
837+
ArrayPrototypePush(this[kRedoStack], entry);
838+
this[kRefreshLine]();
839+
}
840+
841+
[kRedo]() {
842+
if (this[kRedoStack].length <= 0) return;
843+
844+
const entry = this[kRedoStack].pop();
845+
846+
this.line = entry.text;
847+
this.cursor = entry.cursor;
848+
849+
ArrayPrototypePush(this[kUndoStack], entry);
850+
this[kRefreshLine]();
851+
}
852+
796853
// TODO(BridgeAR): Add underscores to the search part and a red background in
797854
// case no match is found. This should only be the visual part and not the
798855
// actual line content!
@@ -802,6 +859,7 @@ class Interface extends InterfaceConstructor {
802859
// one.
803860
[kHistoryNext]() {
804861
if (this.historyIndex >= 0) {
862+
this[kBeforeEdit](this.line, this.cursor);
805863
const search = this[kSubstringSearch] || '';
806864
let index = this.historyIndex - 1;
807865
while (
@@ -824,6 +882,7 @@ class Interface extends InterfaceConstructor {
824882

825883
[kHistoryPrev]() {
826884
if (this.historyIndex < this.history.length && this.history.length) {
885+
this[kBeforeEdit](this.line, this.cursor);
827886
const search = this[kSubstringSearch] || '';
828887
let index = this.historyIndex + 1;
829888
while (
@@ -947,6 +1006,13 @@ class Interface extends InterfaceConstructor {
9471006
}
9481007
}
9491008

1009+
// Undo
1010+
if (typeof key.sequence === 'string' &&
1011+
StringPrototypeCodePointAt(key.sequence, 0) === 0x1f) {
1012+
this[kUndo]();
1013+
return;
1014+
}
1015+
9501016
// Ignore escape key, fixes
9511017
// https://github.com/nodejs/node-v0.x-archive/issues/2876.
9521018
if (key.name === 'escape') return;

test/parallel/test-readline-interface.js

+22
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,28 @@ function assertCursorRowsAndCols(rli, rows, cols) {
721721
rli.close();
722722
}
723723

724+
// Undo
725+
{
726+
const [rli, fi] = getInterface({ terminal: true, prompt: '' });
727+
fi.emit('data', 'the quick brown fox');
728+
assertCursorRowsAndCols(rli, 0, 19);
729+
730+
// Delete right line from the 5th char
731+
fi.emit('keypress', '.', { ctrl: true, shift: false, name: 'b' });
732+
fi.emit('keypress', '.', { ctrl: true, shift: false, name: 'b' });
733+
fi.emit('keypress', '.', { ctrl: true, shift: false, name: 'b' });
734+
fi.emit('keypress', '.', { ctrl: true, shift: false, name: 'b' });
735+
fi.emit('keypress', ',', { ctrl: true, shift: false, name: 'k' });
736+
fi.emit('keypress', ',', { ctrl: true, shift: false, name: 'u' });
737+
assertCursorRowsAndCols(rli, 0, 0);
738+
fi.emit('keypress', ',', { sequence: '\x1F' });
739+
assert.strictEqual(rli.line, 'the quick brown');
740+
fi.emit('keypress', ',', { sequence: '\x1F' });
741+
assert.strictEqual(rli.line, 'the quick brown fox');
742+
fi.emit('data', '\n');
743+
rli.close();
744+
}
745+
724746
// Clear the whole screen
725747
{
726748
const [rli, fi] = getInterface({ terminal: true, prompt: '' });

0 commit comments

Comments
 (0)