-
-
Notifications
You must be signed in to change notification settings - Fork 31.7k
repl: add reverse search #31006
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
repl: add reverse search #31006
Changes from 4 commits
e997e86
be0fe19
4a41ffc
7f38f0a
415d5a1
19ded67
29fcab9
f3387ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -24,6 +24,7 @@ const { | |
|
||
const { | ||
clearLine, | ||
clearScreenDown, | ||
cursorTo, | ||
moveCursor, | ||
} = require('readline'); | ||
|
@@ -42,7 +43,13 @@ const inspectOptions = { | |
compact: true, | ||
breakLength: Infinity | ||
}; | ||
const inspectedOptions = inspect(inspectOptions, { colors: false }); | ||
// Specify options that might change the output in a way that it's not a valid | ||
// stringified object anymore. | ||
const inspectedOptions = inspect(inspectOptions, { | ||
depth: 1, | ||
colors: false, | ||
showHidden: false | ||
}); | ||
|
||
// If the error is that we've unexpectedly ended the input, | ||
// then let the user try to recover by adding more input. | ||
|
@@ -132,11 +139,19 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) { | |
let previewCompletionCounter = 0; | ||
let completionPreview = null; | ||
|
||
function getPreviewPos() { | ||
const displayPos = repl._getDisplayPos(`${repl._prompt}${repl.line}`); | ||
const cursorPos = repl._getCursorPos(); | ||
const rows = 1 + displayPos.rows - cursorPos.rows; | ||
return { rows, cols: cursorPos.cols }; | ||
} | ||
|
||
const clearPreview = () => { | ||
if (inputPreview !== null) { | ||
moveCursor(repl.output, 0, 1); | ||
const { rows } = getPreviewPos(); | ||
moveCursor(repl.output, 0, rows); | ||
clearLine(repl.output); | ||
moveCursor(repl.output, 0, -1); | ||
moveCursor(repl.output, 0, -rows); | ||
lastInputPreview = inputPreview; | ||
inputPreview = null; | ||
} | ||
|
@@ -280,16 +295,6 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) { | |
return; | ||
} | ||
|
||
// Do not show previews in case the current line is longer than the column | ||
// width. | ||
// TODO(BridgeAR): Fix me. This should not be necessary. It currently breaks | ||
// the output though. We also have to check for characters that have more | ||
// than a single byte as length. Check Interface.prototype._moveCursor. It | ||
// contains the necessary logic. | ||
if (repl.line.length + repl._prompt.length > repl.columns) { | ||
return; | ||
} | ||
|
||
// Add the autocompletion preview. | ||
// TODO(BridgeAR): Trigger the input preview after the completion preview. | ||
// That way it's possible to trigger the input prefix including the | ||
|
@@ -344,9 +349,12 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) { | |
`\u001b[90m${inspected}\u001b[39m` : | ||
`// ${inspected}`; | ||
|
||
const { rows: previewRows, cols: cursorCols } = getPreviewPos(); | ||
if (previewRows !== 1) | ||
moveCursor(repl.output, 0, previewRows - 1); | ||
const { cols: resultCols } = repl._getDisplayPos(result); | ||
repl.output.write(`\n${result}`); | ||
moveCursor(repl.output, 0, -1); | ||
cursorTo(repl.output, repl._prompt.length + repl.cursor); | ||
moveCursor(repl.output, cursorCols - resultCols, -previewRows); | ||
}); | ||
}; | ||
|
||
|
@@ -392,8 +400,223 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) { | |
return { showPreview, clearPreview }; | ||
} | ||
|
||
function setupReverseSearch(repl) { | ||
// Simple terminals can't use reverse search. | ||
if (process.env.TERM === 'dumb') { | ||
return { reverseSearch() { return false; } }; | ||
} | ||
|
||
const alreadyMatched = new Set(); | ||
const labels = { | ||
r: 'bck-i-search: ', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can these prompts be less abbreviated? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you have a suggestion? I thought it's nice to keep it aligned with ZSH and to also keep the length of both strings identical. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it OK to land this as is? We can also change the name later on in a patch. |
||
s: 'fwd-i-search: ' | ||
BridgeAR marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}; | ||
let isInReverseSearch = false; | ||
let historyIndex = -1; | ||
let input = ''; | ||
let cursor = -1; | ||
let dir = 'r'; | ||
let lastMatch = -1; | ||
let lastCursor = -1; | ||
let promptPos; | ||
|
||
function next() { | ||
BridgeAR marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return historyIndex >= 0 && historyIndex < repl.history.length; | ||
} | ||
|
||
function isDirectionKey(keyName) { | ||
BridgeAR marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (!labels[keyName]) { | ||
return false; | ||
} | ||
if (dir !== keyName) { | ||
// Reset the already matched set in case the direction is changed. That | ||
// way it's possible to find those entries again. | ||
alreadyMatched.clear(); | ||
} | ||
dir = keyName; | ||
return true; | ||
} | ||
|
||
function goToNextHistoryIndex() { | ||
// Ignore this entry for further searches and continue to the next | ||
// history entry. | ||
alreadyMatched.add(repl.history[historyIndex]); | ||
historyIndex += dir === 'r' ? 1 : -1; | ||
cursor = -1; | ||
} | ||
|
||
function search() { | ||
// Just print an empty line in case the user removed the search parameter. | ||
if (input === '') { | ||
print(repl.line, `${labels[dir]}_`); | ||
return; | ||
} | ||
// Fix the bounds in case the direction has changed in the meanwhile. | ||
if (dir === 'r') { | ||
if (historyIndex < 0) { | ||
historyIndex = 0; | ||
} | ||
} else if (historyIndex >= repl.history.length) { | ||
historyIndex = repl.history.length - 1; | ||
} | ||
// Check the history entries until a match is found. | ||
while (next()) { | ||
let entry = repl.history[historyIndex]; | ||
// Visualize all potential matches only once. | ||
if (alreadyMatched.has(entry)) { | ||
historyIndex += dir === 'r' ? 1 : -1; | ||
continue; | ||
} | ||
// Match the next entry either from the start or from the end, depending | ||
// on the current direction. | ||
if (dir === 'r') { | ||
// Update the cursor in case it's necessary. | ||
if (cursor === -1) { | ||
cursor = entry.length; | ||
} | ||
cursor = entry.lastIndexOf(input, cursor - 1); | ||
} else { | ||
cursor = entry.indexOf(input, cursor + 1); | ||
} | ||
// Match not found. | ||
if (cursor === -1) { | ||
goToNextHistoryIndex(); | ||
// Match found. | ||
} else { | ||
if (repl.useColors) { | ||
const start = entry.slice(0, cursor); | ||
const end = entry.slice(cursor + input.length); | ||
entry = `${start}\x1B[4m${input}\x1B[24m${end}`; | ||
} | ||
print(entry, `${labels[dir]}${input}_`, cursor); | ||
lastMatch = historyIndex; | ||
lastCursor = cursor; | ||
// Explicitly go to the next history item in case no further matches are | ||
// possible with the current entry. | ||
if ((dir === 'r' && cursor === 0) || | ||
(dir === 's' && entry.length === cursor + input.length)) { | ||
goToNextHistoryIndex(); | ||
} | ||
return; | ||
} | ||
} | ||
print(repl.line, `failed-${labels[dir]}${input}_`); | ||
} | ||
|
||
function print(outputLine, inputLine, cursor = repl.cursor) { | ||
let rows = 0; | ||
if (lastMatch !== -1) { | ||
const line = repl.history[lastMatch].slice(0, lastCursor); | ||
rows = repl._getDisplayPos(`${repl._prompt}${line}`).rows; | ||
cursorTo(repl.output, promptPos.cols); | ||
} else if (isInReverseSearch && repl.line !== '') { | ||
rows = repl._getCursorPos().rows; | ||
cursorTo(repl.output, promptPos.cols); | ||
} | ||
if (rows !== 0) | ||
moveCursor(repl.output, 0, -rows); | ||
|
||
if (isInReverseSearch) { | ||
clearScreenDown(repl.output); | ||
repl.output.write(`${outputLine}\n${inputLine}`); | ||
} else { | ||
repl.output.write(`\n${inputLine}`); | ||
} | ||
|
||
lastMatch = -1; | ||
|
||
// To know exactly how many rows we have to move the cursor back we need the | ||
// cursor rows, the output rows and the input rows. | ||
const prompt = repl._prompt; | ||
const cursorLine = `${prompt}${outputLine.slice(0, cursor)}`; | ||
const cursorPos = repl._getDisplayPos(cursorLine); | ||
const outputPos = repl._getDisplayPos(`${prompt}${outputLine}`); | ||
const inputPos = repl._getDisplayPos(inputLine); | ||
|
||
const rowModifier = -1 - inputPos.rows - (outputPos.rows - cursorPos.rows); | ||
|
||
moveCursor(repl.output, 0, rowModifier); | ||
cursorTo(repl.output, cursorPos.cols); | ||
} | ||
|
||
function reset(string) { | ||
isInReverseSearch = string !== undefined; | ||
|
||
// In case the reverse search ends and a history entry is found, reset the | ||
// line to the found entry. | ||
if (!isInReverseSearch) { | ||
if (lastMatch !== -1) { | ||
repl.line = repl.history[lastMatch]; | ||
repl.cursor = lastCursor; | ||
repl.historyIndex = lastMatch; | ||
} | ||
|
||
lastMatch = -1; | ||
|
||
// Clear screen and write the current repl.line before exiting. | ||
cursorTo(repl.output, promptPos.cols); | ||
if (promptPos.rows !== 0) | ||
moveCursor(repl.output, 0, promptPos.rows); | ||
clearScreenDown(repl.output); | ||
if (repl.line !== '') { | ||
repl.output.write(repl.line); | ||
if (repl.line.length !== repl.cursor) { | ||
const { cols, rows } = repl._getCursorPos(); | ||
cursorTo(repl.output, cols); | ||
if (rows !== 0) | ||
moveCursor(repl.output, 0, rows); | ||
} | ||
} | ||
} | ||
|
||
input = string || ''; | ||
cursor = -1; | ||
historyIndex = repl.historyIndex; | ||
alreadyMatched.clear(); | ||
} | ||
|
||
function reverseSearch(string, key) { | ||
if (!isInReverseSearch) { | ||
if (key.ctrl && isDirectionKey(key.name)) { | ||
historyIndex = repl.historyIndex; | ||
promptPos = repl._getDisplayPos(`${repl._prompt}`); | ||
print(repl.line, `${labels[dir]}_`); | ||
isInReverseSearch = true; | ||
} | ||
} else if (key.ctrl && isDirectionKey(key.name)) { | ||
search(); | ||
} else if (key.name === 'backspace' || | ||
(key.ctrl && (key.name === 'h' || key.name === 'w'))) { | ||
reset(input.slice(0, input.length - 1)); | ||
search(); | ||
// Special handle <ctrl> + c and escape. Those should only cancel the | ||
// reverse search. The original line is visible afterwards again. | ||
} else if ((key.ctrl && key.name === 'c') || key.name === 'escape') { | ||
lastMatch = -1; | ||
reset(); | ||
return true; | ||
// End search in case either enter is pressed or if any non-reverse-search | ||
// key (combination) is pressed. | ||
} else if (key.ctrl || | ||
key.meta || | ||
key.name === 'return' || | ||
key.name === 'enter' || | ||
typeof string !== 'string' || | ||
string === '') { | ||
reset(); | ||
} else { | ||
reset(`${input}${string}`); | ||
search(); | ||
} | ||
return isInReverseSearch; | ||
} | ||
|
||
return { reverseSearch }; | ||
} | ||
|
||
module.exports = { | ||
isRecoverableError, | ||
kStandaloneREPL: Symbol('kStandaloneREPL'), | ||
setupPreview | ||
setupPreview, | ||
setupReverseSearch | ||
}; |
Uh oh!
There was an error while loading. Please reload this page.