Skip to content

Commit 96be783

Browse files
puskinnodejs-github-bot
authored andcommitted
repl: add possibility to edit multiline commands while adding them
PR-URL: #58003 Reviewed-By: Ruben Bridgewater <[email protected]> Reviewed-By: Pietro Marchini <[email protected]>
1 parent 5fb879c commit 96be783

7 files changed

+506
-53
lines changed

doc/api/repl.md

+4
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,10 @@ A list of the names of some Node.js modules, e.g., `'http'`.
680680
<!-- YAML
681681
added: v0.1.91
682682
changes:
683+
- version: REPLACEME
684+
pr-url: https://github.com/nodejs/node/pull/58003
685+
description: Added the possibility to add/edit/remove multilines
686+
while adding a multiline command.
683687
- version: REPLACEME
684688
pr-url: https://github.com/nodejs/node/pull/57400
685689
description: The multi-line indicator is now "|" instead of "...".

lib/internal/readline/interface.js

+141-17
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const {
6464
charLengthLeft,
6565
commonPrefix,
6666
kSubstringSearch,
67+
reverseString,
6768
} = require('internal/readline/utils');
6869
let emitKeypressEvents;
6970
let kFirstEventParam;
@@ -98,9 +99,7 @@ const ESCAPE_CODE_TIMEOUT = 500;
9899
// Max length of the kill ring
99100
const kMaxLengthOfKillRing = 32;
100101

101-
// TODO(puskin94): make this configurable
102102
const kMultilinePrompt = Symbol('| ');
103-
const kLastCommandErrored = Symbol('_lastCommandErrored');
104103

105104
const kAddHistory = Symbol('_addHistory');
106105
const kBeforeEdit = Symbol('_beforeEdit');
@@ -131,6 +130,7 @@ const kPrompt = Symbol('_prompt');
131130
const kPushToKillRing = Symbol('_pushToKillRing');
132131
const kPushToUndoStack = Symbol('_pushToUndoStack');
133132
const kQuestionCallback = Symbol('_questionCallback');
133+
const kLastCommandErrored = Symbol('_lastCommandErrored');
134134
const kQuestionReject = Symbol('_questionReject');
135135
const kRedo = Symbol('_redo');
136136
const kRedoStack = Symbol('_redoStack');
@@ -151,6 +151,12 @@ const kYank = Symbol('_yank');
151151
const kYanking = Symbol('_yanking');
152152
const kYankPop = Symbol('_yankPop');
153153
const kNormalizeHistoryLineEndings = Symbol('_normalizeHistoryLineEndings');
154+
const kSavePreviousState = Symbol('_savePreviousState');
155+
const kRestorePreviousState = Symbol('_restorePreviousState');
156+
const kPreviousLine = Symbol('_previousLine');
157+
const kPreviousCursor = Symbol('_previousCursor');
158+
const kPreviousPrevRows = Symbol('_previousPrevRows');
159+
const kAddNewLineOnTTY = Symbol('_addNewLineOnTTY');
154160

155161
function InterfaceConstructor(input, output, completer, terminal) {
156162
this[kSawReturnAt] = 0;
@@ -430,7 +436,7 @@ class Interface extends InterfaceConstructor {
430436
}
431437
}
432438

433-
[kSetLine](line) {
439+
[kSetLine](line = '') {
434440
this.line = line;
435441
this[kIsMultiline] = StringPrototypeIncludes(line, '\n');
436442
}
@@ -477,10 +483,7 @@ class Interface extends InterfaceConstructor {
477483
// Reversing the multilines is necessary when adding / editing and displaying them
478484
if (reverse) {
479485
// First reverse the lines for proper order, then convert separators
480-
return ArrayPrototypeJoin(
481-
ArrayPrototypeReverse(StringPrototypeSplit(line, from)),
482-
to,
483-
);
486+
return reverseString(line, from, to);
484487
}
485488
// For normal cases (saving to history or non-multiline entries)
486489
return StringPrototypeReplaceAll(line, from, to);
@@ -494,22 +497,28 @@ class Interface extends InterfaceConstructor {
494497

495498
// If the trimmed line is empty then return the line
496499
if (StringPrototypeTrim(this.line).length === 0) return this.line;
497-
const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r', false);
500+
501+
// This is necessary because each line would be saved in the history while creating
502+
// A new multiline, and we don't want that.
503+
if (this[kIsMultiline] && this.historyIndex === -1) {
504+
ArrayPrototypeShift(this.history);
505+
} else if (this[kLastCommandErrored]) {
506+
// If the last command errored and we are trying to edit the history to fix it
507+
// Remove the broken one from the history
508+
ArrayPrototypeShift(this.history);
509+
}
510+
511+
const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r', true);
498512

499513
if (this.history.length === 0 || this.history[0] !== normalizedLine) {
500-
if (this[kLastCommandErrored] && this.historyIndex === 0) {
501-
// If the last command errored, remove it from history.
502-
// The user is issuing a new command starting from the errored command,
503-
// Hopefully with the fix
504-
ArrayPrototypeShift(this.history);
505-
}
506514
if (this.removeHistoryDuplicates) {
507515
// Remove older history line if identical to new one
508516
const dupIndex = ArrayPrototypeIndexOf(this.history, this.line);
509517
if (dupIndex !== -1) ArrayPrototypeSplice(this.history, dupIndex, 1);
510518
}
511519

512-
ArrayPrototypeUnshift(this.history, this.line);
520+
// Add the new line to the history
521+
ArrayPrototypeUnshift(this.history, normalizedLine);
513522

514523
// Only store so many
515524
if (this.history.length > this.historySize)
@@ -521,7 +530,7 @@ class Interface extends InterfaceConstructor {
521530
// The listener could change the history object, possibly
522531
// to remove the last added entry if it is sensitive and should
523532
// not be persisted in the history, like a password
524-
const line = this.history[0];
533+
const line = this[kIsMultiline] ? reverseString(this.history[0]) : this.history[0];
525534

526535
// Emit history event to notify listeners of update
527536
this.emit('history', this.history);
@@ -938,6 +947,18 @@ class Interface extends InterfaceConstructor {
938947
}
939948
}
940949

950+
[kSavePreviousState]() {
951+
this[kPreviousLine] = this.line;
952+
this[kPreviousCursor] = this.cursor;
953+
this[kPreviousPrevRows] = this.prevRows;
954+
}
955+
956+
[kRestorePreviousState]() {
957+
this[kSetLine](this[kPreviousLine]);
958+
this.cursor = this[kPreviousCursor];
959+
this.prevRows = this[kPreviousPrevRows];
960+
}
961+
941962
clearLine() {
942963
this[kMoveCursor](+Infinity);
943964
this[kWriteToOutput]('\r\n');
@@ -947,13 +968,115 @@ class Interface extends InterfaceConstructor {
947968
}
948969

949970
[kLine]() {
971+
this[kSavePreviousState]();
950972
const line = this[kAddHistory]();
951973
this[kUndoStack] = [];
952974
this[kRedoStack] = [];
953975
this.clearLine();
954976
this[kOnLine](line);
955977
}
956978

979+
980+
// TODO(puskin94): edit [kTtyWrite] to make call this function on a new key combination
981+
// to make it add a new line in the middle of a "complete" multiline.
982+
// I tried with shift + enter but it is not detected. Find a new one.
983+
// Make sure to call this[kSavePreviousState](); && this.clearLine();
984+
// before calling this[kAddNewLineOnTTY] to simulate what [kLine] is doing.
985+
986+
// When this function is called, the actual cursor is at the very end of the whole string,
987+
// No matter where the new line was entered.
988+
// This function should only be used when the output is a TTY
989+
[kAddNewLineOnTTY]() {
990+
// Restore terminal state and store current line
991+
this[kRestorePreviousState]();
992+
const originalLine = this.line;
993+
994+
// Split the line at the current cursor position
995+
const beforeCursor = StringPrototypeSlice(this.line, 0, this.cursor);
996+
let afterCursor = StringPrototypeSlice(this.line, this.cursor, this.line.length);
997+
998+
// Add the new line where the cursor is at
999+
this[kSetLine](`${beforeCursor}\n${afterCursor}`);
1000+
1001+
// To account for the new line
1002+
this.cursor += 1;
1003+
1004+
const hasContentAfterCursor = afterCursor.length > 0;
1005+
const cursorIsNotOnFirstLine = this.prevRows > 0;
1006+
let needsRewriteFirstLine = false;
1007+
1008+
// Handle cursor positioning based on different scenarios
1009+
if (hasContentAfterCursor) {
1010+
const splitBeg = StringPrototypeSplit(beforeCursor, '\n');
1011+
// Determine if we need to rewrite the first line
1012+
needsRewriteFirstLine = splitBeg.length < 2;
1013+
1014+
// If the cursor is not on the first line
1015+
if (cursorIsNotOnFirstLine) {
1016+
const splitEnd = StringPrototypeSplit(afterCursor, '\n');
1017+
1018+
// If the cursor when I pressed enter was at least on the second line
1019+
// I need to completely erase the line where the cursor was pressed because it is possible
1020+
// That it was pressed in the middle of the line, hence I need to write the whole line.
1021+
// To achieve that, I need to reach the line above the current line coming from the end
1022+
const dy = splitEnd.length + 1;
1023+
1024+
// Calculate how many Xs we need to move on the right to get to the end of the line
1025+
const dxEndOfLineAbove = (splitBeg[splitBeg.length - 2] || '').length + kMultilinePrompt.description.length;
1026+
moveCursor(this.output, dxEndOfLineAbove, -dy);
1027+
1028+
// This is the line that was split in the middle
1029+
// Just add it to the rest of the line that will be printed later
1030+
afterCursor = `${splitBeg[splitBeg.length - 1]}\n${afterCursor}`;
1031+
} else {
1032+
// Otherwise, go to the very beginning of the first line and erase everything
1033+
const dy = StringPrototypeSplit(originalLine, '\n').length;
1034+
moveCursor(this.output, 0, -dy);
1035+
}
1036+
1037+
// Erase from the cursor to the end of the line
1038+
clearScreenDown(this.output);
1039+
1040+
if (cursorIsNotOnFirstLine) {
1041+
this[kWriteToOutput]('\n');
1042+
}
1043+
}
1044+
1045+
if (needsRewriteFirstLine) {
1046+
this[kWriteToOutput](`${this[kPrompt]}${beforeCursor}\n${kMultilinePrompt.description}`);
1047+
} else {
1048+
this[kWriteToOutput](kMultilinePrompt.description);
1049+
}
1050+
1051+
// Write the rest and restore the cursor to where the user left it
1052+
if (hasContentAfterCursor) {
1053+
// Save the cursor pos, we need to come back here
1054+
const oldCursor = this.getCursorPos();
1055+
1056+
// Write everything after the cursor which has been deleted by clearScreenDown
1057+
const formattedEndContent = StringPrototypeReplaceAll(
1058+
afterCursor,
1059+
'\n',
1060+
`\n${kMultilinePrompt.description}`,
1061+
);
1062+
1063+
this[kWriteToOutput](formattedEndContent);
1064+
1065+
const newCursor = this[kGetDisplayPos](this.line);
1066+
1067+
// Go back to where the cursor was, with relative movement
1068+
moveCursor(this.output, oldCursor.cols - newCursor.cols, oldCursor.rows - newCursor.rows);
1069+
1070+
// Setting how many rows we have on top of the cursor
1071+
// Necessary for kRefreshLine
1072+
this.prevRows = oldCursor.rows;
1073+
} else {
1074+
// Setting how many rows we have on top of the cursor
1075+
// Necessary for kRefreshLine
1076+
this.prevRows = StringPrototypeSplit(this.line, '\n').length - 1;
1077+
}
1078+
}
1079+
9571080
[kPushToUndoStack](text, cursor) {
9581081
if (ArrayPrototypePush(this[kUndoStack], { text, cursor }) >
9591082
kMaxUndoRedoStackSize) {
@@ -1525,6 +1648,7 @@ module.exports = {
15251648
kWordRight,
15261649
kWriteToOutput,
15271650
kMultilinePrompt,
1651+
kRestorePreviousState,
1652+
kAddNewLineOnTTY,
15281653
kLastCommandErrored,
1529-
kNormalizeHistoryLineEndings,
15301654
};

lib/internal/readline/utils.js

+16
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const {
77
StringPrototypeCharCodeAt,
88
StringPrototypeCodePointAt,
99
StringPrototypeSlice,
10+
StringPrototypeSplit,
1011
StringPrototypeToLowerCase,
1112
Symbol,
1213
} = primordials;
@@ -395,11 +396,26 @@ function commonPrefix(strings) {
395396
return min;
396397
}
397398

399+
function reverseString(line, from = '\r', to = '\r') {
400+
const parts = StringPrototypeSplit(line, from);
401+
402+
// This implementation should be faster than
403+
// ArrayPrototypeJoin(ArrayPrototypeReverse(StringPrototypeSplit(line, from)), to);
404+
let result = '';
405+
for (let i = parts.length - 1; i > 0; i--) {
406+
result += parts[i] + to;
407+
}
408+
result += parts[0];
409+
410+
return result;
411+
}
412+
398413
module.exports = {
399414
charLengthAt,
400415
charLengthLeft,
401416
commonPrefix,
402417
emitKeys,
418+
reverseString,
403419
kSubstringSearch,
404420
CSI,
405421
};

lib/repl.js

+9-28
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ const {
5353
ArrayPrototypePop,
5454
ArrayPrototypePush,
5555
ArrayPrototypePushApply,
56-
ArrayPrototypeReverse,
5756
ArrayPrototypeShift,
5857
ArrayPrototypeSlice,
5958
ArrayPrototypeSome,
@@ -196,8 +195,8 @@ const {
196195
} = require('internal/vm');
197196
const {
198197
kMultilinePrompt,
198+
kAddNewLineOnTTY,
199199
kLastCommandErrored,
200-
kNormalizeHistoryLineEndings,
201200
} = require('internal/readline/interface');
202201
let nextREPLResourceNumber = 1;
203202
// This prevents v8 code cache from getting confused and using a different
@@ -361,6 +360,7 @@ function REPLServer(prompt,
361360
this.editorMode = false;
362361
// Context id for use with the inspector protocol.
363362
this[kContextId] = undefined;
363+
this[kLastCommandErrored] = false;
364364

365365
if (this.breakEvalOnSigint && eval_) {
366366
// Allowing this would not reflect user expectations.
@@ -929,8 +929,6 @@ function REPLServer(prompt,
929929
debug('finish', e, ret);
930930
ReflectApply(_memory, self, [cmd]);
931931

932-
self[kLastCommandErrored] = false;
933-
934932
if (e && !self[kBufferedCommandSymbol] &&
935933
StringPrototypeStartsWith(StringPrototypeTrim(cmd), 'npm ') &&
936934
!(e instanceof Recoverable)
@@ -943,33 +941,15 @@ function REPLServer(prompt,
943941
}
944942

945943
// If error was SyntaxError and not JSON.parse error
946-
if (e) {
947-
if (e instanceof Recoverable && !sawCtrlD) {
948-
// Start buffering data like that:
949-
// {
950-
// ... x: 1
951-
// ... }
944+
// We can start a multiline command
945+
if (e instanceof Recoverable && !sawCtrlD) {
946+
if (self.terminal) {
947+
self[kAddNewLineOnTTY]();
948+
} else {
952949
self[kBufferedCommandSymbol] += cmd + '\n';
953950
self.displayPrompt();
954-
return;
955-
}
956-
}
957-
958-
// In the next two if blocks, we do not use os.EOL instead of '\n'
959-
// because on Windows it is '\r\n'
960-
if (StringPrototypeIncludes(cmd, '\n')) { // If you are editing a multiline command
961-
self.history[0] = self[kNormalizeHistoryLineEndings](cmd, '\n', '\r');
962-
} else if (self[kBufferedCommandSymbol]) { // If a new multiline command was entered
963-
// Remove the first N lines from the self.history array
964-
// where N is the number of lines in the buffered command
965-
966-
const lines = StringPrototypeSplit(self[kBufferedCommandSymbol], '\n');
967-
self.history = ArrayPrototypeSlice(self.history, lines.length);
968-
lines[lines.length - 1] = cmd;
969-
const newHistoryLine = ArrayPrototypeJoin(ArrayPrototypeReverse(lines), '\r');
970-
if (self.history[0] !== newHistoryLine) {
971-
ArrayPrototypeUnshift(self.history, newHistoryLine);
972951
}
952+
return;
973953
}
974954

975955
if (e) {
@@ -997,6 +977,7 @@ function REPLServer(prompt,
997977
// Display prompt again (unless we already did by emitting the 'error'
998978
// event on the domain instance).
999979
if (!e) {
980+
self[kLastCommandErrored] = false;
1000981
self.displayPrompt();
1001982
}
1002983
}

0 commit comments

Comments
 (0)