Skip to content

repl: add possibility to edit multiline commands while adding them #58003

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

Closed
Closed
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
4 changes: 4 additions & 0 deletions doc/api/repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,10 @@ A list of the names of some Node.js modules, e.g., `'http'`.
<!-- YAML
added: v0.1.91
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/58003
description: Added the possibility to add/edit/remove multilines
while adding a multiline command.
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/57400
description: The multi-line indicator is now "|" instead of "...".
Expand Down
246 changes: 196 additions & 50 deletions lib/internal/readline/interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const {
charLengthLeft,
commonPrefix,
kSubstringSearch,
reverseString,
} = require('internal/readline/utils');
let emitKeypressEvents;
let kFirstEventParam;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand All @@ -151,6 +151,14 @@ 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 kPreviousCursorCols = Symbol('_previousCursorCols');
const kMultilineMove = Symbol('_multilineMove');
const kPreviousPrevRows = Symbol('_previousPrevRows');
const kAddNewLineOnTTY = Symbol('_addNewLineOnTTY');

function InterfaceConstructor(input, output, completer, terminal) {
this[kSawReturnAt] = 0;
Expand Down Expand Up @@ -239,6 +247,7 @@ function InterfaceConstructor(input, output, completer, terminal) {
this[kRedoStack] = [];
this.history = history;
this.historySize = historySize;
this[kPreviousCursorCols] = -1;

// The kill ring is a global list of blocks of text that were previously
// killed (deleted). If its size exceeds kMaxLengthOfKillRing, the oldest
Expand Down Expand Up @@ -430,7 +439,7 @@ class Interface extends InterfaceConstructor {
}
}

[kSetLine](line) {
[kSetLine](line = '') {
this.line = line;
this[kIsMultiline] = StringPrototypeIncludes(line, '\n');
}
Expand Down Expand Up @@ -477,10 +486,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);
Expand All @@ -494,22 +500,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)
Expand All @@ -521,7 +533,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);
Expand Down Expand Up @@ -938,6 +950,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');
Expand All @@ -947,13 +971,115 @@ class Interface extends InterfaceConstructor {
}

[kLine]() {
this[kSavePreviousState]();
const line = this[kAddHistory]();
this[kUndoStack] = [];
this[kRedoStack] = [];
this.clearLine();
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) {
Expand Down Expand Up @@ -991,27 +1117,50 @@ class Interface extends InterfaceConstructor {
this[kRefreshLine]();
}

[kMoveDownOrHistoryNext]() {
const { cols, rows } = this.getCursorPos();
const splitLine = StringPrototypeSplit(this.line, '\n');
if (!this.historyIndex && rows === splitLine.length) {
return;
[kMultilineMove](direction, splitLines, { rows, cols }) {
const curr = splitLines[rows];
const down = direction === 1;
const adj = splitLines[rows + direction];
const promptLen = kMultilinePrompt.description.length;
let amountToMove;
// Clamp distance to end of current + prompt + next/prev line + newline
const clamp = down ?
curr.length - cols + promptLen + adj.length + 1 :
-cols + 1;
const shouldClamp = cols > adj.length + 1;

if (shouldClamp) {
if (this[kPreviousCursorCols] === -1) {
this[kPreviousCursorCols] = cols;
}
amountToMove = clamp;
} else {
if (down) {
amountToMove = curr.length + 1;
} else {
amountToMove = -adj.length - 1;
}
if (this[kPreviousCursorCols] !== -1) {
if (this[kPreviousCursorCols] <= adj.length) {
amountToMove += this[kPreviousCursorCols] - cols;
this[kPreviousCursorCols] = -1;
} else {
amountToMove = clamp;
}
}
}
// Go to the next history only if the cursor is in the first line of the multiline input.
// Otherwise treat the "arrow down" as a movement to the next row.
if (this[kIsMultiline] && rows < splitLine.length - 1) {
const currentLine = splitLine[rows];
const nextLine = splitLine[rows + 1];
// If I am moving down and the current line is longer than the next line
const amountToMove = (cols > nextLine.length + 1) ?
currentLine.length - cols + nextLine.length +
kMultilinePrompt.description.length + 1 : // Move to the end of the current line
// + chars to account for the kMultilinePrompt prefix, + 1 to go to the first char
currentLine.length + 1; // Otherwise just move to the next line, in the same position
this[kMoveCursor](amountToMove);

this[kMoveCursor](amountToMove);
}

[kMoveDownOrHistoryNext]() {
const cursorPos = this.getCursorPos();
const splitLines = StringPrototypeSplit(this.line, '\n');
if (this[kIsMultiline] && cursorPos.rows < splitLines.length - 1) {
this[kMultilineMove](1, splitLines, cursorPos);
return;
}

this[kPreviousCursorCols] = -1;
this[kHistoryNext]();
}

Expand Down Expand Up @@ -1046,23 +1195,13 @@ class Interface extends InterfaceConstructor {
}

[kMoveUpOrHistoryPrev]() {
const { cols, rows } = this.getCursorPos();
if (this.historyIndex === this.history.length && rows) {
return;
}
// Go to the previous history only if the cursor is in the first line of the multiline input.
// Otherwise treat the "arrow up" as a movement to the previous row.
if (this[kIsMultiline] && rows > 0) {
const splitLine = StringPrototypeSplit(this.line, '\n');
const previousLine = splitLine[rows - 1];
// If I am moving up and the current line is longer than the previous line
const amountToMove = (cols > previousLine.length + 1) ?
-cols + 1 : // Move to the beginning of the current line + 1 char to go to the end of the previous line
-previousLine.length - 1; // Otherwise just move to the previous line, in the same position
this[kMoveCursor](amountToMove);
const cursorPos = this.getCursorPos();
if (this[kIsMultiline] && cursorPos.rows > 0) {
const splitLines = StringPrototypeSplit(this.line, '\n');
this[kMultilineMove](-1, splitLines, cursorPos);
return;
}

this[kPreviousCursorCols] = -1;
this[kHistoryPrev]();
}

Expand Down Expand Up @@ -1173,6 +1312,7 @@ class Interface extends InterfaceConstructor {
const previousKey = this[kPreviousKey];
key ||= kEmptyObject;
this[kPreviousKey] = key;
let shouldResetPreviousCursorCols = true;

if (!key.meta || key.name !== 'y') {
// Reset yanking state unless we are doing yank pop.
Expand Down Expand Up @@ -1420,10 +1560,12 @@ class Interface extends InterfaceConstructor {
break;

case 'up':
shouldResetPreviousCursorCols = false;
this[kMoveUpOrHistoryPrev]();
break;

case 'down':
shouldResetPreviousCursorCols = false;
this[kMoveDownOrHistoryNext]();
break;

Expand Down Expand Up @@ -1459,6 +1601,9 @@ class Interface extends InterfaceConstructor {
}
}
}
if (shouldResetPreviousCursorCols) {
this[kPreviousCursorCols] = -1;
}
}

/**
Expand Down Expand Up @@ -1525,6 +1670,7 @@ module.exports = {
kWordRight,
kWriteToOutput,
kMultilinePrompt,
kRestorePreviousState,
kAddNewLineOnTTY,
kLastCommandErrored,
kNormalizeHistoryLineEndings,
};
Loading
Loading