From 5a4c74c647a7e53d936ef98a039f8cc580eb790c Mon Sep 17 00:00:00 2001 From: Ray Date: Fri, 21 Jan 2022 18:24:06 +0800 Subject: [PATCH] readline: undo previous edit when get key code 0x1F 1. Undo previous edit on keystroke `ctrl -` (emit 0x1F) 2. unittests 3. documentation PR-URL: https://github.com/nodejs/node/pull/41392 Fixes: https://github.com/nodejs/node/issues/41308 Reviewed-By: James M Snell Reviewed-By: Qingyu Deng Reviewed-By: Antoine du Hamel --- doc/api/readline.md | 6 +++ lib/internal/readline/interface.js | 66 ++++++++++++++++++++++++ test/parallel/test-readline-interface.js | 22 ++++++++ 3 files changed, 94 insertions(+) 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'); Previous history item + + Ctrl+- + Undo previous change + Any keystroke emits key code 0x1F would do this action. + + 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: '' });