diff --git a/doc/api/readline.md b/doc/api/readline.md index 4c64678fb467b2..7e7b06b756bcb2 100644 --- a/doc/api/readline.md +++ b/doc/api/readline.md @@ -1313,6 +1313,16 @@ const { createInterface } = require('readline'); Delete from the current position to the end of line + + Ctrl+Y + Yank (Recall) the previously deleted text + Only works with text deleted by Ctrl+U or Ctrl+K + + + Meta+Y + Cycle among previously deleted lines + Only available when the last keystroke is Ctrl+Y + Ctrl+A Go to start of line diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js index 4ef4fe2ffa6846..5c9cb94ced5817 100644 --- a/lib/internal/readline/interface.js +++ b/lib/internal/readline/interface.js @@ -81,6 +81,9 @@ const kQuestionCancel = Symbol('kQuestionCancel'); // GNU readline library - keyseq-timeout is 500ms (default) const ESCAPE_CODE_TIMEOUT = 500; +// Max length of the kill ring +const kMaxLengthOfKillRing = 32; + const kAddHistory = Symbol('_addHistory'); const kBeforeEdit = Symbol('_beforeEdit'); const kDecoder = Symbol('_decoder'); @@ -96,12 +99,15 @@ const kHistoryPrev = Symbol('_historyPrev'); const kInsertString = Symbol('_insertString'); const kLine = Symbol('_line'); const kLine_buffer = Symbol('_line_buffer'); +const kKillRing = Symbol('_killRing'); +const kKillRingCursor = Symbol('_killRingCursor'); const kMoveCursor = Symbol('_moveCursor'); const kNormalWrite = Symbol('_normalWrite'); const kOldPrompt = Symbol('_oldPrompt'); const kOnLine = Symbol('_onLine'); const kPreviousKey = Symbol('_previousKey'); const kPrompt = Symbol('_prompt'); +const kPushToKillRing = Symbol('_pushToKillRing'); const kPushToUndoStack = Symbol('_pushToUndoStack'); const kQuestionCallback = Symbol('_questionCallback'); const kRedo = Symbol('_redo'); @@ -118,6 +124,9 @@ const kUndoStack = Symbol('_undoStack'); const kWordLeft = Symbol('_wordLeft'); const kWordRight = Symbol('_wordRight'); const kWriteToOutput = Symbol('_writeToOutput'); +const kYank = Symbol('_yank'); +const kYanking = Symbol('_yanking'); +const kYankPop = Symbol('_yankPop'); function InterfaceConstructor(input, output, completer, terminal) { this[kSawReturnAt] = 0; @@ -211,6 +220,15 @@ function InterfaceConstructor(input, output, completer, terminal) { this[kRedoStack] = []; this.history = history; this.historySize = historySize; + + // The kill ring is a global list of blocks of text that were previously + // killed (deleted). If its size exceeds kMaxLengthOfKillRing, the oldest + // element will be removed to make room for the latest deletion. With kill + // ring, users are able to recall (yank) or cycle (yank pop) among previously + // killed texts, quite similar to the behavior of Emacs. + this[kKillRing] = []; + this[kKillRingCursor] = 0; + this.removeHistoryDuplicates = !!removeHistoryDuplicates; this.crlfDelay = crlfDelay ? MathMax(kMincrlfDelay, crlfDelay) : @@ -606,10 +624,12 @@ class Interface extends InterfaceConstructor { this.cursor += c.length; this[kRefreshLine](); } else { + const oldPos = this.getCursorPos(); this.line += c; this.cursor += c.length; + const newPos = this.getCursorPos(); - if (this.getCursorPos().cols === 0) { + if (oldPos.rows < newPos.rows) { this[kRefreshLine](); } else { this[kWriteToOutput](c); @@ -792,17 +812,57 @@ class Interface extends InterfaceConstructor { [kDeleteLineLeft]() { this[kBeforeEdit](this.line, this.cursor); + const del = StringPrototypeSlice(this.line, 0, this.cursor); this.line = StringPrototypeSlice(this.line, this.cursor); this.cursor = 0; + this[kPushToKillRing](del); this[kRefreshLine](); } [kDeleteLineRight]() { this[kBeforeEdit](this.line, this.cursor); + const del = StringPrototypeSlice(this.line, this.cursor); this.line = StringPrototypeSlice(this.line, 0, this.cursor); + this[kPushToKillRing](del); this[kRefreshLine](); } + [kPushToKillRing](del) { + if (!del || del === this[kKillRing][0]) return; + ArrayPrototypeUnshift(this[kKillRing], del); + this[kKillRingCursor] = 0; + while (this[kKillRing].length > kMaxLengthOfKillRing) + ArrayPrototypePop(this[kKillRing]); + } + + [kYank]() { + if (this[kKillRing].length > 0) { + this[kYanking] = true; + this[kInsertString](this[kKillRing][this[kKillRingCursor]]); + } + } + + [kYankPop]() { + if (!this[kYanking]) { + return; + } + if (this[kKillRing].length > 1) { + const lastYank = this[kKillRing][this[kKillRingCursor]]; + this[kKillRingCursor]++; + if (this[kKillRingCursor] >= this[kKillRing].length) { + this[kKillRingCursor] = 0; + } + const currentYank = this[kKillRing][this[kKillRingCursor]]; + const head = + StringPrototypeSlice(this.line, 0, this.cursor - lastYank.length); + const tail = + StringPrototypeSlice(this.line, this.cursor); + this.line = head + currentYank + tail; + this.cursor = head.length + currentYank.length; + this[kRefreshLine](); + } + } + clearLine() { this[kMoveCursor](+Infinity); this[kWriteToOutput]('\r\n'); @@ -984,6 +1044,11 @@ class Interface extends InterfaceConstructor { key = key || {}; this[kPreviousKey] = key; + if (!key.meta || key.name !== 'y') { + // Reset yanking state unless we are doing yank pop. + this[kYanking] = false; + } + // Activate or deactivate substring search. if ( (key.name === 'up' || key.name === 'down') && @@ -1094,6 +1159,10 @@ class Interface extends InterfaceConstructor { this[kHistoryPrev](); break; + case 'y': // Yank killed string + this[kYank](); + break; + case 'z': if (process.platform === 'win32') break; if (this.listenerCount('SIGTSTP') > 0) { @@ -1158,6 +1227,10 @@ class Interface extends InterfaceConstructor { case 'backspace': // Delete backwards to a word boundary this[kDeleteWordLeft](); break; + + case 'y': // Doing yank pop + this[kYankPop](); + break; } } else { /* No modifier keys used */ diff --git a/test/parallel/test-readline-interface.js b/test/parallel/test-readline-interface.js index ff0c83efc9fef4..04bacf39309168 100644 --- a/test/parallel/test-readline-interface.js +++ b/test/parallel/test-readline-interface.js @@ -674,6 +674,77 @@ function assertCursorRowsAndCols(rli, rows, cols) { rli.close(); } +// yank +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + assertCursorRowsAndCols(rli, 0, 19); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + // Move forward one char + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + // Delete the right part + fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' }); + assertCursorRowsAndCols(rli, 0, 1); + + // Yank + fi.emit('keypress', '.', { ctrl: true, name: 'y' }); + assertCursorRowsAndCols(rli, 0, 19); + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick brown fox'); + })); + + fi.emit('data', '\n'); + rli.close(); +} + +// yank pop +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + assertCursorRowsAndCols(rli, 0, 19); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + // Move forward one char + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + // Delete the right part + fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' }); + assertCursorRowsAndCols(rli, 0, 1); + // Yank + fi.emit('keypress', '.', { ctrl: true, name: 'y' }); + assertCursorRowsAndCols(rli, 0, 19); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + // Move forward four chars + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + // Delete the right part + fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' }); + assertCursorRowsAndCols(rli, 0, 4); + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + assertCursorRowsAndCols(rli, 0, 0); + + // Yank: 'quick brown fox|the ' + fi.emit('keypress', '.', { ctrl: true, name: 'y' }); + // Yank pop: 'he quick brown fox|the' + fi.emit('keypress', '.', { meta: true, name: 'y' }); + assertCursorRowsAndCols(rli, 0, 18); + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'he quick brown foxthe '); + })); + + fi.emit('data', '\n'); + rli.close(); +} + // Close readline interface { const [rli, fi] = getInterface({ terminal: true, prompt: '' });