Skip to content

Commit 9aca5ed

Browse files
committed
repl: add possibility to edit multiline commands while adding them
1 parent c11c7be commit 9aca5ed

5 files changed

Lines changed: 395 additions & 45 deletions

File tree

doc/api/repl.md

Lines changed: 4 additions & 0 deletions
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

Lines changed: 158 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,7 @@ const ESCAPE_CODE_TIMEOUT = 500;
9898
// Max length of the kill ring
9999
const kMaxLengthOfKillRing = 32;
100100

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

105103
const kAddHistory = Symbol('_addHistory');
106104
const kBeforeEdit = Symbol('_beforeEdit');
@@ -131,6 +129,8 @@ const kPrompt = Symbol('_prompt');
131129
const kPushToKillRing = Symbol('_pushToKillRing');
132130
const kPushToUndoStack = Symbol('_pushToUndoStack');
133131
const kQuestionCallback = Symbol('_questionCallback');
132+
const kReverseString = Symbol('_reverseString');
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,15 +483,26 @@ 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 this[kReverseString](line, from, to);
484487
}
485488
// For normal cases (saving to history or non-multiline entries)
486489
return StringPrototypeReplaceAll(line, from, to);
487490
}
488491

492+
[kReverseString](line, from, to) {
493+
const parts = StringPrototypeSplit(line, from);
494+
495+
// This implementation should be faster than
496+
// ArrayPrototypeJoin(ArrayPrototypeReverse(StringPrototypeSplit(line, from)), to);
497+
let result = '';
498+
for (let i = parts.length - 1; i > 0; i--) {
499+
result += parts[i] + to;
500+
}
501+
result += parts[0];
502+
503+
return result;
504+
}
505+
489506
[kAddHistory]() {
490507
if (this.line.length === 0) return '';
491508

@@ -494,22 +511,30 @@ class Interface extends InterfaceConstructor {
494511

495512
// If the trimmed line is empty then return the line
496513
if (StringPrototypeTrim(this.line).length === 0) return this.line;
514+
515+
// This is necessary because each like would be saved in the history while creating
516+
// A new multiline, and we don't want that.
517+
if (this[kIsMultiline] && this.historyIndex === -1) {
518+
ArrayPrototypeShift(this.history);
519+
}
520+
// If the last command errored and we are trying to edit the history to fix it
521+
// Remove the broken one from the history
522+
if (this[kLastCommandErrored] && this.history.length > 0) {
523+
ArrayPrototypeShift(this.history);
524+
}
525+
497526
const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r', false);
498527

499528
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-
}
506529
if (this.removeHistoryDuplicates) {
507530
// Remove older history line if identical to new one
508531
const dupIndex = ArrayPrototypeIndexOf(this.history, this.line);
509532
if (dupIndex !== -1) ArrayPrototypeSplice(this.history, dupIndex, 1);
510533
}
511534

512-
ArrayPrototypeUnshift(this.history, this.line);
535+
ArrayPrototypeUnshift(this.history, this[kIsMultiline] ?
536+
this[kReverseString](normalizedLine, '\r', '\r') :
537+
normalizedLine);
513538

514539
// Only store so many
515540
if (this.history.length > this.historySize)
@@ -521,7 +546,7 @@ class Interface extends InterfaceConstructor {
521546
// The listener could change the history object, possibly
522547
// to remove the last added entry if it is sensitive and should
523548
// not be persisted in the history, like a password
524-
const line = this.history[0];
549+
const line = this[kIsMultiline] ? this[kReverseString](this.history[0], '\r', '\r') : this.history[0];
525550

526551
// Emit history event to notify listeners of update
527552
this.emit('history', this.history);
@@ -938,6 +963,18 @@ class Interface extends InterfaceConstructor {
938963
}
939964
}
940965

966+
[kSavePreviousState]() {
967+
this[kPreviousLine] = this.line;
968+
this[kPreviousCursor] = this.cursor;
969+
this[kPreviousPrevRows] = this.prevRows;
970+
}
971+
972+
[kRestorePreviousState]() {
973+
this[kSetLine](this[kPreviousLine]);
974+
this.cursor = this[kPreviousCursor];
975+
this.prevRows = this[kPreviousPrevRows];
976+
}
977+
941978
clearLine() {
942979
this[kMoveCursor](+Infinity);
943980
this[kWriteToOutput]('\r\n');
@@ -947,13 +984,117 @@ class Interface extends InterfaceConstructor {
947984
}
948985

949986
[kLine]() {
987+
this[kSavePreviousState]();
950988
const line = this[kAddHistory]();
951989
this[kUndoStack] = [];
952990
this[kRedoStack] = [];
953991
this.clearLine();
954992
this[kOnLine](line);
955993
}
956994

995+
996+
// TODO(puskin94): edit [kTtyWrite] to make call this function on a new key combination
997+
// to make it add a new line in the middle of a "complete" multiline.
998+
// I tried with shift + enter but it is not detected. Find a new one.
999+
// Make sure to call this[kSavePreviousState](); && this.clearLine();
1000+
// before calling this[kAddNewLineOnTTY] to simulate what [kLine] is doing.
1001+
1002+
// When this function is called, the actual cursor is at the very end of the whole string,
1003+
// No matter where the new line was entered.
1004+
// This function should only be used when the output is a TTY
1005+
[kAddNewLineOnTTY]() {
1006+
if (!this.terminal) {
1007+
return;
1008+
}
1009+
1010+
this[kRestorePreviousState]();
1011+
const originalLine = this.line;
1012+
1013+
// Before the cursor
1014+
const beg = StringPrototypeSlice(this.line, 0, this.cursor);
1015+
// After the cursor
1016+
let end = StringPrototypeSlice(this.line, this.cursor, this.line.length);
1017+
1018+
// Add the new line where the cursor is at
1019+
this[kSetLine](`${beg}\n${end}`);
1020+
1021+
// To account for the new line
1022+
this.cursor += 1;
1023+
1024+
const hasContentAfterCursor = end.length > 0;
1025+
const cursorIsNotOnFirstLine = this.prevRows > 0;
1026+
const lineParts = hasContentAfterCursor ? StringPrototypeSplit(beg, '\n') : null;
1027+
1028+
// Handle cursor positioning based on different scenarios
1029+
if (hasContentAfterCursor) {
1030+
// If the cursor is not on the first line
1031+
if (cursorIsNotOnFirstLine) {
1032+
const splitEnd = StringPrototypeSplit(end, '\n');
1033+
// If the cursor when I pressed enter was at least on the second line
1034+
// I need to completely erase the line where the cursor was pressed because it is possible
1035+
// That it was pressed in the middle of the line, hence I need to write the whole line.
1036+
// To achieve that, I need to reach the line above the current line coming from the end
1037+
const dy = splitEnd.length + 1;
1038+
// Calculate how many Xs we need to move on the right to get to the end of the line
1039+
const dxEndOfLineAbove = lineParts[lineParts.length - 2].length + kMultilinePrompt.description.length;
1040+
moveCursor(this.output, dxEndOfLineAbove, -dy);
1041+
// This is the line that was split in the middle
1042+
// Just add it to the rest of the line that will be printed later
1043+
end = `${lineParts[lineParts.length - 1]}\n${end}`;
1044+
} else {
1045+
// Otherwise, go to the very beginning of the first line and erase everything
1046+
const dy = StringPrototypeSplit(originalLine, '\n').length;
1047+
moveCursor(this.output, 0, -dy);
1048+
}
1049+
} else {
1050+
// Otherwise, go to the very beginning of the currentLine
1051+
// We need to rewrite everything
1052+
cursorTo(this.output, 0);
1053+
}
1054+
1055+
// Erase from the cursor to the end of the line
1056+
clearScreenDown(this.output);
1057+
1058+
// Handle new line output for multiline case
1059+
if (hasContentAfterCursor && cursorIsNotOnFirstLine) {
1060+
this[kWriteToOutput]('\n');
1061+
}
1062+
1063+
// Determine if we need to rewrite the first line
1064+
const needsRewriteFirstLine = lineParts && lineParts.length < 2;
1065+
if (needsRewriteFirstLine) {
1066+
this[kWriteToOutput](`${this[kPrompt]}${beg}\n${kMultilinePrompt.description}`);
1067+
} else {
1068+
this[kWriteToOutput](kMultilinePrompt.description);
1069+
}
1070+
1071+
// Write the rest and restore the cursor to where the user left it
1072+
if (hasContentAfterCursor) {
1073+
// Save the cursor pos, we need to come back here
1074+
const oldCursor = this.getCursorPos();
1075+
1076+
// Write everything after the cursor which has been deleted by clearScreenDown
1077+
for (const letter of end) {
1078+
if (letter === '\n') {
1079+
this[kWriteToOutput](`\n${kMultilinePrompt.description}`);
1080+
} else {
1081+
this[kWriteToOutput](letter);
1082+
}
1083+
}
1084+
1085+
const newCursor = this[kGetDisplayPos](this.line);
1086+
// Go back to where the cursor was, with relative movement
1087+
moveCursor(this.output, oldCursor.cols - newCursor.cols, oldCursor.rows - newCursor.rows);
1088+
// Setting how many rows we have on top of the cursor
1089+
// Necessary for kRefreshLine
1090+
this.prevRows = oldCursor.rows;
1091+
} else {
1092+
// Setting how many rows we have on top of the cursor
1093+
// Necessary for kRefreshLine
1094+
this.prevRows = StringPrototypeSplit(this.line, '\n').length - 1;
1095+
}
1096+
}
1097+
9571098
[kPushToUndoStack](text, cursor) {
9581099
if (ArrayPrototypePush(this[kUndoStack], { text, cursor }) >
9591100
kMaxUndoRedoStackSize) {
@@ -1525,6 +1666,7 @@ module.exports = {
15251666
kWordRight,
15261667
kWriteToOutput,
15271668
kMultilinePrompt,
1669+
kRestorePreviousState,
1670+
kAddNewLineOnTTY,
15281671
kLastCommandErrored,
1529-
kNormalizeHistoryLineEndings,
15301672
};

lib/repl.js

Lines changed: 9 additions & 28 deletions
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 && 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)