/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// Constants used for defining the direction of JSTerm input history navigation. const {
HISTORY_BACK,
HISTORY_FORWARD,
} = require("resource://devtools/client/webconsole/constants.js");
const JSTERM_CODEMIRROR_ORIGIN = "jsterm";
/** * Create a JSTerminal (a JavaScript command line). This is attached to an * existing HeadsUpDisplay (a Web Console instance). This code is responsible * with handling command line input and code evaluation.
*/ class JSTerm extends Component { static get propTypes() { return { // Returns previous or next value from the history // (depending on direction argument).
getValueFromHistory: PropTypes.func.isRequired, // History of executed expression (state).
history: PropTypes.object.isRequired, // Console object.
webConsoleUI: PropTypes.object.isRequired, // Needed for opening context menu
serviceContainer: PropTypes.object.isRequired, // Handler for clipboard 'paste' event (also used for 'drop' event, callback).
onPaste: PropTypes.func, // Evaluate provided expression.
evaluateExpression: PropTypes.func.isRequired, // Update position in the history after executing an expression (action).
updateHistoryPosition: PropTypes.func.isRequired, // Update autocomplete popup state.
autocompleteUpdate: PropTypes.func.isRequired,
autocompleteClear: PropTypes.func.isRequired, // Data to be displayed in the autocomplete popup.
autocompleteData: PropTypes.object.isRequired, // Toggle the editor mode.
editorToggle: PropTypes.func.isRequired, // Dismiss the editor onboarding UI.
editorOnboardingDismiss: PropTypes.func.isRequired, // Set the last JS input value.
terminalInputChanged: PropTypes.func.isRequired, // Is the input in editor mode.
editorMode: PropTypes.bool,
editorWidth: PropTypes.number,
editorPrettifiedAt: PropTypes.number,
showEditorOnboarding: PropTypes.bool,
autocomplete: PropTypes.bool,
showEvaluationContextSelector: PropTypes.bool,
autocompletePopupPosition: PropTypes.string,
inputEnabled: PropTypes.bool,
};
}
// We debounce the autocompleteUpdate so we don't send too many requests to the server // as the user is typing. // The delay should be small enough to be unnoticed by the user. this.autocompleteUpdate = debounce(this.props.autocompleteUpdate, 75, this);
// Updates to the terminal input which can trigger eager evaluations are // similarly debounced. this.terminalInputChanged = debounce( this.props.terminalInputChanged,
75, this
);
// Because the autocomplete has a slight delay (75ms), there can be time where the // codeMirror completion text is out-of-date, which might lead to issue when the user // accept the autocompletion while the update of the completion text is still pending. // In order to account for that, we put any future value of the completion text in // this property. this.pendingCompletionText = null;
const doc = this.webConsoleUI.document; const { toolbox } = this.webConsoleUI.wrapper; const tooltipDoc = toolbox ? toolbox.doc : doc; // The popup will be attached to the toolbox document or HUD document in the case // such as the browser console which doesn't have a toolbox. this.autocompletePopup = new AutocompletePopup(
tooltipDoc,
autocompleteOptions
);
if (this.node) { const onArrowUp = () => {
let inputUpdated; if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectPreviousItem(); returnnull;
}
const onArrowRight = () => { // We only want to complete on Right arrow if the completion text is // displayed. if (this.getAutoCompletionText()) { this.acceptProposedCompletion(); returnnull;
}
Left: onArrowLeft, "Ctrl-Left": onArrowLeft, "Cmd-Left": onArrowLeft, "Alt-Left": onArrowLeft, // On OSX, Ctrl-A navigates to the beginning of the line. "Ctrl-A": isMacOS ? onArrowLeft : undefined,
this.resizeObserver = new ResizeObserver(() => { // If we don't have the node reference, or if the node isn't connected // anymore, we disconnect the resize observer (componentWillUnmount is never // called on this component, so we have to do it here). if (!this.node || !this.node.isConnected) { this.resizeObserver.disconnect(); return;
} // Calling `refresh` will update the cursor position, and all the selection blocks. this.editor.codeMirror.refresh();
}); this.resizeObserver.observe(this.node);
// Update the character width needed for the popup offset calculations. this._inputCharWidth = this._getInputCharWidth(); this.lastInputValue && this._setValue(this.lastInputValue);
}
}
/** * Do all the imperative work needed after a Redux store update. * * @param {Object} nextProps: props passed from shouldComponentUpdate.
*/
imperativeUpdate(nextProps) { if (!nextProps) { return;
}
if (
nextProps.editorPrettifiedAt &&
nextProps.editorPrettifiedAt !== this.props.editorPrettifiedAt
) { this._setValue(
beautify.js(this._getValue(), { // Read directly from prefs because this.editor.config.indentUnit and // this.editor.getOption('indentUnit') are not really synced with // prefs.
indent_size: Services.prefs.getIntPref("devtools.editor.tabsize"),
indent_with_tabs: !Services.prefs.getBoolPref( "devtools.editor.expandtab"
),
})
);
}
}
/** * * @param {Number|null} editorWidth: The width to set the node to. If null, removes any * `width` property on node style.
*/
setEditorWidth(editorWidth) { if (!this.node) { return;
}
const findPreviousFocusableElement = el => { if (!el || !el.querySelectorAll) { returnnull;
}
// We only want to get visible focusable element, and for that we can assert that // the offsetParent isn't null. We can do that because we don't have fixed position // element in the console. const items = getFocusableElements(el).filter(
({ offsetParent }) => offsetParent !== null
); const inputIndex = items.indexOf(inputField);
const focusableEl = findPreviousFocusableElement(this.node.parentNode); if (focusableEl) {
focusableEl.focus();
}
}
/** * Execute a string. Execution happens asynchronously in the content process.
*/
_execute() { const value = this._getValue(); // In editor mode, we only evaluate the text selection if there's one. The feature isn't // enabled in inline mode as it can be confusing since input is cleared when evaluating. const executeString = this.props.editorMode
? this.getSelectedText() || value
: value;
if (!executeString) { return;
}
if (!this.props.editorMode) { // Calling this.props.terminalInputChanged instead of this.terminalInputChanged // because we want to instantly hide the instant evaluation result, and don't want // the delay we have in this.terminalInputChanged. this.props.terminalInputChanged(""); this._setValue("");
} this.clearCompletion(); this.props.evaluateExpression(executeString);
}
/** * Sets the value of the input field. * * @param string newValue * The new value to set. * @returns void
*/
_setValue(newValue = "") { this.lastInputValue = newValue; this.terminalInputChanged(newValue);
if (this.editor) { // In order to get the autocomplete popup to work properly, we need to set the // editor text and the cursor in the same operation. If we don't, the text change // is done before the cursor is moved, and the autocompletion call to the server // sends an erroneous query. this.editor.codeMirror.operation(() => { this.editor.setText(newValue);
// Set the cursor at the end of the input. const lines = newValue.split("\n"); this.editor.setCursor({
line: lines.length - 1,
ch: lines[lines.length - 1].length,
}); this.editor.setAutoCompletionText();
});
}
this.emitForTests("set-input-value");
}
/** * Gets the value from the input field * @returns string
*/
_getValue() { returnthis.editor ? this.editor.getText() || "" : "";
}
/** * Open the file picker for the user to select a javascript file and open it. *
*/
async _openFile() { const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
fp.init( this.webConsoleUI.document.defaultView.browsingContext,
l10n.getStr("webconsole.input.openJavaScriptFile"),
Ci.nsIFilePicker.modeOpen
);
/** * Even handler for the "beforeChange" event fired by codeMirror. This event is fired * when codeMirror is about to make a change to its DOM representation.
*/
_onEditorBeforeChange(cm, change) { // If the user did not type a character that matches the completion text, then we // clear it before the change is done to prevent a visual glitch. // See Bugs 1491776 & 1558248. const { from, to, origin, text } = change; const isAddedText =
from.line === to.line && from.ch === to.ch && origin === "+input";
// if there was no changes (hitting delete on an empty input, or suppr when at the end // of the input), we bail out. if (
!isAddedText &&
origin === "+delete" &&
from.line === to.line &&
from.ch === to.ch
) { return;
}
if (!completionText || change.canceled || !addedCharacterMatchCompletion) { this.setAutoCompletionText("");
}
if (!addedCharacterMatchCompletion && !addedCharacterMatchPopupItem) { this.autocompletePopup.hidePopup();
} elseif (
!change.canceled &&
(completionText ||
addedCharacterMatchCompletion ||
addedCharacterMatchPopupItem)
) { // The completion text will be updated when the debounced autocomplete update action // is done, so in the meantime we set the pending value to pendingCompletionText. // See Bug 1595068 for more information. this.pendingCompletionText = completionText.substring(text.length); // And we update the preLabel of the matching autocomplete items that may be used // in the acceptProposedAutocompletion function. this.autocompletePopup.items.forEach(item => { if (item.label.startsWith(item.preLabel + addedText)) {
item.preLabel += addedText;
}
});
}
}
/** * Even handler for the "blur" event fired by codeMirror.
*/
_onEditorBlur(cm) { if (cm.somethingSelected()) { // If there's a selection when the input is blurred, then we remove it by setting // the cursor at the position that matches the start of the first selection. const [{ head }] = cm.listSelections();
cm.setCursor(head, { scroll: false });
}
}
/** * Fired after a key is handled through a key map. * * @param {CodeMirror} cm: codeMirror instance * @param {String} key: The key that was handled
*/
_onEditorKeyHandled(cm, key) { // The autocloseBracket addon handle closing brackets keys when they're typed, but // there's already an existing closing bracket. // ex: // 1. input is `foo(x|)` (where | represents the cursor) // 2. user types `)` // 3. input is now `foo(x)|` (i.e. the typed character wasn't inserted) // In such case, _onEditorBeforeChange isn't triggered, so we need to hide the popup // here. We can do that because this function won't be called when codeMirror _do_ // insert the closing char. const closingKeys = [`']'`, `')'`, "'}'"]; if (this.autocompletePopup.isOpen && closingKeys.includes(key)) { this.clearCompletion();
}
}
/** * Retrieve variable declared in the expression from the CodeMirror state, in order * to display them in the autocomplete popup.
*/
_getExpressionVariables() { const cm = this.editor.codeMirror; const { state } = cm.getTokenAt(cm.getCursor()); const variables = [];
if (state.context) { for (let c = state.context; c; c = c.prev) { for (let v = c.vars; v; v = v.next) { if (v.name) {
variables.push(v.name);
}
}
}
}
const keys = ["localVars", "globalVars"]; for (const key of keys) { if (state[key]) { for (let v = state[key]; v; v = v.next) { if (v.name) {
variables.push(v.name);
}
}
}
}
return variables;
}
/** * The editor "changes" event handler.
*/
_onEditorChanges(cm, changes) { const value = this._getValue();
if (this.lastInputValue !== value) { // We don't autocomplete if the changes were made by JsTerm (e.g. autocomplete was // accepted). const isJsTermChangeOnly = changes.every(
({ origin }) => origin === JSTERM_CODEMIRROR_ORIGIN
);
/** * Go up/down the history stack of input values. * * @param number direction * History navigation direction: HISTORY_BACK or HISTORY_FORWARD. * * @returns boolean * True if the input value changed, false otherwise.
*/
historyPeruse(direction) { const { history, updateHistoryPosition, getValueFromHistory } = this.props;
if (newInputValue != null) { this._setValue(newInputValue); returntrue;
}
returnfalse;
}
/** * Test for empty input. * * @return boolean
*/
hasEmptyInput() { returnthis._getValue() === "";
}
/** * Check if the caret is at a location that allows selecting the previous item * in history when the user presses the Up arrow key. * * @return boolean * True if the caret is at a location that allows selecting the * previous item in history when the user presses the Up arrow key, * otherwise false.
*/
canCaretGoPrevious() { if (!this.editor) { returnfalse;
}
/** * Check if the caret is at a location that allows selecting the next item in * history when the user presses the Down arrow key. * * @return boolean * True if the caret is at a location that allows selecting the next * item in history when the user presses the Down arrow key, otherwise * false.
*/
canCaretGoNext() { if (!this.editor) { returnfalse;
}
/** * Takes the data returned by the server and update the autocomplete popup state (i.e. * its visibility and items). * * @param {Object} data * The autocompletion data as returned by the webconsole actor's autocomplete * service. Should be of the following shape: * { * matches: {Array} array of the properties matching the input, * matchProp: {String} The string used to filter the properties, * isElementAccess: {Boolean} True when the input is an element access, * i.e. `document["addEve`. * } * @fires autocomplete-updated
*/
async updateAutocompletionPopup(data) { if (!this.editor) { return;
}
const items = matches.map(label => {
let preLabel = label.substring(0, matchProp.length); // If the user is performing an element access, and if they did not typed a quote, // then we need to adjust the preLabel to match the quote from the label + what // the user entered. if (isElementAccess && /^['"`]/.test(matchProp) === false) {
preLabel = label.substring(0, matchProp.length + 1);
} return { preLabel, label, isElementAccess };
});
if (items.length) { const { preLabel, label } = items[0];
let suffix = label.substring(preLabel.length); if (isElementAccess) { if (!matchProp) {
suffix = label;
} const inputAfterCursor = this._getValue().substring(
inputUntilCursor.length
); // If there's not a bracket after the cursor, add it to the completionText. if (!inputAfterCursor.trimLeft().startsWith("]")) {
suffix = suffix + "]";
}
} this.setAutoCompletionText(suffix);
}
const popup = this.autocompletePopup; // We don't want to trigger the onSelect callback since we already set the completion // text a few lines above.
popup.setItems(items, 0, {
preventSelectCallback: true,
});
const minimumAutoCompleteLength = 2;
// We want to show the autocomplete popup if: // - there are at least 2 matching results // - OR, if there's 1 result, but whose label does not start like the input (this can // happen with insensitive search: `num` will match `Number`). // - OR, if there's 1 result, but we can't show the completionText (because there's // some text after the cursor), unless the text in the popup is the same as the input. if (
items.length >= minimumAutoCompleteLength ||
(items.length === 1 && items[0].preLabel !== matchProp) ||
(items.length === 1 &&
!this.canDisplayAutoCompletionText() &&
items[0].label !== matchProp)
) { // We need to show the popup at the "." or "[". const xOffset = -1 * matchProp.length * this._inputCharWidth; const yOffset = 5; const popupAlignElement = this.props.serviceContainer.getJsTermTooltipAnchor(); this._openPopupPendingPromise = popup.openPopup(
popupAlignElement,
xOffset,
yOffset,
0,
{
preventSelectCallback: true,
}
);
await this._openPopupPendingPromise; this._openPopupPendingPromise = null;
} elseif (
items.length < minimumAutoCompleteLength &&
(popup.isOpen || this._openPopupPendingPromise)
) { if (this._openPopupPendingPromise) {
await this._openPopupPendingPromise;
}
popup.hidePopup();
}
// Eager evaluation results incorporate the current autocomplete item. We need to // trigger it here as well as in onAutocompleteSelect as we set the items with // preventSelectCallback (which means we won't trigger onAutocompleteSelect when the // popup is open). this.terminalInputChanged( this.getInputValueWithCompletionText().expression
);
// If the user is performing an element access, we need to check if we should add // starting and ending quotes, as well as a closing bracket. if (isElementAccess) { const inputBeforeCursor = this.getInputValueBeforeCursor(); if (inputBeforeCursor.trim().endsWith("[")) {
suffix = label;
}
const inputAfterCursor = this._getValue().substring(
inputBeforeCursor.length
); // If there's no closing bracket after the cursor, add it to the completionText. if (!inputAfterCursor.trimLeft().startsWith("]")) {
suffix = suffix + "]";
}
} this.setAutoCompletionText(suffix);
} else { this.setAutoCompletionText("");
} // Eager evaluation results incorporate the current autocomplete item. this.terminalInputChanged( this.getInputValueWithCompletionText().expression
);
}
/** * Clear the current completion information, cancel any pending autocompletion update * and close the autocomplete popup, if needed. * @fires autocomplete-updated
*/
clearCompletion() { this.autocompleteUpdate.cancel(); // Update Eager evaluation result as the completion text was removed. this.terminalInputChanged(this._getValue());
this.setAutoCompletionText("");
let onPopupClosed = Promise.resolve(); if (this.autocompletePopup) { this.autocompletePopup.clearItems();
if (this.autocompletePopup.isOpen || this._openPopupPendingPromise) {
onPopupClosed = this.autocompletePopup.once("popup-closed");
// If the code triggering the opening of the popup was already triggered but not yet // settled, then we need to wait until it's resolved in order to close the popup (See // Bug 1655406). if (this._openPopupPendingPromise) { this._openPopupPendingPromise.then(() => this.autocompletePopup.hidePopup()
);
}
if (completionText) { this.insertStringAtCursor(
completionText,
numberOfCharsToReplaceCharsBeforeCursor
);
/** * Returns an object containing the expression we would get if the user accepted the * current completion text. This is more than the current input + the completion text, * as there are special cases for element access and case-insensitive matches. * * @return {Object}: An object of the following shape: * - {String} expression: The complete expression * - {String} completionText: the completion text only, which should be used * with the next property * - {Integer} numberOfCharsToReplaceCharsBeforeCursor: The number of chars that * should be removed from the current input before the cursor to * cleanly apply the completionText. This is handy when we only want * to insert the completionText. * - {Integer} numberOfCharsToMoveTheCursorForward: The number of chars that the * cursor should be moved after the completion is done. This can * be useful for element access where there's already a closing * quote and/or bracket.
*/
getInputValueWithCompletionText() { const inputBeforeCursor = this.getInputValueBeforeCursor(); const inputAfterCursor = this._getValue().substring(
inputBeforeCursor.length
);
let completionText = this.getAutoCompletionText();
let numberOfCharsToReplaceCharsBeforeCursor;
let numberOfCharsToMoveTheCursorForward = 0;
// If the autocompletion popup is open, we always get the selected element from there, // since the autocompletion text might not be enough (e.g. `dOcUmEn` should // autocomplete to `document`, but the autocompletion text only shows `t`). if (this.autocompletePopup.isOpen && this.autocompletePopup.selectedItem) { const { selectedItem } = this.autocompletePopup; const { label, preLabel, isElementAccess } = selectedItem;
// If the user is performing an element access, we need to check if we should add // starting and ending quotes, as well as a closing bracket. if (isElementAccess) { const lastOpeningBracketIndex = inputBeforeCursor.lastIndexOf("["); if (lastOpeningBracketIndex > -1) {
numberOfCharsToReplaceCharsBeforeCursor = inputBeforeCursor.substring(
lastOpeningBracketIndex + 1
).length;
}
// If the autoclose bracket option is enabled, the input might be in a state where // there's already the closing quote and the closing bracket, e.g. // `document["activeEl|"]`, so we don't need to add // Let's retrieve the completionText last character, to see if it's a quote. const completionTextLastChar =
completionText[completionText.length - 1]; const endingQuote = [`"`, `'`, "`"].includes(completionTextLastChar)
? completionTextLastChar
: ""; if (
endingQuote &&
inputAfterCursor.trimLeft().startsWith(endingQuote)
) {
completionText = completionText.substring(
0,
completionText.length - 1
);
numberOfCharsToMoveTheCursorForward++;
}
// If there's not a closing bracket already, we add one. if (
!inputAfterCursor.trimLeft().match(new RegExp(`^${endingQuote}?]`))
) {
completionText = completionText + "]";
} else { // if there's already one, we want to move the cursor after the closing bracket.
numberOfCharsToMoveTheCursorForward++;
}
}
}
/** * Insert a string into the console at the cursor location, * moving the cursor to the end of the string. * * @param {string} str * @param {int} numberOfCharsToReplaceCharsBeforeCursor - defaults to 0
*/
insertStringAtCursor(str, numberOfCharsToReplaceCharsBeforeCursor = 0) { if (!this.editor) { return;
}
/** * Set the autocompletion text of the input. * * @param string suffix * The proposed suffix for the input value.
*/
setAutoCompletionText(suffix) { if (!this.editor) { return;
}
this.pendingCompletionText = null;
if (suffix && !this.canDisplayAutoCompletionText()) {
suffix = "";
}
/** * Indicate if the input has an autocompletion suggestion, i.e. that there is either * something in the autocompletion text or that there's a selected item in the * autocomplete popup.
*/
hasAutocompletionSuggestion() { // We can have cases where the popup is opened but we can't display the autocompletion // text. return ( this.getAutoCompletionText() ||
(this.autocompletePopup.isOpen &&
Number.isInteger(this.autocompletePopup.selectedIndex) && this.autocompletePopup.selectedIndex > -1)
);
}
/** * Returns a boolean indicating if we can display an autocompletion text in the input, * i.e. if there is no characters displayed on the same line of the cursor and after it.
*/
canDisplayAutoCompletionText() { if (!this.editor) { returnfalse;
}
/** * Calculates and returns the width of a single character of the input box. * This will be used in opening the popup at the correct offset. * * @returns {Number|null}: Width off the "x" char, or null if the input does not exist.
*/
_getInputCharWidth() { returnthis.editor ? this.editor.defaultCharWidth() : null;
}
renderEvaluationContextSelector() { if (this.props.editorMode || !this.props.showEvaluationContextSelector) { returnnull;
}
return EvaluationContextSelector(this.props);
}
renderEditorOnboarding() { if (!this.props.showEditorOnboarding) { returnnull;
}
// We deliberately use getStr, and not getFormatStr, because we want keyboard // shortcuts to be wrapped in their own span. const label = l10n.getStr("webconsole.input.editor.onboarding.label");
let [prefix, suffix] = label.split("%1$S");
suffix = suffix.split("%2$S");
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung ist noch experimentell.