repl: add possibility to edit multiline commands while adding them

PR-URL: https://github.com/nodejs/node/pull/58003
Reviewed-By: Ruben Bridgewater <ruben@bridgewater.de>
Reviewed-By: Pietro Marchini <pietro.marchini94@gmail.com>
This commit is contained in:
Giovanni 2025-04-23 15:05:56 +02:00 committed by Node.js GitHub Bot
parent 5fb879c458
commit 96be7836d7
7 changed files with 507 additions and 54 deletions

View file

@ -64,6 +64,7 @@ const {
charLengthLeft,
commonPrefix,
kSubstringSearch,
reverseString,
} = require('internal/readline/utils');
let emitKeypressEvents;
let kFirstEventParam;
@ -98,9 +99,7 @@ const ESCAPE_CODE_TIMEOUT = 500;
// Max length of the kill ring
const kMaxLengthOfKillRing = 32;
// TODO(puskin94): make this configurable
const kMultilinePrompt = Symbol('| ');
const kLastCommandErrored = Symbol('_lastCommandErrored');
const kAddHistory = Symbol('_addHistory');
const kBeforeEdit = Symbol('_beforeEdit');
@ -131,6 +130,7 @@ const kPrompt = Symbol('_prompt');
const kPushToKillRing = Symbol('_pushToKillRing');
const kPushToUndoStack = Symbol('_pushToUndoStack');
const kQuestionCallback = Symbol('_questionCallback');
const kLastCommandErrored = Symbol('_lastCommandErrored');
const kQuestionReject = Symbol('_questionReject');
const kRedo = Symbol('_redo');
const kRedoStack = Symbol('_redoStack');
@ -151,6 +151,12 @@ const kYank = Symbol('_yank');
const kYanking = Symbol('_yanking');
const kYankPop = Symbol('_yankPop');
const kNormalizeHistoryLineEndings = Symbol('_normalizeHistoryLineEndings');
const kSavePreviousState = Symbol('_savePreviousState');
const kRestorePreviousState = Symbol('_restorePreviousState');
const kPreviousLine = Symbol('_previousLine');
const kPreviousCursor = Symbol('_previousCursor');
const kPreviousPrevRows = Symbol('_previousPrevRows');
const kAddNewLineOnTTY = Symbol('_addNewLineOnTTY');
function InterfaceConstructor(input, output, completer, terminal) {
this[kSawReturnAt] = 0;
@ -430,7 +436,7 @@ class Interface extends InterfaceConstructor {
}
}
[kSetLine](line) {
[kSetLine](line = '') {
this.line = line;
this[kIsMultiline] = StringPrototypeIncludes(line, '\n');
}
@ -477,10 +483,7 @@ class Interface extends InterfaceConstructor {
// Reversing the multilines is necessary when adding / editing and displaying them
if (reverse) {
// First reverse the lines for proper order, then convert separators
return ArrayPrototypeJoin(
ArrayPrototypeReverse(StringPrototypeSplit(line, from)),
to,
);
return reverseString(line, from, to);
}
// For normal cases (saving to history or non-multiline entries)
return StringPrototypeReplaceAll(line, from, to);
@ -494,22 +497,28 @@ class Interface extends InterfaceConstructor {
// If the trimmed line is empty then return the line
if (StringPrototypeTrim(this.line).length === 0) return this.line;
const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r', false);
// This is necessary because each line would be saved in the history while creating
// A new multiline, and we don't want that.
if (this[kIsMultiline] && this.historyIndex === -1) {
ArrayPrototypeShift(this.history);
} else if (this[kLastCommandErrored]) {
// If the last command errored and we are trying to edit the history to fix it
// Remove the broken one from the history
ArrayPrototypeShift(this.history);
}
const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r', true);
if (this.history.length === 0 || this.history[0] !== normalizedLine) {
if (this[kLastCommandErrored] && this.historyIndex === 0) {
// If the last command errored, remove it from history.
// The user is issuing a new command starting from the errored command,
// Hopefully with the fix
ArrayPrototypeShift(this.history);
}
if (this.removeHistoryDuplicates) {
// Remove older history line if identical to new one
const dupIndex = ArrayPrototypeIndexOf(this.history, this.line);
if (dupIndex !== -1) ArrayPrototypeSplice(this.history, dupIndex, 1);
}
ArrayPrototypeUnshift(this.history, this.line);
// Add the new line to the history
ArrayPrototypeUnshift(this.history, normalizedLine);
// Only store so many
if (this.history.length > this.historySize)
@ -521,7 +530,7 @@ class Interface extends InterfaceConstructor {
// The listener could change the history object, possibly
// to remove the last added entry if it is sensitive and should
// not be persisted in the history, like a password
const line = this.history[0];
const line = this[kIsMultiline] ? reverseString(this.history[0]) : this.history[0];
// Emit history event to notify listeners of update
this.emit('history', this.history);
@ -938,6 +947,18 @@ class Interface extends InterfaceConstructor {
}
}
[kSavePreviousState]() {
this[kPreviousLine] = this.line;
this[kPreviousCursor] = this.cursor;
this[kPreviousPrevRows] = this.prevRows;
}
[kRestorePreviousState]() {
this[kSetLine](this[kPreviousLine]);
this.cursor = this[kPreviousCursor];
this.prevRows = this[kPreviousPrevRows];
}
clearLine() {
this[kMoveCursor](+Infinity);
this[kWriteToOutput]('\r\n');
@ -947,6 +968,7 @@ class Interface extends InterfaceConstructor {
}
[kLine]() {
this[kSavePreviousState]();
const line = this[kAddHistory]();
this[kUndoStack] = [];
this[kRedoStack] = [];
@ -954,6 +976,107 @@ class Interface extends InterfaceConstructor {
this[kOnLine](line);
}
// TODO(puskin94): edit [kTtyWrite] to make call this function on a new key combination
// to make it add a new line in the middle of a "complete" multiline.
// I tried with shift + enter but it is not detected. Find a new one.
// Make sure to call this[kSavePreviousState](); && this.clearLine();
// before calling this[kAddNewLineOnTTY] to simulate what [kLine] is doing.
// When this function is called, the actual cursor is at the very end of the whole string,
// No matter where the new line was entered.
// This function should only be used when the output is a TTY
[kAddNewLineOnTTY]() {
// Restore terminal state and store current line
this[kRestorePreviousState]();
const originalLine = this.line;
// Split the line at the current cursor position
const beforeCursor = StringPrototypeSlice(this.line, 0, this.cursor);
let afterCursor = StringPrototypeSlice(this.line, this.cursor, this.line.length);
// Add the new line where the cursor is at
this[kSetLine](`${beforeCursor}\n${afterCursor}`);
// To account for the new line
this.cursor += 1;
const hasContentAfterCursor = afterCursor.length > 0;
const cursorIsNotOnFirstLine = this.prevRows > 0;
let needsRewriteFirstLine = false;
// Handle cursor positioning based on different scenarios
if (hasContentAfterCursor) {
const splitBeg = StringPrototypeSplit(beforeCursor, '\n');
// Determine if we need to rewrite the first line
needsRewriteFirstLine = splitBeg.length < 2;
// If the cursor is not on the first line
if (cursorIsNotOnFirstLine) {
const splitEnd = StringPrototypeSplit(afterCursor, '\n');
// If the cursor when I pressed enter was at least on the second line
// I need to completely erase the line where the cursor was pressed because it is possible
// That it was pressed in the middle of the line, hence I need to write the whole line.
// To achieve that, I need to reach the line above the current line coming from the end
const dy = splitEnd.length + 1;
// Calculate how many Xs we need to move on the right to get to the end of the line
const dxEndOfLineAbove = (splitBeg[splitBeg.length - 2] || '').length + kMultilinePrompt.description.length;
moveCursor(this.output, dxEndOfLineAbove, -dy);
// This is the line that was split in the middle
// Just add it to the rest of the line that will be printed later
afterCursor = `${splitBeg[splitBeg.length - 1]}\n${afterCursor}`;
} else {
// Otherwise, go to the very beginning of the first line and erase everything
const dy = StringPrototypeSplit(originalLine, '\n').length;
moveCursor(this.output, 0, -dy);
}
// Erase from the cursor to the end of the line
clearScreenDown(this.output);
if (cursorIsNotOnFirstLine) {
this[kWriteToOutput]('\n');
}
}
if (needsRewriteFirstLine) {
this[kWriteToOutput](`${this[kPrompt]}${beforeCursor}\n${kMultilinePrompt.description}`);
} else {
this[kWriteToOutput](kMultilinePrompt.description);
}
// Write the rest and restore the cursor to where the user left it
if (hasContentAfterCursor) {
// Save the cursor pos, we need to come back here
const oldCursor = this.getCursorPos();
// Write everything after the cursor which has been deleted by clearScreenDown
const formattedEndContent = StringPrototypeReplaceAll(
afterCursor,
'\n',
`\n${kMultilinePrompt.description}`,
);
this[kWriteToOutput](formattedEndContent);
const newCursor = this[kGetDisplayPos](this.line);
// Go back to where the cursor was, with relative movement
moveCursor(this.output, oldCursor.cols - newCursor.cols, oldCursor.rows - newCursor.rows);
// Setting how many rows we have on top of the cursor
// Necessary for kRefreshLine
this.prevRows = oldCursor.rows;
} else {
// Setting how many rows we have on top of the cursor
// Necessary for kRefreshLine
this.prevRows = StringPrototypeSplit(this.line, '\n').length - 1;
}
}
[kPushToUndoStack](text, cursor) {
if (ArrayPrototypePush(this[kUndoStack], { text, cursor }) >
kMaxUndoRedoStackSize) {
@ -1525,6 +1648,7 @@ module.exports = {
kWordRight,
kWriteToOutput,
kMultilinePrompt,
kRestorePreviousState,
kAddNewLineOnTTY,
kLastCommandErrored,
kNormalizeHistoryLineEndings,
};