/* 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/. */ "use strict"; const { debounce } = require("resource://devtools/shared/debounce.js"); const isMacOS = Services.appinfo.OS === "Darwin"; loader.lazyRequireGetter(this, "Debugger", "Debugger"); loader.lazyRequireGetter( this, "EventEmitter", "resource://devtools/shared/event-emitter.js" ); loader.lazyRequireGetter( this, "AutocompletePopup", "resource://devtools/client/shared/autocomplete-popup.js" ); loader.lazyRequireGetter( this, "PropTypes", "resource://devtools/client/shared/vendor/react-prop-types.js" ); loader.lazyRequireGetter( this, "KeyCodes", "resource://devtools/client/shared/keycodes.js", true ); loader.lazyRequireGetter( this, "Editor", "resource://devtools/client/shared/sourceeditor/editor.js" ); loader.lazyRequireGetter( this, "getFocusableElements", "resource://devtools/client/shared/focus.js", true ); loader.lazyRequireGetter( this, "l10n", "resource://devtools/client/webconsole/utils/messages.js", true ); loader.lazyRequireGetter( this, "saveAs", "resource://devtools/shared/DevToolsUtils.js", true ); loader.lazyRequireGetter( this, "beautify", "resource://devtools/shared/jsbeautify/beautify.js" ); // React & Redux const { Component, createFactory, } = require("resource://devtools/client/shared/vendor/react.js"); const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); const { connect, } = require("resource://devtools/client/shared/vendor/react-redux.js"); // History Modules const { getHistory, getHistoryValue, } = require("resource://devtools/client/webconsole/selectors/history.js"); const { getAutocompleteState, } = require("resource://devtools/client/webconsole/selectors/autocomplete.js"); const actions = require("resource://devtools/client/webconsole/actions/index.js"); const EvaluationContextSelector = createFactory( require("resource://devtools/client/webconsole/components/Input/EvaluationContextSelector.js") ); // 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, }; } constructor(props) { super(props); const { webConsoleUI } = props; this.webConsoleUI = webConsoleUI; this.hudId = this.webConsoleUI.hudId; this._onEditorChanges = this._onEditorChanges.bind(this); this._onEditorBeforeChange = this._onEditorBeforeChange.bind(this); this._onEditorKeyHandled = this._onEditorKeyHandled.bind(this); this.onContextMenu = this.onContextMenu.bind(this); this.imperativeUpdate = this.imperativeUpdate.bind(this); // 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; /** * Last input value. * @type string */ this.lastInputValue = ""; this.autocompletePopup = null; EventEmitter.decorate(this); webConsoleUI.jsterm = this; } componentDidMount() { if (this.props.editorMode) { this.setEditorWidth(this.props.editorWidth); } const autocompleteOptions = { onSelect: this.onAutocompleteSelect.bind(this), onClick: this.acceptProposedCompletion.bind(this), listId: "webConsole_autocompletePopupListBox", position: this.props.autocompletePopupPosition, autoSelect: true, useXulWrapper: true, }; 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(); return null; } if (this.props.editorMode === false && this.canCaretGoPrevious()) { inputUpdated = this.historyPeruse(HISTORY_BACK); } return inputUpdated ? null : "CodeMirror.Pass"; }; const onArrowDown = () => { let inputUpdated; if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectNextItem(); return null; } if (this.props.editorMode === false && this.canCaretGoNext()) { inputUpdated = this.historyPeruse(HISTORY_FORWARD); } return inputUpdated ? null : "CodeMirror.Pass"; }; const onArrowLeft = () => { if (this.autocompletePopup.isOpen || this.getAutoCompletionText()) { this.clearCompletion(); } return "CodeMirror.Pass"; }; const onArrowRight = () => { // We only want to complete on Right arrow if the completion text is // displayed. if (this.getAutoCompletionText()) { this.acceptProposedCompletion(); return null; } this.clearCompletion(); return "CodeMirror.Pass"; }; const onCtrlCmdEnter = () => { if (this.hasAutocompletionSuggestion()) { return this.acceptProposedCompletion(); } this._execute(); return null; }; this.editor = new Editor({ autofocus: true, enableCodeFolding: this.props.editorMode, lineNumbers: this.props.editorMode, lineWrapping: true, mode: { name: "javascript", globalVars: true, }, styleActiveLine: false, tabIndex: "0", viewportMargin: Infinity, disableSearchAddon: true, extraKeys: { Enter: () => { // No need to handle shift + Enter as it's natively handled by CodeMirror. const hasSuggestion = this.hasAutocompletionSuggestion(); if ( !hasSuggestion && !Debugger.isCompilableUnit(this._getValue()) ) { // incomplete statement return "CodeMirror.Pass"; } if (hasSuggestion) { return this.acceptProposedCompletion(); } if (!this.props.editorMode) { this._execute(); return null; } return "CodeMirror.Pass"; }, "Cmd-Enter": onCtrlCmdEnter, "Ctrl-Enter": onCtrlCmdEnter, [Editor.accel("S")]: () => { const value = this._getValue(); if (!value) { return null; } const date = new Date(); const suggestedName = `console-input-${date.getFullYear()}-` + `${date.getMonth() + 1}-${date.getDate()}_${date.getHours()}-` + `${date.getMinutes()}-${date.getSeconds()}.js`; const data = new TextEncoder().encode(value); return saveAs(window, data, suggestedName, [ { pattern: "*.js", label: l10n.getStr("webconsole.input.openJavaScriptFileFilter"), }, ]); }, [Editor.accel("O")]: async () => this._openFile(), Tab: () => { if (this.hasEmptyInput()) { this.editor.codeMirror.getInputField().blur(); return false; } if ( this.props.autocompleteData && this.props.autocompleteData.getterPath ) { this.props.autocompleteUpdate( true, this.props.autocompleteData.getterPath ); return false; } const isSomethingSelected = this.editor.somethingSelected(); const hasSuggestion = this.hasAutocompletionSuggestion(); if (hasSuggestion && !isSomethingSelected) { this.acceptProposedCompletion(); return false; } if (!isSomethingSelected) { this.insertStringAtCursor("\t"); return false; } // Something is selected, let the editor handle the indent. return true; }, "Shift-Tab": () => { if (this.hasEmptyInput()) { this.focusPreviousElement(); return false; } const hasSuggestion = this.hasAutocompletionSuggestion(); if (hasSuggestion) { return false; } return "CodeMirror.Pass"; }, Up: onArrowUp, "Cmd-Up": onArrowUp, Down: onArrowDown, "Cmd-Down": onArrowDown, 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, Right: onArrowRight, "Ctrl-Right": onArrowRight, "Cmd-Right": onArrowRight, "Alt-Right": onArrowRight, "Ctrl-N": () => { // Control-N differs from down arrow: it ignores autocomplete state. // Note that we preserve the default 'down' navigation within // multiline text. if ( Services.appinfo.OS === "Darwin" && this.props.editorMode === false && this.canCaretGoNext() && this.historyPeruse(HISTORY_FORWARD) ) { return null; } this.clearCompletion(); return "CodeMirror.Pass"; }, "Ctrl-P": () => { // Control-P differs from up arrow: it ignores autocomplete state. // Note that we preserve the default 'up' navigation within // multiline text. if ( Services.appinfo.OS === "Darwin" && this.props.editorMode === false && this.canCaretGoPrevious() && this.historyPeruse(HISTORY_BACK) ) { return null; } this.clearCompletion(); return "CodeMirror.Pass"; }, PageUp: () => { if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectPreviousPageItem(); } else { const { outputScroller } = this.webConsoleUI; const { scrollTop, clientHeight } = outputScroller; outputScroller.scrollTop = Math.max(0, scrollTop - clientHeight); } return null; }, PageDown: () => { if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectNextPageItem(); } else { const { outputScroller } = this.webConsoleUI; const { scrollTop, scrollHeight, clientHeight } = outputScroller; outputScroller.scrollTop = Math.min( scrollHeight, scrollTop + clientHeight ); } return null; }, Home: () => { if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectItemAtIndex(0); return null; } if (!this._getValue()) { this.webConsoleUI.outputScroller.scrollTop = 0; return null; } if (this.getAutoCompletionText()) { this.clearCompletion(); } return "CodeMirror.Pass"; }, End: () => { if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectItemAtIndex( this.autocompletePopup.itemCount - 1 ); return null; } if (!this._getValue()) { const { outputScroller } = this.webConsoleUI; outputScroller.scrollTop = outputScroller.scrollHeight; return null; } if (this.getAutoCompletionText()) { this.clearCompletion(); } return "CodeMirror.Pass"; }, "Ctrl-Space": () => { if (!this.autocompletePopup.isOpen) { this.props.autocompleteUpdate( true, null, this._getExpressionVariables() ); return null; } return "CodeMirror.Pass"; }, Esc: false, // Don't handle Ctrl/Cmd + F so it can be listened by a parent node [Editor.accel("F")]: false, }, }); this.editor.on("changes", this._onEditorChanges); this.editor.on("beforeChange", this._onEditorBeforeChange); this.editor.on("blur", this._onEditorBlur); this.editor.on("keyHandled", this._onEditorKeyHandled); this.editor.appendToLocalElement(this.node); const cm = this.editor.codeMirror; cm.on("paste", (_, event) => this.props.onPaste(event)); cm.on("drop", (_, event) => this.props.onPaste(event)); this.node.addEventListener("keydown", event => { if (event.keyCode === KeyCodes.DOM_VK_ESCAPE) { if (this.autocompletePopup.isOpen) { this.clearCompletion(); event.preventDefault(); event.stopPropagation(); } if ( this.props.autocompleteData && this.props.autocompleteData.getterPath ) { this.props.autocompleteClear(); event.preventDefault(); event.stopPropagation(); } } }); 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); } } // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 UNSAFE_componentWillReceiveProps(nextProps) { this.imperativeUpdate(nextProps); } shouldComponentUpdate(nextProps) { return ( this.props.showEditorOnboarding !== nextProps.showEditorOnboarding || this.props.editorMode !== nextProps.editorMode ); } /** * 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.autocompleteData !== this.props.autocompleteData && nextProps.autocompleteData.pendingRequestId === null ) { this.updateAutocompletionPopup(nextProps.autocompleteData); } if (nextProps.editorMode !== this.props.editorMode) { if (this.editor) { this.editor.setOption("lineNumbers", nextProps.editorMode); this.editor.setOption("enableCodeFolding", nextProps.editorMode); } if (nextProps.editorMode && nextProps.editorWidth) { this.setEditorWidth(nextProps.editorWidth); } else { this.setEditorWidth(null); } if (this.autocompletePopup.isOpen) { this.autocompletePopup.hidePopup(); } } if ( nextProps.autocompletePopupPosition !== this.props.autocompletePopupPosition && this.autocompletePopup ) { this.autocompletePopup.position = nextProps.autocompletePopupPosition; } 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; } if (editorWidth) { this.node.style.width = `${editorWidth}px`; } else { this.node.style.removeProperty("width"); } } focus() { if (this.editor) { this.editor.focus(); } } focusPreviousElement() { const inputField = this.editor.codeMirror.getInputField(); const findPreviousFocusableElement = el => { if (!el || !el.querySelectorAll) { return null; } // 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); if (items.length === 0 || (inputIndex > -1 && items.length === 1)) { return findPreviousFocusableElement(el.parentNode); } const index = inputIndex > 0 ? inputIndex - 1 : items.length - 1; return items[index]; }; 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() { return this.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, l10n.getStr("webconsole.input.openJavaScriptFile"), Ci.nsIFilePicker.modeOpen ); // Append file filters fp.appendFilter( l10n.getStr("webconsole.input.openJavaScriptFileFilter"), "*.js" ); function readFile(file) { return new Promise(resolve => { IOUtils.read(file.path).then(data => { const decoder = new TextDecoder(); resolve(decoder.decode(data)); }); }); } const content = await new Promise(resolve => { fp.open(rv => { if (rv == Ci.nsIFilePicker.returnOK) { const file = Cc["@mozilla.org/file/local;1"].createInstance( Ci.nsIFile ); file.initWithPath(fp.file.path); readFile(file).then(resolve); } }); }); this._setValue(content); } getSelectionStart() { return this.getInputValueBeforeCursor().length; } getSelectedText() { return this.editor.getSelection(); } /** * 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; } const addedText = text.join(""); const completionText = this.getAutoCompletionText(); const addedCharacterMatchCompletion = isAddedText && completionText.startsWith(addedText); const addedCharacterMatchPopupItem = isAddedText && this.autocompletePopup.items.some(({ preLabel, label }) => label.startsWith(preLabel + addedText) ); const nextSelectedAutocompleteItemIndex = addedCharacterMatchPopupItem && this.autocompletePopup.items.findIndex(({ preLabel, label }) => label.startsWith(preLabel + addedText) ); if (addedCharacterMatchPopupItem) { this.autocompletePopup.selectItemAtIndex( nextSelectedAutocompleteItemIndex, { preventSelectCallback: true } ); } if (!completionText || change.canceled || !addedCharacterMatchCompletion) { this.setAutoCompletionText(""); } if (!addedCharacterMatchCompletion && !addedCharacterMatchPopupItem) { this.autocompletePopup.hidePopup(); } else if ( !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 * @param {Event} e: The keypress event */ _onEditorKeyHandled(cm, key, e) { // 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 ); if ( !isJsTermChangeOnly && (this.props.autocomplete || this.hasAutocompletionSuggestion()) ) { this.autocompleteUpdate(false, null, this._getExpressionVariables()); } this.lastInputValue = value; this.terminalInputChanged(value); } } /** * 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 (!history.entries.length) { return false; } const newInputValue = getValueFromHistory(direction); const expression = this._getValue(); updateHistoryPosition(direction, expression); if (newInputValue != null) { this._setValue(newInputValue); return true; } return false; } /** * Test for empty input. * * @return boolean */ hasEmptyInput() { return this._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) { return false; } const inputValue = this._getValue(); const { line, ch } = this.editor.getCursor(); return (line === 0 && ch === 0) || (line === 0 && ch === inputValue.length); } /** * 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) { return false; } const inputValue = this._getValue(); const multiline = /[\r\n]/.test(inputValue); const { line, ch } = this.editor.getCursor(); return ( (!multiline && ch === 0) || this.editor.getDoc().getRange({ line: 0, ch: 0 }, { line, ch }).length === inputValue.length ); } /** * 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 { matches, matchProp, isElementAccess } = data; if (!matches.length) { this.clearCompletion(); return; } const inputUntilCursor = this.getInputValueBeforeCursor(); 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; } else if ( 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 ); this.emit("autocomplete-updated"); } onAutocompleteSelect() { const { selectedItem } = this.autocompletePopup; if (selectedItem) { const { preLabel, label, isElementAccess } = selectedItem; let suffix = label.substring(preLabel.length); // 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 (this._openPopupPendingPromise) { this._openPopupPendingPromise.then(() => this.autocompletePopup.hidePopup() ); } else { this.autocompletePopup.hidePopup(); } onPopupClosed.then(() => this.focus()); } } onPopupClosed.then(() => this.emit("autocomplete-updated")); } /** * Accept the proposed input completion. */ acceptProposedCompletion() { const { completionText, numberOfCharsToMoveTheCursorForward, numberOfCharsToReplaceCharsBeforeCursor, } = this.getInputValueWithCompletionText(); this.autocompleteUpdate.cancel(); this.props.autocompleteClear(); // 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 ); if (numberOfCharsToMoveTheCursorForward) { const { line, ch } = this.editor.getCursor(); this.editor.setCursor({ line, ch: ch + numberOfCharsToMoveTheCursorForward, }); } } } /** * 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; completionText = label; numberOfCharsToReplaceCharsBeforeCursor = preLabel.length; // 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++; } } } const expression = inputBeforeCursor.substring( 0, inputBeforeCursor.length - (numberOfCharsToReplaceCharsBeforeCursor || 0) ) + completionText + inputAfterCursor; return { completionText, expression, numberOfCharsToMoveTheCursorForward, numberOfCharsToReplaceCharsBeforeCursor, }; } getInputValueBeforeCursor() { return this.editor ? this.editor .getDoc() .getRange({ line: 0, ch: 0 }, this.editor.getCursor()) : null; } /** * 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; } const cursor = this.editor.getCursor(); const from = { line: cursor.line, ch: cursor.ch - numberOfCharsToReplaceCharsBeforeCursor, }; this.editor .getDoc() .replaceRange(str, from, cursor, JSTERM_CODEMIRROR_ORIGIN); } /** * 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 = ""; } this.editor.setAutoCompletionText(suffix); } getAutoCompletionText() { const renderedCompletionText = this.editor && this.editor.getAutoCompletionText(); return typeof this.pendingCompletionText === "string" ? this.pendingCompletionText : renderedCompletionText; } /** * 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) { return false; } const { ch, line } = this.editor.getCursor(); const lineContent = this.editor.getLine(line); const textAfterCursor = lineContent.substring(ch); return textAfterCursor === ""; } /** * 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() { return this.editor ? this.editor.defaultCharWidth() : null; } onContextMenu(e) { this.props.serviceContainer.openEditContextMenu(e); } destroy() { this.autocompleteUpdate.cancel(); this.terminalInputChanged.cancel(); this._openPopupPendingPromise = null; if (this.autocompletePopup) { this.autocompletePopup.destroy(); this.autocompletePopup = null; } if (this.editor) { this.resizeObserver.disconnect(); this.editor.destroy(); this.editor = null; } this.webConsoleUI = null; } renderOpenEditorButton() { if (this.props.editorMode) { return null; } return dom.button({ className: "devtools-button webconsole-input-openEditorButton" + (this.props.showEditorOnboarding ? " devtools-feature-callout" : ""), title: l10n.getFormatStr("webconsole.input.openEditorButton.tooltip2", [ isMacOS ? "Cmd + B" : "Ctrl + B", ]), onClick: this.props.editorToggle, }); } renderEvaluationContextSelector() { if (this.props.editorMode || !this.props.showEvaluationContextSelector) { return null; } return EvaluationContextSelector(this.props); } renderEditorOnboarding() { if (!this.props.showEditorOnboarding) { return null; } // 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"); const enterString = l10n.getStr("webconsole.enterKey"); return dom.header( { className: "editor-onboarding" }, dom.img({ className: "editor-onboarding-fox", src: "chrome://devtools/skin/images/fox-smiling.svg", }), dom.p( {}, prefix, dom.span({ className: "editor-onboarding-shortcut" }, enterString), suffix[0], dom.span({ className: "editor-onboarding-shortcut" }, [ isMacOS ? `Cmd+${enterString}` : `Ctrl+${enterString}`, ]), suffix[1] ), dom.button( { className: "editor-onboarding-dismiss-button", onClick: () => this.props.editorOnboardingDismiss(), }, l10n.getStr("webconsole.input.editor.onboarding.dismiss.label") ) ); } render() { if (!this.props.inputEnabled) { return null; } return dom.div( { className: "jsterm-input-container devtools-input", key: "jsterm-container", "aria-live": "off", tabIndex: -1, onContextMenu: this.onContextMenu, ref: node => { this.node = node; }, }, dom.div( { className: "webconsole-input-buttons" }, this.renderEvaluationContextSelector(), this.renderOpenEditorButton() ), this.renderEditorOnboarding() ); } } // Redux connect function mapStateToProps(state) { return { history: getHistory(state), getValueFromHistory: direction => getHistoryValue(state, direction), autocompleteData: getAutocompleteState(state), showEditorOnboarding: state.ui.showEditorOnboarding, showEvaluationContextSelector: state.ui.showEvaluationContextSelector, autocompletePopupPosition: state.prefs.eagerEvaluation ? "top" : "bottom", editorPrettifiedAt: state.ui.editorPrettifiedAt, }; } function mapDispatchToProps(dispatch) { return { updateHistoryPosition: (direction, expression) => dispatch(actions.updateHistoryPosition(direction, expression)), autocompleteUpdate: (force, getterPath, expressionVars) => dispatch(actions.autocompleteUpdate(force, getterPath, expressionVars)), autocompleteClear: () => dispatch(actions.autocompleteClear()), evaluateExpression: expression => dispatch(actions.evaluateExpression(expression)), editorToggle: () => dispatch(actions.editorToggle()), editorOnboardingDismiss: () => dispatch(actions.editorOnboardingDismiss()), terminalInputChanged: value => dispatch(actions.terminalInputChanged(value)), }; } module.exports = connect(mapStateToProps, mapDispatchToProps)(JSTerm);