Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

readline: add features yank and yank pop #41301

Merged
merged 1 commit into from
Jan 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/api/readline.md
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,16 @@ const { createInterface } = require('readline');
<td>Delete from the current position to the end of line</td>
<td></td>
</tr>
<tr>
<td><kbd>Ctrl</kbd>+<kbd>Y</kbd></td>
<td>Yank (Recall) the previously deleted text</td>
<td>Only works with text deleted by <kbd>Ctrl</kbd>+<kbd>U</kbd> or <kbd>Ctrl</kbd>+<kbd>K</kbd></td>
</tr>
<tr>
<td><kbd>Meta</kbd>+<kbd>Y</kbd></td>
<td>Cycle among previously deleted lines</td>
<td>Only available when the last keystroke is <kbd>Ctrl</kbd>+<kbd>Y</kbd></td>
</tr>
<tr>
<td><kbd>Ctrl</kbd>+<kbd>A</kbd></td>
<td>Go to start of line</td>
Expand Down
75 changes: 74 additions & 1 deletion lib/internal/readline/interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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;
Expand Down Expand Up @@ -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) :
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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') &&
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 */
Expand Down
71 changes: 71 additions & 0 deletions test/parallel/test-readline-interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '' });
Expand Down