/* 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 {Utils: WebConsoleUtils} = require("devtools/client/webconsole/utils"); const Services = require("Services"); loader.lazyServiceGetter(this, "clipboardHelper", "@mozilla.org/widget/clipboardhelper;1", "nsIClipboardHelper"); loader.lazyRequireGetter(this, "Debugger", "Debugger"); loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); loader.lazyRequireGetter(this, "AutocompletePopup", "devtools/client/shared/autocomplete-popup"); loader.lazyRequireGetter(this, "PropTypes", "devtools/client/shared/vendor/react-prop-types"); loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true); loader.lazyRequireGetter(this, "KeyCodes", "devtools/client/shared/keycodes", true); loader.lazyRequireGetter(this, "Editor", "devtools/client/sourceeditor/editor"); loader.lazyRequireGetter(this, "Telemetry", "devtools/client/shared/telemetry"); loader.lazyRequireGetter(this, "processScreenshot", "devtools/shared/webconsole/screenshot-helper"); const l10n = require("devtools/client/webconsole/webconsole-l10n"); const HELP_URL = "https://developer.mozilla.org/docs/Tools/Web_Console/Helpers"; function gSequenceId() { return gSequenceId.n++; } gSequenceId.n = 0; // React & Redux const { Component } = require("devtools/client/shared/vendor/react"); const dom = require("devtools/client/shared/vendor/react-dom-factories"); const { connect } = require("devtools/client/shared/vendor/react-redux"); // History Modules const { getHistory, getHistoryValue } = require("devtools/client/webconsole/selectors/history"); const historyActions = require("devtools/client/webconsole/actions/history"); // Constants used for defining the direction of JSTerm input history navigation. const { HISTORY_BACK, HISTORY_FORWARD } = require("devtools/client/webconsole/constants"); /** * 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. * * @constructor * @param object webConsoleFrame * The WebConsoleFrame object that owns this JSTerm instance. */ class JSTerm extends Component { static get propTypes() { return { // Append new executed expression into history list (action). appendToHistory: PropTypes.func.isRequired, // Remove all entries from the history list (action). clearHistory: PropTypes.func.isRequired, // 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. hud: 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, codeMirrorEnabled: PropTypes.bool, // Update position in the history after executing an expression (action). updateHistoryPosition: PropTypes.func.isRequired, }; } constructor(props) { super(props); const { hud, } = props; this.hud = hud; this.hudId = this.hud.hudId; this._keyPress = this._keyPress.bind(this); this._inputEventHandler = this._inputEventHandler.bind(this); this._blurEventHandler = this._blurEventHandler.bind(this); this.onContextMenu = this.onContextMenu.bind(this); this.SELECTED_FRAME = -1; /** * Array that caches the user input suggestions received from the server. * @private * @type array */ this._autocompleteCache = null; /** * The input that caused the last request to the server, whose response is * cached in the _autocompleteCache array. * @private * @type string */ this._autocompleteQuery = null; /** * The frameActorId used in the last autocomplete query. Whenever this changes * the autocomplete cache must be invalidated. * @private * @type string */ this._lastFrameActorId = null; /** * Last input value. * @type string */ this.lastInputValue = ""; this.currentAutoCompletionRequestId = null; this.autocompletePopup = null; this.inputNode = null; this.completeNode = null; this._telemetry = new Telemetry(); EventEmitter.decorate(this); hud.jsterm = this; } componentDidMount() { const autocompleteOptions = { onSelect: this.onAutocompleteSelect.bind(this), onClick: this.acceptProposedCompletion.bind(this), listId: "webConsole_autocompletePopupListBox", position: "top", autoSelect: true }; const doc = this.hud.document; const toolbox = gDevTools.getToolbox(this.hud.owner.target); 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.props.codeMirrorEnabled) { if (this.node) { const onArrowUp = () => { let inputUpdated; if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectPreviousItem(); return null; } if (this.canCaretGoPrevious()) { inputUpdated = this.historyPeruse(HISTORY_BACK); } if (!inputUpdated) { return "CodeMirror.Pass"; } return null; }; const onArrowDown = () => { let inputUpdated; if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectNextItem(); return null; } if (this.canCaretGoNext()) { inputUpdated = this.historyPeruse(HISTORY_FORWARD); } if (!inputUpdated) { return "CodeMirror.Pass"; } return null; }; this.editor = new Editor({ autofocus: true, enableCodeFolding: false, gutters: [], lineWrapping: true, mode: Editor.modes.js, styleActiveLine: false, tabIndex: "0", viewportMargin: Infinity, extraKeys: { "Enter": () => { // No need to handle shift + Enter as it's natively handled by CodeMirror. const hasSuggestion = this.hasAutocompletionSuggestion(); if (!hasSuggestion && !Debugger.isCompilableUnit(this.getInputValue())) { // incomplete statement return "CodeMirror.Pass"; } if (hasSuggestion) { return this.acceptProposedCompletion(); } this.execute(); return null; }, "Tab": () => { if (this.hasEmptyInput()) { this.editor.codeMirror.getInputField().blur(); 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; }, "Up": onArrowUp, "Cmd-Up": onArrowUp, "Down": onArrowDown, "Cmd-Down": onArrowDown, "Left": () => { if (this.autocompletePopup.isOpen || this.getAutoCompletionText()) { this.clearCompletion(); } return "CodeMirror.Pass"; }, "Right": () => { // 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"; }, "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.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.canCaretGoPrevious() && this.historyPeruse(HISTORY_BACK) ) { return null; } this.clearCompletion(); return "CodeMirror.Pass"; }, "PageUp": () => { if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectPreviousPageItem(); } else { this.hud.outputScroller.scrollTop = Math.max( 0, this.hud.outputScroller.scrollTop - this.hud.outputScroller.clientHeight ); } return null; }, "PageDown": () => { if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectNextPageItem(); } else { this.hud.outputScroller.scrollTop = Math.min( this.hud.outputScroller.scrollHeight, this.hud.outputScroller.scrollTop + this.hud.outputScroller.clientHeight ); } return null; }, "Home": () => { if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectedIndex = 0; return null; } if (!this.getInputValue()) { this.hud.outputScroller.scrollTop = 0; return null; } return "CodeMirror.Pass"; }, "End": () => { if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectedIndex = this.autocompletePopup.itemCount - 1; return null; } if (!this.getInputValue()) { this.hud.outputScroller.scrollTop = this.hud.outputScroller.scrollHeight; return null; } return "CodeMirror.Pass"; }, "Esc": false, "Cmd-F": false, "Ctrl-F": false, } }); this.editor.on("changes", this._inputEventHandler); 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 && this.autocompletePopup.isOpen) { this.clearCompletion(); event.preventDefault(); event.stopPropagation(); } }); } } else if (this.inputNode) { this.inputNode.addEventListener("keypress", this._keyPress); this.inputNode.addEventListener("input", this._inputEventHandler); this.inputNode.addEventListener("keyup", this._inputEventHandler); this.focus(); } this.inputBorderSize = this.inputNode ? this.inputNode.getBoundingClientRect().height - this.inputNode.clientHeight : 0; // Update the character and chevron width needed for the popup offset calculations. this._inputCharWidth = this._getInputCharWidth(); this._chevronWidth = this.editor ? null : this._getChevronWidth(); this.hud.window.addEventListener("blur", this._blurEventHandler); this.lastInputValue && this.setInputValue(this.lastInputValue); } shouldComponentUpdate(nextProps, nextState) { // XXX: For now, everything is handled in an imperative way and we // only want React to do the initial rendering of the component. // This should be modified when the actual refactoring will take place. return false; } /** * Getter for the element that holds the messages we display. * @type Element */ get outputNode() { return this.hud.outputNode; } /** * Getter for the debugger WebConsoleClient. * @type object */ get webConsoleClient() { return this.hud.webConsoleClient; } focus() { if (this.editor) { this.editor.focus(); } else if (this.inputNode && !this.inputNode.getAttribute("focused")) { this.inputNode.focus(); } } /** * The JavaScript evaluation response handler. * * @private * @param object response * The message received from the server. */ async _executeResultCallback(response) { if (!this.hud) { return null; } if (response.error) { console.error("Evaluation error " + response.error + ": " + response.message); return null; } let errorMessage = response.exceptionMessage; // Wrap thrown strings in Error objects, so `throw "foo"` outputs "Error: foo" if (typeof response.exception === "string") { errorMessage = new Error(errorMessage).toString(); } const result = response.result; const helperResult = response.helperResult; const helperHasRawOutput = !!(helperResult || {}).rawOutput; if (helperResult && helperResult.type) { switch (helperResult.type) { case "clearOutput": this.hud.clearOutput(); break; case "clearHistory": this.props.clearHistory(); break; case "inspectObject": this.inspectObjectActor(helperResult.object); break; case "error": try { errorMessage = l10n.getStr(helperResult.message); } catch (ex) { errorMessage = helperResult.message; } break; case "help": this.hud.owner.openLink(HELP_URL); break; case "copyValueToClipboard": clipboardHelper.copyString(helperResult.value); break; case "screenshotOutput": const { args, value } = helperResult; const results = await processScreenshot(this.hud.window, args, value); this.screenshotNotify(results); // early return as screenshot notify has dispatched all necessary messages return null; } } // Hide undefined results coming from JSTerm helper functions. if (!errorMessage && result && typeof result == "object" && result.type == "undefined" && helperResult && !helperHasRawOutput) { return null; } if (this.hud.consoleOutput) { return this.hud.consoleOutput.dispatchMessageAdd(response, true); } return null; } inspectObjectActor(objectActor) { this.hud.consoleOutput.dispatchMessageAdd({ helperResult: { type: "inspectObject", object: objectActor } }, true); return this.hud.consoleOutput; } screenshotNotify(results) { const wrappedResults = results.map(message => ({ message, type: "logMessage" })); this.hud.consoleOutput.dispatchMessagesAdd(wrappedResults); } /** * Execute a string. Execution happens asynchronously in the content process. * * @param {String} executeString * The string you want to execute. If this is not provided, the current * user input is used - taken from |this.getInputValue()|. * @returns {Promise} * Resolves with the message once the result is displayed. */ async execute(executeString) { // attempt to execute the content of the inputNode executeString = executeString || this.getInputValue(); if (!executeString) { return null; } // Append executed expression into the history list. this.props.appendToHistory(executeString); WebConsoleUtils.usageCount++; this.setInputValue(""); this.clearCompletion(); let selectedNodeActor = null; const inspectorSelection = this.hud.owner.getInspectorSelection(); if (inspectorSelection && inspectorSelection.nodeFront) { selectedNodeActor = inspectorSelection.nodeFront.actorID; } const { ConsoleCommand } = require("devtools/client/webconsole/types"); const cmdMessage = new ConsoleCommand({ messageText: executeString, }); this.hud.proxy.dispatchMessageAdd(cmdMessage); const options = { frame: this.SELECTED_FRAME, selectedNodeActor, }; const mappedString = await this.hud.owner.getMappedExpression(executeString); // Even if requestEvaluation rejects (because of webConsoleClient.evaluateJSAsync), // we still need to pass the error response to executeResultCallback. const onEvaluated = this.requestEvaluation(mappedString, options) .then(res => res, res => res); const response = await onEvaluated; return this._executeResultCallback(response); } /** * Request a JavaScript string evaluation from the server. * * @param string str * String to execute. * @param object [options] * Options for evaluation: * - bindObjectActor: tells the ObjectActor ID for which you want to do * the evaluation. The Debugger.Object of the OA will be bound to * |_self| during evaluation, such that it's usable in the string you * execute. * - frame: tells the stackframe depth to evaluate the string in. If * the jsdebugger is paused, you can pick the stackframe to be used for * evaluation. Use |this.SELECTED_FRAME| to always pick th; * user-selected stackframe. * If you do not provide a |frame| the string will be evaluated in the * global content window. * - selectedNodeActor: tells the NodeActor ID of the current selection * in the Inspector, if such a selection exists. This is used by * helper functions that can evaluate on the current selection. * @return object * A promise object that is resolved when the server response is * received. */ requestEvaluation(str, options = {}) { // Send telemetry event. If we are in the browser toolbox we send -1 as the // toolbox session id. this.props.serviceContainer.recordTelemetryEvent("execute_js", { "lines": str.split(/\n/).length }); let frameActor = null; if ("frame" in options) { frameActor = this.getFrameActor(options.frame); } const evalOptions = { bindObjectActor: options.bindObjectActor, frameActor, selectedNodeActor: options.selectedNodeActor, selectedObjectActor: options.selectedObjectActor, }; return this.webConsoleClient.evaluateJSAsync(str, null, evalOptions); } /** * Copy the object/variable by invoking the server * which invokes the `copy(variable)` command and makes it * available in the clipboard * @param evalString - string which has the evaluation string to be copied * @param options - object - Options for evaluation * @return object * A promise object that is resolved when the server response is * received. */ copyObject(evalString, evalOptions) { return this.webConsoleClient.evaluateJSAsync(`copy(${evalString})`, null, evalOptions); } /** * Retrieve the FrameActor ID given a frame depth. * * @param number frame * Frame depth. * @return string|null * The FrameActor ID for the given frame depth. */ getFrameActor(frame) { const state = this.hud.owner.getDebuggerFrames(); if (!state) { return null; } let grip; if (frame == this.SELECTED_FRAME) { grip = state.frames[state.selected]; } else { grip = state.frames[frame]; } return grip ? grip.actor : null; } /** * Updates the size of the input field (command line) to fit its contents. * * @returns void */ resizeInput() { if (this.props.codeMirrorEnabled) { return; } if (!this.inputNode) { return; } const inputNode = this.inputNode; // Reset the height so that scrollHeight will reflect the natural height of // the contents of the input field. inputNode.style.height = "auto"; // Now resize the input field to fit its contents. const scrollHeight = inputNode.scrollHeight; if (scrollHeight > 0) { inputNode.style.height = (scrollHeight + this.inputBorderSize) + "px"; } } /** * Sets the value of the input field (command line), and resizes the field to * fit its contents. This method is preferred over setting "inputNode.value" * directly, because it correctly resizes the field. * * @param string newValue * The new value to set. * @returns void */ setInputValue(newValue = "") { if (this.props.codeMirrorEnabled) { 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(); }); } } else { if (!this.inputNode) { return; } this.inputNode.value = newValue; this.completeNode.value = ""; } this.lastInputValue = newValue; this.resizeInput(); this.emit("set-input-value"); } /** * Gets the value from the input field * @returns string */ getInputValue() { if (this.props.codeMirrorEnabled) { return this.editor ? this.editor.getText() || "" : ""; } return this.inputNode ? this.inputNode.value || "" : ""; } getSelectionStart() { if (this.props.codeMirrorEnabled) { return this.getInputValueBeforeCursor().length; } return this.inputNode ? this.inputNode.selectionStart : null; } /** * The inputNode "input" and "keyup" event handler. * @private */ _inputEventHandler() { const value = this.getInputValue(); if (this.lastInputValue !== value) { this.resizeInput(); this.updateAutocompletion(); this.lastInputValue = value; } } /** * The window "blur" event handler. * @private */ _blurEventHandler() { if (this.autocompletePopup) { this.clearCompletion(); } } /* eslint-disable complexity */ /** * The inputNode "keypress" event handler. * * @private * @param Event event */ _keyPress(event) { const inputNode = this.inputNode; const inputValue = this.getInputValue(); let inputUpdated = false; if (event.ctrlKey) { switch (event.charCode) { case 101: // control-e if (Services.appinfo.OS == "WINNT") { break; } let lineEndPos = inputValue.length; if (this.hasMultilineInput()) { // find index of closest newline >= cursor for (let i = inputNode.selectionEnd; i < lineEndPos; i++) { if (inputValue.charAt(i) == "\r" || inputValue.charAt(i) == "\n") { lineEndPos = i; break; } } } inputNode.setSelectionRange(lineEndPos, lineEndPos); event.preventDefault(); this.clearCompletion(); break; case 110: // 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.canCaretGoNext() && this.historyPeruse(HISTORY_FORWARD)) { event.preventDefault(); // Ctrl-N is also used to focus the Network category button on // MacOSX. The preventDefault() call doesn't prevent the focus // from moving away from the input. this.focus(); } this.clearCompletion(); break; case 112: // 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.canCaretGoPrevious() && this.historyPeruse(HISTORY_BACK)) { event.preventDefault(); // Ctrl-P may also be used to focus some category button on MacOSX. // The preventDefault() call doesn't prevent the focus from moving // away from the input. this.focus(); } this.clearCompletion(); break; default: break; } return; } else if (event.keyCode == KeyCodes.DOM_VK_RETURN) { if (!this.autocompletePopup.isOpen && ( event.shiftKey || !Debugger.isCompilableUnit(this.getInputValue()) )) { // shift return or incomplete statement return; } } switch (event.keyCode) { case KeyCodes.DOM_VK_ESCAPE: if (this.autocompletePopup.isOpen) { this.clearCompletion(); event.preventDefault(); event.stopPropagation(); } break; case KeyCodes.DOM_VK_RETURN: if (this.hasAutocompletionSuggestion()) { this.acceptProposedCompletion(); } else { this.execute(); } event.preventDefault(); break; case KeyCodes.DOM_VK_UP: if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectPreviousItem(); event.preventDefault(); } else if (this.canCaretGoPrevious()) { inputUpdated = this.historyPeruse(HISTORY_BACK); } if (inputUpdated) { event.preventDefault(); } break; case KeyCodes.DOM_VK_DOWN: if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectNextItem(); event.preventDefault(); } else if (this.canCaretGoNext()) { inputUpdated = this.historyPeruse(HISTORY_FORWARD); } if (inputUpdated) { event.preventDefault(); } break; case KeyCodes.DOM_VK_PAGE_UP: if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectPreviousPageItem(); } else { this.hud.outputScroller.scrollTop = Math.max(0, this.hud.outputScroller.scrollTop - this.hud.outputScroller.clientHeight ); } event.preventDefault(); break; case KeyCodes.DOM_VK_PAGE_DOWN: if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectNextPageItem(); } else { this.hud.outputScroller.scrollTop = Math.min(this.hud.outputScroller.scrollHeight, this.hud.outputScroller.scrollTop + this.hud.outputScroller.clientHeight ); } event.preventDefault(); break; case KeyCodes.DOM_VK_HOME: if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectedIndex = 0; event.preventDefault(); } else if (inputValue.length <= 0) { this.hud.outputScroller.scrollTop = 0; event.preventDefault(); } break; case KeyCodes.DOM_VK_END: if (this.autocompletePopup.isOpen) { this.autocompletePopup.selectedIndex = this.autocompletePopup.itemCount - 1; event.preventDefault(); } else if (inputValue.length <= 0) { this.hud.outputScroller.scrollTop = this.hud.outputScroller.scrollHeight; event.preventDefault(); } break; case KeyCodes.DOM_VK_LEFT: if (this.autocompletePopup.isOpen || this.getAutoCompletionText()) { this.clearCompletion(); } break; case KeyCodes.DOM_VK_RIGHT: // We only want to complete on Right arrow if the completion text is // displayed. if (this.getAutoCompletionText()) { this.acceptProposedCompletion(); event.preventDefault(); } this.clearCompletion(); break; case KeyCodes.DOM_VK_TAB: if (this.hasAutocompletionSuggestion()) { this.acceptProposedCompletion(); event.preventDefault(); } else if (!this.hasEmptyInput()) { if (!event.shiftKey) { this.insertStringAtCursor("\t"); } event.preventDefault(); } break; default: break; } } /* eslint-enable complexity */ /** * 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.getInputValue(); updateHistoryPosition(direction, expression); if (newInputValue != null) { this.setInputValue(newInputValue); return true; } return false; } /** * Test for empty input. * * @return boolean */ hasEmptyInput() { return this.getInputValue() === ""; } /** * Test for multiline input. * * @return boolean * True if CR or LF found in node value; else false. */ hasMultilineInput() { return /[\r\n]/.test(this.getInputValue()); } /** * 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() { const inputValue = this.getInputValue(); if (this.editor) { const {line, ch} = this.editor.getCursor(); return (line === 0 && ch === 0) || (line === 0 && ch === inputValue.length); } const node = this.inputNode; if (node.selectionStart != node.selectionEnd) { return false; } const multiline = /[\r\n]/.test(inputValue); return node.selectionStart == 0 ? true : node.selectionStart == inputValue.length && !multiline; } /** * 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() { const inputValue = this.getInputValue(); const multiline = /[\r\n]/.test(inputValue); if (this.editor) { const {line, ch} = this.editor.getCursor(); return (!multiline && ch === 0) || this.editor.getDoc() .getRange({line: 0, ch: 0}, {line, ch}) .length === inputValue.length; } const node = this.inputNode; if (node.selectionStart != node.selectionEnd) { return false; } return node.selectionStart == node.value.length ? true : node.selectionStart == 0 && !multiline; } async updateAutocompletion() { const inputValue = this.getInputValue(); const {editor, inputNode} = this; const frameActor = this.getFrameActor(this.SELECTED_FRAME); // Only complete if the selection is empty and the input value is not. if ( !inputValue || (inputNode && inputNode.selectionStart != inputNode.selectionEnd) || (editor && editor.getSelection()) || (this.lastInputValue === inputValue && frameActor === this._lastFrameActorId) ) { this.clearCompletion(); this.emit("autocomplete-updated"); return; } const cursor = this.getSelectionStart(); const input = inputValue.substring(0, cursor); // If the current input starts with the previous input, then we already // have a list of suggestions and we just need to filter the cached // suggestions. When the current input ends with a non-alphanumeric character we ask // the server again for suggestions. // Check if last character is non-alphanumeric if (!/[a-zA-Z0-9]$/.test(input) || frameActor != this._lastFrameActorId) { this._autocompleteQuery = null; this._autocompleteCache = null; } if (this._autocompleteQuery && input.startsWith(this._autocompleteQuery)) { let filterBy = input; // Find the last non-alphanumeric other than "_", ":", or "$" if it exists. const lastNonAlpha = input.match(/[^a-zA-Z0-9_$:][a-zA-Z0-9_$:]*$/); // If input contains non-alphanumerics, use the part after the last one // to filter the cache. if (lastNonAlpha) { filterBy = input.substring(input.lastIndexOf(lastNonAlpha) + 1); } const newList = this._autocompleteCache.sort().filter(l => l.startsWith(filterBy)); this._receiveAutocompleteProperties(null, { matches: newList, matchProp: filterBy }); return; } const requestId = gSequenceId(); this._lastFrameActorId = frameActor; this.currentAutoCompletionRequestId = requestId; const message = await this.webConsoleClient.autocomplete(input, cursor, frameActor); this._receiveAutocompleteProperties(requestId, message); } /** * Handler for the autocompletion results. This method takes * the completion result received from the server and updates the UI * accordingly. * * @param number requestId * Request ID. * @param object message * The JSON message which holds the completion results received from * the content process. */ _receiveAutocompleteProperties(requestId, message) { if (this.currentAutoCompletionRequestId !== requestId) { return; } this.currentAutoCompletionRequestId = null; // Cache whatever came from the server if the last char is // alphanumeric or '.' const inputUntilCursor = this.getInputValueBeforeCursor(); if (requestId != null && /[a-zA-Z0-9.]$/.test(inputUntilCursor)) { this._autocompleteCache = message.matches; this._autocompleteQuery = inputUntilCursor; } const matches = message.matches; const lastPart = message.matchProp; if (!matches.length) { this.clearCompletion(); this.emit("autocomplete-updated"); return; } const popup = this.autocompletePopup; const items = matches.map(match => ({ preLabel: lastPart, label: match })); popup.setItems(items); const minimumAutoCompleteLength = 2; if (items.length >= minimumAutoCompleteLength) { let popupAlignElement; let xOffset; let yOffset; if (this.editor) { popupAlignElement = this.node.querySelector(".CodeMirror-cursor"); // We need to show the popup at the ".". xOffset = -1 * lastPart.length * this._inputCharWidth; yOffset = 5; } else if (this.inputNode) { const offset = inputUntilCursor.length - (inputUntilCursor.lastIndexOf("\n") + 1) - lastPart.length; xOffset = (offset * this._inputCharWidth) + this._chevronWidth; popupAlignElement = this.inputNode; } if (popupAlignElement) { popup.openPopup(popupAlignElement, xOffset, yOffset); } } else if (items.length < minimumAutoCompleteLength && popup.isOpen) { popup.hidePopup(); } if (items.length > 0) { const suffix = items[0].label.substring(lastPart.length); this.setAutoCompletionText(suffix); } this.emit("autocomplete-updated"); } onAutocompleteSelect() { const {selectedItem} = this.autocompletePopup; if (selectedItem) { const suffix = selectedItem.label.substring(selectedItem.preLabel.length); this.setAutoCompletionText(suffix); } else { this.setAutoCompletionText(""); } } /** * Clear the current completion information and close the autocomplete popup, * if needed. */ clearCompletion() { this.setAutoCompletionText(""); if (this.autocompletePopup) { this.autocompletePopup.clearItems(); if (this.autocompletePopup.isOpen) { // Trigger a blur/focus of the JSTerm input to force screen readers to read the // value again. if (this.inputNode) { this.inputNode.blur(); } this.autocompletePopup.once("popup-closed", () => { this.focus(); }); this.autocompletePopup.hidePopup(); } } } /** * Accept the proposed input completion. * * @return boolean * True if there was a selected completion item and the input value * was updated, false otherwise. */ acceptProposedCompletion() { let completionText = this.getAutoCompletionText(); // In some cases the completion text might not be displayed (e.g. there is some text // just after the cursor so we can't display it). In those case, if the popup is // open and has a selectedItem, we use it for completing the input. if ( !completionText && this.autocompletePopup.isOpen && this.autocompletePopup.selectedItem ) { const {selectedItem} = this.autocompletePopup; completionText = selectedItem.label.substring(selectedItem.preLabel.length); } this.clearCompletion(); if (completionText) { this.insertStringAtCursor(completionText); } } getInputValueBeforeCursor() { if (this.editor) { return this.editor.getDoc().getRange({line: 0, ch: 0}, this.editor.getCursor()); } if (this.inputNode) { const cursor = this.inputNode.selectionStart; return this.getInputValue().substring(0, cursor); } return null; } /** * Insert a string into the console at the cursor location, * moving the cursor to the end of the string. * * @param string str */ insertStringAtCursor(str) { const value = this.getInputValue(); const prefix = this.getInputValueBeforeCursor(); const suffix = value.replace(prefix, ""); // We need to retrieve the cursor before setting the new value. const editorCursor = this.editor && this.editor.getCursor(); this.setInputValue(prefix + str + suffix); if (this.inputNode) { const newCursor = prefix.length + str.length; this.inputNode.selectionStart = this.inputNode.selectionEnd = newCursor; } else if (this.editor) { // Set the cursor on the same line it was already at, after the autocompleted text this.editor.setCursor({ line: editorCursor.line, ch: editorCursor.ch + str.length }); } } /** * Set the autocompletion text of the input. * * @param string suffix * The proposed suffix for the inputNode value. */ setAutoCompletionText(suffix) { if (suffix && !this.canDisplayAutoCompletionText()) { suffix = ""; } if (this.completeNode) { const lines = this.getInputValueBeforeCursor().split("\n"); const lastLine = lines[lines.length - 1]; const prefix = ("\n".repeat(lines.length - 1)) + lastLine.replace(/[\S]/g, " "); this.completeNode.value = suffix ? prefix + suffix : ""; } if (this.editor) { this.editor.setAutoCompletionText(suffix); } } getAutoCompletionText() { if (this.completeNode) { // Remove the spaces we set to align with the input value. return this.completeNode.value.replace(/^\s+/gm, ""); } if (this.editor) { return this.editor.getAutoCompletionText(); } return null; } /** * 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) { const { ch, line } = this.editor.getCursor(); const lineContent = this.editor.getLine(line); const textAfterCursor = lineContent.substring(ch); return textAfterCursor === ""; } if (this.inputNode) { const value = this.getInputValue(); const textAfterCursor = value.substring(this.inputNode.selectionStart); return textAfterCursor.split("\n")[0] === ""; } return false; } /** * 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() { if (!this.inputNode && !this.node) { return null; } if (this.editor) { return this.editor.defaultCharWidth(); } const doc = this.hud.document; const tempLabel = doc.createElement("span"); const style = tempLabel.style; style.position = "fixed"; style.padding = "0"; style.margin = "0"; style.width = "auto"; style.color = "transparent"; WebConsoleUtils.copyTextStyles(this.inputNode, tempLabel); tempLabel.textContent = "x"; doc.documentElement.appendChild(tempLabel); const width = tempLabel.offsetWidth; tempLabel.remove(); return width; } /** * Calculates and returns the width of the chevron icon. * This will be used in opening the popup at the correct offset. * * @returns {Number|null}: Width of the icon, or null if the input does not exist. */ _getChevronWidth() { if (!this.inputNode) { return null; } // Calculate the width of the chevron placed at the beginning of the input // box. Remove 4 more pixels to accommodate the padding of the popup. const doc = this.hud.document; return doc.defaultView .getComputedStyle(this.inputNode) .paddingLeft.replace(/[^0-9.]/g, "") - 4; } onContextMenu(e) { // The toolbox does it's own edit menu handling with // toolbox-textbox-context-popup and friends. For now, fall // back to use that if running inside the toolbox, but use our // own menu when running in the Browser Console (see Bug 1476097). if (this.props.hud.isBrowserConsole) { this.props.serviceContainer.openEditContextMenu(e); } } destroy() { this.clearCompletion(); this.webConsoleClient.clearNetworkRequests(); if (this.hud.outputNode) { // We do this because it's much faster than letting React handle the ConsoleOutput // unmounting. this.hud.outputNode.innerHTML = ""; } if (this.autocompletePopup) { this.autocompletePopup.destroy(); this.autocompletePopup = null; } if (this.inputNode) { this.inputNode.removeEventListener("keypress", this._keyPress); this.inputNode.removeEventListener("input", this._inputEventHandler); this.inputNode.removeEventListener("keyup", this._inputEventHandler); this.hud.window.removeEventListener("blur", this._blurEventHandler); } if (this.editor) { this.editor.destroy(); this.editor = null; } this.hud = null; } render() { if (this.props.hud.isBrowserConsole && !Services.prefs.getBoolPref("devtools.chrome.enabled")) { return null; } if (this.props.codeMirrorEnabled) { return dom.div({ className: "jsterm-input-container devtools-monospace", key: "jsterm-container", style: {direction: "ltr"}, "aria-live": "off", onContextMenu: this.onContextMenu, ref: node => { this.node = node; }, }); } const { onPaste } = this.props; return ( dom.div({ className: "jsterm-input-container", key: "jsterm-container", style: {direction: "ltr"}, "aria-live": "off", }, dom.textarea({ className: "jsterm-complete-node devtools-monospace", key: "complete", tabIndex: "-1", ref: node => { this.completeNode = node; }, }), dom.textarea({ className: "jsterm-input-node devtools-monospace", key: "input", tabIndex: "0", rows: "1", "aria-autocomplete": "list", ref: node => { this.inputNode = node; }, onPaste: onPaste, onDrop: onPaste, onContextMenu: this.onContextMenu, }) ) ); } } // Redux connect function mapStateToProps(state) { return { history: getHistory(state), getValueFromHistory: (direction) => getHistoryValue(state, direction), }; } function mapDispatchToProps(dispatch) { return { appendToHistory: (expr) => dispatch(historyActions.appendToHistory(expr)), clearHistory: () => dispatch(historyActions.clearHistory()), updateHistoryPosition: (direction, expression) => dispatch(historyActions.updateHistoryPosition(direction, expression)), }; } module.exports = connect(mapStateToProps, mapDispatchToProps)(JSTerm);