Skip to content

Commit 1947902

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

File tree

6 files changed

+505
-53
lines changed

6 files changed

+505
-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

+157-17
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,28 @@ 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;
497-
const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r', false);
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+
526+
const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r', true);
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, normalizedLine);
513536

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

526549
// Emit history event to notify listeners of update
527550
this.emit('history', this.history);
@@ -938,6 +961,18 @@ class Interface extends InterfaceConstructor {
938961
}
939962
}
940963

964+
[kSavePreviousState]() {
965+
this[kPreviousLine] = this.line;
966+
this[kPreviousCursor] = this.cursor;
967+
this[kPreviousPrevRows] = this.prevRows;
968+
}
969+
970+
[kRestorePreviousState]() {
971+
this[kSetLine](this[kPreviousLine]);
972+
this.cursor = this[kPreviousCursor];
973+
this.prevRows = this[kPreviousPrevRows];
974+
}
975+
941976
clearLine() {
942977
this[kMoveCursor](+Infinity);
943978
this[kWriteToOutput]('\r\n');
@@ -947,13 +982,117 @@ class Interface extends InterfaceConstructor {
947982
}
948983

949984
[kLine]() {
985+
this[kSavePreviousState]();
950986
const line = this[kAddHistory]();
951987
this[kUndoStack] = [];
952988
this[kRedoStack] = [];
953989
this.clearLine();
954990
this[kOnLine](line);
955991
}
956992

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

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 && 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)