/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* 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 promise = require("promise"); const Debugger = require("Debugger"); const Services = require("Services"); const {KeyCodes} = require("devtools/client/shared/keycodes"); loader.lazyServiceGetter(this, "clipboardHelper", "@mozilla.org/widget/clipboardhelper;1", "nsIClipboardHelper"); loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); loader.lazyRequireGetter(this, "AutocompletePopup", "devtools/client/shared/autocomplete-popup", true); loader.lazyRequireGetter(this, "ToolSidebar", "devtools/client/framework/sidebar", true); loader.lazyRequireGetter(this, "Messages", "devtools/client/webconsole/console-output", true); loader.lazyRequireGetter(this, "asyncStorage", "devtools/shared/async-storage"); loader.lazyRequireGetter(this, "EnvironmentClient", "devtools/shared/client/main", true); loader.lazyRequireGetter(this, "ObjectClient", "devtools/shared/client/main", true); loader.lazyImporter(this, "VariablesView", "resource://devtools/client/shared/widgets/VariablesView.jsm"); loader.lazyImporter(this, "VariablesViewController", "resource://devtools/client/shared/widgets/VariablesViewController.jsm"); loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true); const STRINGS_URI = "devtools/client/locales/webconsole.properties"; var l10n = new WebConsoleUtils.L10n(STRINGS_URI); // Constants used for defining the direction of JSTerm input history navigation. const HISTORY_BACK = -1; const HISTORY_FORWARD = 1; const XHTML_NS = "http://www.w3.org/1999/xhtml"; const HELP_URL = "https://developer.mozilla.org/docs/Tools/Web_Console/Helpers"; const VARIABLES_VIEW_URL = "chrome://devtools/content/shared/widgets/VariablesView.xul"; const PREF_INPUT_HISTORY_COUNT = "devtools.webconsole.inputHistoryCount"; const PREF_AUTO_MULTILINE = "devtools.webconsole.autoMultiline"; /** * 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, code evaluation and result output. * * @constructor * @param object webConsoleFrame * The WebConsoleFrame object that owns this JSTerm instance. */ function JSTerm(webConsoleFrame) { this.hud = webConsoleFrame; this.hudId = this.hud.hudId; this.inputHistoryCount = Services.prefs.getIntPref(PREF_INPUT_HISTORY_COUNT); this.lastCompletion = { value: null }; this._loadHistory(); this._objectActorsInVariablesViews = new Map(); this._keyPress = this._keyPress.bind(this); this._inputEventHandler = this._inputEventHandler.bind(this); this._focusEventHandler = this._focusEventHandler.bind(this); this._onKeypressInVariablesView = this._onKeypressInVariablesView.bind(this); this._blurEventHandler = this._blurEventHandler.bind(this); EventEmitter.decorate(this); } JSTerm.prototype = { SELECTED_FRAME: -1, /** * Load the console history from previous sessions. * @private */ _loadHistory: function () { this.history = []; this.historyIndex = this.historyPlaceHolder = 0; this.historyLoaded = asyncStorage.getItem("webConsoleHistory") .then(value => { if (Array.isArray(value)) { // Since it was gotten asynchronously, there could be items already in // the history. It's not likely but stick them onto the end anyway. this.history = value.concat(this.history); // Holds the number of entries in history. This value is incremented // in this.execute(). this.historyIndex = this.history.length; // Holds the index of the history entry that the user is currently // viewing. This is reset to this.history.length when this.execute() // is invoked. this.historyPlaceHolder = this.history.length; } }, console.error); }, /** * Clear the console history altogether. Note that this will not affect * other consoles that are already opened (since they have their own copy), * but it will reset the array for all newly-opened consoles. * @returns Promise * Resolves once the changes have been persisted. */ clearHistory: function () { this.history = []; this.historyIndex = this.historyPlaceHolder = 0; return this.storeHistory(); }, /** * Stores the console history for future console instances. * @returns Promise * Resolves once the changes have been persisted. */ storeHistory: function () { return asyncStorage.setItem("webConsoleHistory", this.history); }, /** * Stores the data for the last completion. * @type object */ lastCompletion: null, /** * Array that caches the user input suggestions received from the server. * @private * @type array */ _autocompleteCache: null, /** * The input that caused the last request to the server, whose response is * cached in the _autocompleteCache array. * @private * @type string */ _autocompleteQuery: null, /** * The frameActorId used in the last autocomplete query. Whenever this changes * the autocomplete cache must be invalidated. * @private * @type string */ _lastFrameActorId: null, /** * The Web Console sidebar. * @see this._createSidebar() * @see Sidebar.jsm */ sidebar: null, /** * The Variables View instance shown in the sidebar. * @private * @type object */ _variablesView: null, /** * Tells if you want the variables view UI updates to be lazy or not. Tests * disable lazy updates. * * @private * @type boolean */ _lazyVariablesView: true, /** * Holds a map between VariablesView instances and sets of ObjectActor IDs * that have been retrieved from the server. This allows us to release the * objects when needed. * * @private * @type Map */ _objectActorsInVariablesViews: null, /** * Last input value. * @type string */ lastInputValue: "", /** * Tells if the input node changed since the last focus. * * @private * @type boolean */ _inputChanged: false, /** * Tells if the autocomplete popup was navigated since the last open. * * @private * @type boolean */ _autocompletePopupNavigated: false, /** * History of code that was executed. * @type array */ history: null, autocompletePopup: null, inputNode: null, completeNode: null, /** * Getter for the element that holds the messages we display. * @type nsIDOMElement */ get outputNode() { return this.hud.outputNode; }, /** * Getter for the debugger WebConsoleClient. * @type object */ get webConsoleClient() { return this.hud.webConsoleClient; }, COMPLETE_FORWARD: 0, COMPLETE_BACKWARD: 1, COMPLETE_HINT_ONLY: 2, COMPLETE_PAGEUP: 3, COMPLETE_PAGEDOWN: 4, /** * Initialize the JSTerminal UI. */ init: function () { let autocompleteOptions = { onSelect: this.onAutocompleteSelect.bind(this), onClick: this.acceptProposedCompletion.bind(this), listId: "webConsole_autocompletePopupListBox", position: "top", theme: "auto", autoSelect: true }; let doc = this.hud.document; let toolbox = gDevTools.getToolbox(this.hud.owner.target); let 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); let inputContainer = doc.querySelector(".jsterm-input-container"); this.completeNode = doc.querySelector(".jsterm-complete-node"); this.inputNode = doc.querySelector(".jsterm-input-node"); if (this.hud.isBrowserConsole && !Services.prefs.getBoolPref("devtools.chrome.enabled")) { inputContainer.style.display = "none"; } else { let okstring = l10n.getStr("selfxss.okstring"); let msg = l10n.getFormatStr("selfxss.msg", [okstring]); this._onPaste = WebConsoleUtils.pasteHandlerGen( this.inputNode, doc.getElementById("webconsole-notificationbox"), msg, okstring); this.inputNode.addEventListener("keypress", this._keyPress, false); this.inputNode.addEventListener("paste", this._onPaste); this.inputNode.addEventListener("drop", this._onPaste); this.inputNode.addEventListener("input", this._inputEventHandler, false); this.inputNode.addEventListener("keyup", this._inputEventHandler, false); this.inputNode.addEventListener("focus", this._focusEventHandler, false); } this.hud.window.addEventListener("blur", this._blurEventHandler, false); this.lastInputValue && this.setInputValue(this.lastInputValue); }, focus: function () { if (!this.inputNode.getAttribute("focused")) { this.inputNode.focus(); } }, /** * The JavaScript evaluation response handler. * * @private * @param function [callback] * Optional function to invoke when the evaluation result is added to * the output. * @param object response * The message received from the server. */ _executeResultCallback: function (callback, response) { if (!this.hud) { return; } if (response.error) { console.error("Evaluation error " + response.error + ": " + response.message); return; } let errorMessage = response.exceptionMessage; let errorDocURL = response.exceptionDocURL; let errorDocLink; if (errorDocURL) { errorMessage += " "; errorDocLink = this.hud.document.createElementNS(XHTML_NS, "a"); errorDocLink.className = "learn-more-link webconsole-learn-more-link"; errorDocLink.textContent = `[${l10n.getStr("webConsoleMoreInfoLabel")}]`; errorDocLink.title = errorDocURL.split("?")[0]; errorDocLink.href = "#"; errorDocLink.draggable = false; errorDocLink.addEventListener("click", () => { this.hud.owner.openLink(errorDocURL); }); } // Wrap thrown strings in Error objects, so `throw "foo"` outputs // "Error: foo" if (typeof response.exception === "string") { errorMessage = new Error(errorMessage).toString(); } let result = response.result; let helperResult = response.helperResult; let helperHasRawOutput = !!(helperResult || {}).rawOutput; if (helperResult && helperResult.type) { switch (helperResult.type) { case "clearOutput": this.clearOutput(); break; case "clearHistory": this.clearHistory(); break; case "inspectObject": this.openVariablesView({ label: VariablesView.getString(helperResult.object, { concise: true }), objectActor: 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; } } // Hide undefined results coming from JSTerm helper functions. if (!errorMessage && result && typeof result == "object" && result.type == "undefined" && helperResult && !helperHasRawOutput) { callback && callback(); return; } if (this.hud.NEW_CONSOLE_OUTPUT_ENABLED) { this.hud.newConsoleOutput.dispatchMessageAdd(response, true).then(callback); return; } let msg = new Messages.JavaScriptEvalOutput(response, errorMessage, errorDocLink); this.hud.output.addMessage(msg); if (callback) { let oldFlushCallback = this.hud._flushCallback; this.hud._flushCallback = () => { callback(msg.element); if (oldFlushCallback) { oldFlushCallback(); this.hud._flushCallback = oldFlushCallback; return true; } return false; }; } msg._objectActors = new Set(); if (WebConsoleUtils.isActorGrip(response.exception)) { msg._objectActors.add(response.exception.actor); } if (WebConsoleUtils.isActorGrip(result)) { msg._objectActors.add(result.actor); } }, /** * 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()|. * @param function [callback] * Optional function to invoke when the result is displayed. * This is deprecated - please use the promise return value instead. * @returns Promise * Resolves with the message once the result is displayed. */ execute: function (executeString, callback) { let deferred = promise.defer(); let resultCallback; if (this.hud.NEW_CONSOLE_OUTPUT_ENABLED) { resultCallback = (msg) => deferred.resolve(msg); } else { resultCallback = (msg) => { deferred.resolve(msg); if (callback) { callback(msg); } }; } // attempt to execute the content of the inputNode executeString = executeString || this.getInputValue(); if (!executeString) { return null; } let selectedNodeActor = null; let inspectorSelection = this.hud.owner.getInspectorSelection(); if (inspectorSelection && inspectorSelection.nodeFront) { selectedNodeActor = inspectorSelection.nodeFront.actorID; } if (this.hud.NEW_CONSOLE_OUTPUT_ENABLED) { const { ConsoleCommand } = require("devtools/client/webconsole/new-console-output/types"); let message = new ConsoleCommand({ messageText: executeString, }); this.hud.proxy.dispatchMessageAdd(message); } else { let message = new Messages.Simple(executeString, { category: "input", severity: "log", }); this.hud.output.addMessage(message); } let onResult = this._executeResultCallback.bind(this, resultCallback); let options = { frame: this.SELECTED_FRAME, selectedNodeActor: selectedNodeActor, }; this.requestEvaluation(executeString, options).then(onResult, onResult); // Append a new value in the history of executed code, or overwrite the most // recent entry. The most recent entry may contain the last edited input // value that was not evaluated yet. this.history[this.historyIndex++] = executeString; this.historyPlaceHolder = this.history.length; if (this.history.length > this.inputHistoryCount) { this.history.splice(0, this.history.length - this.inputHistoryCount); this.historyIndex = this.historyPlaceHolder = this.history.length; } this.storeHistory(); WebConsoleUtils.usageCount++; this.setInputValue(""); this.clearCompletion(); return deferred.promise; }, /** * 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 the * 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: function (str, options = {}) { let deferred = promise.defer(); function onResult(response) { if (!response.error) { deferred.resolve(response); } else { deferred.reject(response); } } let frameActor = null; if ("frame" in options) { frameActor = this.getFrameActor(options.frame); } let evalOptions = { bindObjectActor: options.bindObjectActor, frameActor: frameActor, selectedNodeActor: options.selectedNodeActor, selectedObjectActor: options.selectedObjectActor, }; this.webConsoleClient.evaluateJSAsync(str, onResult, evalOptions); return deferred.promise; }, /** * 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: function (frame) { let 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; }, /** * Opens a new variables view that allows the inspection of the given object. * * @param object options * Options for the variables view: * - objectActor: grip of the ObjectActor you want to show in the * variables view. * - rawObject: the raw object you want to show in the variables view. * - label: label to display in the variables view for inspected * object. * - hideFilterInput: optional boolean, |true| if you want to hide the * variables view filter input. * - targetElement: optional nsIDOMElement to append the variables view * to. An iframe element is used as a container for the view. If this * option is not used, then the variables view opens in the sidebar. * - autofocus: optional boolean, |true| if you want to give focus to * the variables view window after open, |false| otherwise. * @return object * A promise object that is resolved when the variables view has * opened. The new variables view instance is given to the callbacks. */ openVariablesView: function (options) { let onContainerReady = (window) => { let container = window.document.querySelector("#variables"); let view = this._variablesView; if (!view || options.targetElement) { let viewOptions = { container: container, hideFilterInput: options.hideFilterInput, }; view = this._createVariablesView(viewOptions); if (!options.targetElement) { this._variablesView = view; window.addEventListener("keypress", this._onKeypressInVariablesView); } } options.view = view; this._updateVariablesView(options); if (!options.targetElement && options.autofocus) { window.focus(); } this.emit("variablesview-open", view, options); return view; }; let openPromise; if (options.targetElement) { let deferred = promise.defer(); openPromise = deferred.promise; let document = options.targetElement.ownerDocument; let iframe = document.createElementNS(XHTML_NS, "iframe"); iframe.addEventListener("load", function onIframeLoad() { iframe.removeEventListener("load", onIframeLoad, true); iframe.style.visibility = "visible"; deferred.resolve(iframe.contentWindow); }, true); iframe.flex = 1; iframe.style.visibility = "hidden"; iframe.setAttribute("src", VARIABLES_VIEW_URL); options.targetElement.appendChild(iframe); } else { if (!this.sidebar) { this._createSidebar(); } openPromise = this._addVariablesViewSidebarTab(); } return openPromise.then(onContainerReady); }, /** * Create the Web Console sidebar. * * @see devtools/framework/sidebar.js * @private */ _createSidebar: function () { let tabbox = this.hud.document.querySelector("#webconsole-sidebar"); this.sidebar = new ToolSidebar(tabbox, this, "webconsole"); this.sidebar.show(); this.emit("sidebar-opened"); }, /** * Add the variables view tab to the sidebar. * * @private * @return object * A promise object for the adding of the new tab. */ _addVariablesViewSidebarTab: function () { let deferred = promise.defer(); let onTabReady = () => { let window = this.sidebar.getWindowForTab("variablesview"); deferred.resolve(window); }; let tabPanel = this.sidebar.getTabPanel("variablesview"); if (tabPanel) { if (this.sidebar.getCurrentTabID() == "variablesview") { onTabReady(); } else { this.sidebar.once("variablesview-selected", onTabReady); this.sidebar.select("variablesview"); } } else { this.sidebar.once("variablesview-ready", onTabReady); this.sidebar.addTab("variablesview", VARIABLES_VIEW_URL, {selected: true}); } return deferred.promise; }, /** * The keypress event handler for the Variables View sidebar. Currently this * is used for removing the sidebar when Escape is pressed. * * @private * @param nsIDOMEvent event * The keypress DOM event object. */ _onKeypressInVariablesView: function (event) { let tag = event.target.nodeName; if (event.keyCode != KeyCodes.DOM_VK_ESCAPE || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey || ["input", "textarea", "select", "textbox"].indexOf(tag) > -1) { return; } this._sidebarDestroy(); this.focus(); event.stopPropagation(); }, /** * Create a variables view instance. * * @private * @param object options * Options for the new Variables View instance: * - container: the DOM element where the variables view is inserted. * - hideFilterInput: boolean, if true the variables filter input is * hidden. * @return object * The new Variables View instance. */ _createVariablesView: function (options) { let view = new VariablesView(options.container); view.toolbox = gDevTools.getToolbox(this.hud.owner.target); view.searchPlaceholder = l10n.getStr("propertiesFilterPlaceholder"); view.emptyText = l10n.getStr("emptyPropertiesList"); view.searchEnabled = !options.hideFilterInput; view.lazyEmpty = this._lazyVariablesView; VariablesViewController.attach(view, { getEnvironmentClient: grip => { return new EnvironmentClient(this.hud.proxy.client, grip); }, getObjectClient: grip => { return new ObjectClient(this.hud.proxy.client, grip); }, getLongStringClient: grip => { return this.webConsoleClient.longString(grip); }, releaseActor: actor => { this.hud._releaseObject(actor); }, simpleValueEvalMacro: simpleValueEvalMacro, overrideValueEvalMacro: overrideValueEvalMacro, getterOrSetterEvalMacro: getterOrSetterEvalMacro, }); // Relay events from the VariablesView. view.on("fetched", (event, type, variableObject) => { this.emit("variablesview-fetched", variableObject); }); return view; }, /** * Update the variables view. * * @private * @param object options * Options for updating the variables view: * - view: the view you want to update. * - objectActor: the grip of the new ObjectActor you want to show in * the view. * - rawObject: the new raw object you want to show. * - label: the new label for the inspected object. */ _updateVariablesView: function (options) { let view = options.view; view.empty(); // We need to avoid pruning the object inspection starting point. // That one is pruned when the console message is removed. view.controller.releaseActors(actor => { return view._consoleLastObjectActor != actor; }); if (options.objectActor && (!this.hud.isBrowserConsole || Services.prefs.getBoolPref("devtools.chrome.enabled"))) { // Make sure eval works in the correct context. view.eval = this._variablesViewEvaluate.bind(this, options); view.switch = this._variablesViewSwitch.bind(this, options); view.delete = this._variablesViewDelete.bind(this, options); } else { view.eval = null; view.switch = null; view.delete = null; } let { variable, expanded } = view.controller.setSingleVariable(options); variable.evaluationMacro = simpleValueEvalMacro; if (options.objectActor) { view._consoleLastObjectActor = options.objectActor.actor; } else if (options.rawObject) { view._consoleLastObjectActor = null; } else { throw new Error( "Variables View cannot open without giving it an object display."); } expanded.then(() => { this.emit("variablesview-updated", view, options); }); }, /** * The evaluation function used by the variables view when editing a property * value. * * @private * @param object options * The options used for |this._updateVariablesView()|. * @param object variableObject * The Variable object instance for the edited property. * @param string value * The value the edited property was changed to. */ _variablesViewEvaluate: function (options, variableObject, value) { let updater = this._updateVariablesView.bind(this, options); let onEval = this._silentEvalCallback.bind(this, updater); let string = variableObject.evaluationMacro(variableObject, value); let evalOptions = { frame: this.SELECTED_FRAME, bindObjectActor: options.objectActor.actor, }; this.requestEvaluation(string, evalOptions).then(onEval, onEval); }, /** * The property deletion function used by the variables view when a property * is deleted. * * @private * @param object options * The options used for |this._updateVariablesView()|. * @param object variableObject * The Variable object instance for the deleted property. */ _variablesViewDelete: function (options, variableObject) { let onEval = this._silentEvalCallback.bind(this, null); let evalOptions = { frame: this.SELECTED_FRAME, bindObjectActor: options.objectActor.actor, }; this.requestEvaluation("delete _self" + variableObject.symbolicName, evalOptions).then(onEval, onEval); }, /** * The property rename function used by the variables view when a property * is renamed. * * @private * @param object options * The options used for |this._updateVariablesView()|. * @param object variableObject * The Variable object instance for the renamed property. * @param string newName * The new name for the property. */ _variablesViewSwitch: function (options, variableObject, newName) { let updater = this._updateVariablesView.bind(this, options); let onEval = this._silentEvalCallback.bind(this, updater); let evalOptions = { frame: this.SELECTED_FRAME, bindObjectActor: options.objectActor.actor, }; let newSymbolicName = variableObject.ownerView.symbolicName + '["' + newName + '"]'; if (newSymbolicName == variableObject.symbolicName) { return; } let code = "_self" + newSymbolicName + " = _self" + variableObject.symbolicName + ";" + "delete _self" + variableObject.symbolicName; this.requestEvaluation(code, evalOptions).then(onEval, onEval); }, /** * A noop callback for JavaScript evaluation. This method releases any * result ObjectActors that come from the server for evaluation requests. This * is used for editing, renaming and deleting properties in the variables * view. * * Exceptions are displayed in the output. * * @private * @param function callback * Function to invoke once the response is received. * @param object response * The response packet received from the server. */ _silentEvalCallback: function (callback, response) { if (response.error) { console.error("Web Console evaluation failed. " + response.error + ":" + response.message); callback && callback(response); return; } if (response.exceptionMessage) { let message = new Messages.Simple(response.exceptionMessage, { category: "output", severity: "error", timestamp: response.timestamp, }); this.hud.output.addMessage(message); message._objectActors = new Set(); if (WebConsoleUtils.isActorGrip(response.exception)) { message._objectActors.add(response.exception.actor); } } let helper = response.helperResult || { type: null }; let helperGrip = null; if (helper.type == "inspectObject") { helperGrip = helper.object; } let grips = [response.result, helperGrip]; for (let grip of grips) { if (WebConsoleUtils.isActorGrip(grip)) { this.hud._releaseObject(grip.actor); } } callback && callback(response); }, /** * Clear the Web Console output. * * This method emits the "messages-cleared" notification. * * @param boolean clearStorage * True if you want to clear the console messages storage associated to * this Web Console. */ clearOutput: function (clearStorage) { let hud = this.hud; let outputNode = hud.outputNode; let node; while ((node = outputNode.firstChild)) { hud.removeOutputMessage(node); } hud.groupDepth = 0; hud._outputQueue.forEach(hud._destroyItem, hud); hud._outputQueue = []; this.webConsoleClient.clearNetworkRequests(); hud._repeatNodes = {}; if (clearStorage) { this.webConsoleClient.clearMessagesCache(); } this._sidebarDestroy(); if (hud.NEW_CONSOLE_OUTPUT_ENABLED) { hud.newConsoleOutput.dispatchMessagesClear(); } this.emit("messages-cleared"); }, /** * Remove all of the private messages from the Web Console output. * * This method emits the "private-messages-cleared" notification. */ clearPrivateMessages: function () { let nodes = this.hud.outputNode.querySelectorAll(".message[private]"); for (let node of nodes) { this.hud.removeOutputMessage(node); } this.emit("private-messages-cleared"); }, /** * Updates the size of the input field (command line) to fit its contents. * * @returns void */ resizeInput: function () { let 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. let scrollHeight = inputNode.inputField.scrollHeight; if (scrollHeight > 0) { inputNode.style.height = scrollHeight + "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: function (newValue) { this.inputNode.value = newValue; this.lastInputValue = newValue; this.completeNode.value = ""; this.resizeInput(); this._inputChanged = true; this.emit("set-input-value"); }, /** * Gets the value from the input field * @returns string */ getInputValue: function () { return this.inputNode.value || ""; }, /** * The inputNode "input" and "keyup" event handler. * @private */ _inputEventHandler: function () { if (this.lastInputValue != this.getInputValue()) { this.resizeInput(); this.complete(this.COMPLETE_HINT_ONLY); this.lastInputValue = this.getInputValue(); this._inputChanged = true; } }, /** * The window "blur" event handler. * @private */ _blurEventHandler: function () { if (this.autocompletePopup) { this.clearCompletion(); } }, /* eslint-disable complexity */ /** * The inputNode "keypress" event handler. * * @private * @param nsIDOMEvent event */ _keyPress: function (event) { let inputNode = this.inputNode; let 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) { let autoMultiline = Services.prefs.getBoolPref(PREF_AUTO_MULTILINE); if (event.shiftKey || (!Debugger.isCompilableUnit(inputNode.value) && autoMultiline)) { // shift return or incomplete statement return; } } switch (event.keyCode) { case KeyCodes.DOM_VK_ESCAPE: if (this.autocompletePopup.isOpen) { this.clearCompletion(); event.preventDefault(); event.stopPropagation(); } else if (this.sidebar) { this._sidebarDestroy(); event.preventDefault(); event.stopPropagation(); } break; case KeyCodes.DOM_VK_RETURN: if (this._autocompletePopupNavigated && this.autocompletePopup.isOpen && this.autocompletePopup.selectedIndex > -1) { this.acceptProposedCompletion(); } else { this.execute(); this._inputChanged = false; } event.preventDefault(); break; case KeyCodes.DOM_VK_UP: if (this.autocompletePopup.isOpen) { inputUpdated = this.complete(this.COMPLETE_BACKWARD); if (inputUpdated) { this._autocompletePopupNavigated = true; } } else if (this.canCaretGoPrevious()) { inputUpdated = this.historyPeruse(HISTORY_BACK); } if (inputUpdated) { event.preventDefault(); } break; case KeyCodes.DOM_VK_DOWN: if (this.autocompletePopup.isOpen) { inputUpdated = this.complete(this.COMPLETE_FORWARD); if (inputUpdated) { this._autocompletePopupNavigated = true; } } else if (this.canCaretGoNext()) { inputUpdated = this.historyPeruse(HISTORY_FORWARD); } if (inputUpdated) { event.preventDefault(); } break; case KeyCodes.DOM_VK_PAGE_UP: if (this.autocompletePopup.isOpen) { inputUpdated = this.complete(this.COMPLETE_PAGEUP); if (inputUpdated) { this._autocompletePopupNavigated = true; } } 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) { inputUpdated = this.complete(this.COMPLETE_PAGEDOWN); if (inputUpdated) { this._autocompletePopupNavigated = true; } } 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.lastCompletion.value) { this.clearCompletion(); } break; case KeyCodes.DOM_VK_RIGHT: let cursorAtTheEnd = this.inputNode.selectionStart == this.inputNode.selectionEnd && this.inputNode.selectionStart == inputValue.length; let haveSuggestion = this.autocompletePopup.isOpen || this.lastCompletion.value; let useCompletion = cursorAtTheEnd || this._autocompletePopupNavigated; if (haveSuggestion && useCompletion && this.complete(this.COMPLETE_HINT_ONLY) && this.lastCompletion.value && this.acceptProposedCompletion()) { event.preventDefault(); } if (this.autocompletePopup.isOpen) { this.clearCompletion(); } break; case KeyCodes.DOM_VK_TAB: // Generate a completion and accept the first proposed value. if (this.complete(this.COMPLETE_HINT_ONLY) && this.lastCompletion && this.acceptProposedCompletion()) { event.preventDefault(); } else if (this._inputChanged) { this.updateCompleteNode(l10n.getStr("Autocomplete.blank")); event.preventDefault(); } break; default: break; } }, /* eslint-enable complexity */ /** * The inputNode "focus" event handler. * @private */ _focusEventHandler: function () { this._inputChanged = false; }, /** * 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: function (direction) { if (!this.history.length) { return false; } // Up Arrow key if (direction == HISTORY_BACK) { if (this.historyPlaceHolder <= 0) { return false; } let inputVal = this.history[--this.historyPlaceHolder]; // Save the current input value as the latest entry in history, only if // the user is already at the last entry. // Note: this code does not store changes to items that are already in // history. if (this.historyPlaceHolder + 1 == this.historyIndex) { this.history[this.historyIndex] = this.getInputValue() || ""; } this.setInputValue(inputVal); } else if (direction == HISTORY_FORWARD) { // Down Arrow key if (this.historyPlaceHolder >= (this.history.length - 1)) { return false; } let inputVal = this.history[++this.historyPlaceHolder]; this.setInputValue(inputVal); } else { throw new Error("Invalid argument 0"); } return true; }, /** * Test for multiline input. * * @return boolean * True if CR or LF found in node value; else false. */ hasMultilineInput: function () { 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: function () { let node = this.inputNode; if (node.selectionStart != node.selectionEnd) { return false; } let multiline = /[\r\n]/.test(node.value); return node.selectionStart == 0 ? true : node.selectionStart == node.value.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: function () { let node = this.inputNode; if (node.selectionStart != node.selectionEnd) { return false; } let multiline = /[\r\n]/.test(node.value); return node.selectionStart == node.value.length ? true : node.selectionStart == 0 && !multiline; }, /** * Completes the current typed text in the inputNode. Completion is performed * only if the selection/cursor is at the end of the string. If no completion * is found, the current inputNode value and cursor/selection stay. * * @param int type possible values are * - this.COMPLETE_FORWARD: If there is more than one possible completion * and the input value stayed the same compared to the last time this * function was called, then the next completion of all possible * completions is used. If the value changed, then the first possible * completion is used and the selection is set from the current * cursor position to the end of the completed text. * If there is only one possible completion, then this completion * value is used and the cursor is put at the end of the completion. * - this.COMPLETE_BACKWARD: Same as this.COMPLETE_FORWARD but if the * value stayed the same as the last time the function was called, * then the previous completion of all possible completions is used. * - this.COMPLETE_PAGEUP: Scroll up one page if available or select the * first item. * - this.COMPLETE_PAGEDOWN: Scroll down one page if available or select * the last item. * - this.COMPLETE_HINT_ONLY: If there is more than one possible * completion and the input value stayed the same compared to the * last time this function was called, then the same completion is * used again. If there is only one possible completion, then * the this.getInputValue() is set to this value and the selection * is set from the current cursor position to the end of the * completed text. * @param function callback * Optional function invoked when the autocomplete properties are * updated. * @returns boolean true if there existed a completion for the current input, * or false otherwise. */ complete: function (type, callback) { let inputNode = this.inputNode; let inputValue = this.getInputValue(); let frameActor = this.getFrameActor(this.SELECTED_FRAME); // If the inputNode has no value, then don't try to complete on it. if (!inputValue) { this.clearCompletion(); callback && callback(this); this.emit("autocomplete-updated"); return false; } // Only complete if the selection is empty. if (inputNode.selectionStart != inputNode.selectionEnd) { this.clearCompletion(); callback && callback(this); this.emit("autocomplete-updated"); return false; } // Update the completion results. if (this.lastCompletion.value != inputValue || frameActor != this._lastFrameActorId) { this._updateCompletionResult(type, callback); return false; } let popup = this.autocompletePopup; let accepted = false; if (type != this.COMPLETE_HINT_ONLY && popup.itemCount == 1) { this.acceptProposedCompletion(); accepted = true; } else if (type == this.COMPLETE_BACKWARD) { popup.selectPreviousItem(); } else if (type == this.COMPLETE_FORWARD) { popup.selectNextItem(); } else if (type == this.COMPLETE_PAGEUP) { popup.selectPreviousPageItem(); } else if (type == this.COMPLETE_PAGEDOWN) { popup.selectNextPageItem(); } callback && callback(this); this.emit("autocomplete-updated"); return accepted || popup.itemCount > 0; }, /** * Update the completion result. This operation is performed asynchronously by * fetching updated results from the content process. * * @private * @param int type * Completion type. See this.complete() for details. * @param function [callback] * Optional, function to invoke when completion results are received. */ _updateCompletionResult: function (type, callback) { let frameActor = this.getFrameActor(this.SELECTED_FRAME); if (this.lastCompletion.value == this.getInputValue() && frameActor == this._lastFrameActorId) { return; } let requestId = gSequenceId(); let cursor = this.inputNode.selectionStart; let input = this.getInputValue().substring(0, cursor); let cache = this._autocompleteCache; // 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. let 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); } let newList = cache.sort().filter(function (l) { return l.startsWith(filterBy); }); this.lastCompletion = { requestId: null, completionType: type, value: null, }; let response = { matches: newList, matchProp: filterBy }; this._receiveAutocompleteProperties(null, callback, response); return; } this._lastFrameActorId = frameActor; this.lastCompletion = { requestId: requestId, completionType: type, value: null, }; let autocompleteCallback = this._receiveAutocompleteProperties.bind(this, requestId, callback); this.webConsoleClient.autocomplete( input, cursor, autocompleteCallback, frameActor); }, /** * 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 function [callback=null] * Optional, function to invoke when the completion result is received. * @param object message * The JSON message which holds the completion results received from * the content process. */ _receiveAutocompleteProperties: function (requestId, callback, message) { let inputNode = this.inputNode; let inputValue = this.getInputValue(); if (this.lastCompletion.value == inputValue || requestId != this.lastCompletion.requestId) { return; } // Cache whatever came from the server if the last char is // alphanumeric or '.' let cursor = inputNode.selectionStart; let inputUntilCursor = inputValue.substring(0, cursor); if (requestId != null && /[a-zA-Z0-9.]$/.test(inputUntilCursor)) { this._autocompleteCache = message.matches; this._autocompleteQuery = inputUntilCursor; } let matches = message.matches; let lastPart = message.matchProp; if (!matches.length) { this.clearCompletion(); callback && callback(this); this.emit("autocomplete-updated"); return; } let items = matches.reverse().map(function (match) { return { preLabel: lastPart, label: match }; }); let popup = this.autocompletePopup; popup.setItems(items); let completionType = this.lastCompletion.completionType; this.lastCompletion = { value: inputValue, matchProp: lastPart, }; if (items.length > 1 && !popup.isOpen) { let str = this.getInputValue().substr(0, this.inputNode.selectionStart); let offset = str.length - (str.lastIndexOf("\n") + 1) - lastPart.length; let x = offset * this.hud._inputCharWidth; popup.openPopup(inputNode, x + this.hud._chevronWidth); this._autocompletePopupNavigated = false; } else if (items.length < 2 && popup.isOpen) { popup.hidePopup(); this._autocompletePopupNavigated = false; } if (items.length == 1) { popup.selectedIndex = 0; } this.onAutocompleteSelect(); if (completionType != this.COMPLETE_HINT_ONLY && popup.itemCount == 1) { this.acceptProposedCompletion(); } else if (completionType == this.COMPLETE_BACKWARD) { popup.selectPreviousItem(); } else if (completionType == this.COMPLETE_FORWARD) { popup.selectNextItem(); } callback && callback(this); this.emit("autocomplete-updated"); }, onAutocompleteSelect: function () { // Render the suggestion only if the cursor is at the end of the input. if (this.inputNode.selectionStart != this.getInputValue().length) { return; } let currentItem = this.autocompletePopup.selectedItem; if (currentItem && this.lastCompletion.value) { let suffix = currentItem.label.substring(this.lastCompletion.matchProp.length); this.updateCompleteNode(suffix); } else { this.updateCompleteNode(""); } }, /** * Clear the current completion information and close the autocomplete popup, * if needed. */ clearCompletion: function () { this.autocompletePopup.clearItems(); this.lastCompletion = { value: null }; this.updateCompleteNode(""); if (this.autocompletePopup.isOpen) { // Trigger a blur/focus of the JSTerm input to force screen readers to read the // value again. this.inputNode.blur(); this.autocompletePopup.once("popup-closed", () => { this.inputNode.focus(); }); this.autocompletePopup.hidePopup(); this._autocompletePopupNavigated = false; } }, /** * Accept the proposed input completion. * * @return boolean * True if there was a selected completion item and the input value * was updated, false otherwise. */ acceptProposedCompletion: function () { let updated = false; let currentItem = this.autocompletePopup.selectedItem; if (currentItem && this.lastCompletion.value) { let suffix = currentItem.label.substring(this.lastCompletion.matchProp.length); let cursor = this.inputNode.selectionStart; let value = this.getInputValue(); this.setInputValue(value.substr(0, cursor) + suffix + value.substr(cursor)); let newCursor = cursor + suffix.length; this.inputNode.selectionStart = this.inputNode.selectionEnd = newCursor; updated = true; } this.clearCompletion(); return updated; }, /** * Update the node that displays the currently selected autocomplete proposal. * * @param string suffix * The proposed suffix for the inputNode value. */ updateCompleteNode: function (suffix) { // completion prefix = input, with non-control chars replaced by spaces let prefix = suffix ? this.getInputValue().replace(/[\S]/g, " ") : ""; this.completeNode.value = prefix + suffix; }, /** * Destroy the sidebar. * @private */ _sidebarDestroy: function () { if (this._variablesView) { this._variablesView.controller.releaseActors(); this._variablesView = null; } if (this.sidebar) { this.sidebar.hide(); this.sidebar.destroy(); this.sidebar = null; } this.emit("sidebar-closed"); }, /** * Destroy the JSTerm object. Call this method to avoid memory leaks. */ destroy: function () { this._sidebarDestroy(); this.clearCompletion(); this.clearOutput(); this.autocompletePopup.destroy(); this.autocompletePopup = null; if (this._onPaste) { this.inputNode.removeEventListener("paste", this._onPaste, false); this.inputNode.removeEventListener("drop", this._onPaste, false); this._onPaste = null; } this.inputNode.removeEventListener("keypress", this._keyPress, false); this.inputNode.removeEventListener("input", this._inputEventHandler, false); this.inputNode.removeEventListener("keyup", this._inputEventHandler, false); this.inputNode.removeEventListener("focus", this._focusEventHandler, false); this.hud.window.removeEventListener("blur", this._blurEventHandler, false); this.hud = null; }, }; function gSequenceId() { return gSequenceId.n++; } gSequenceId.n = 0; exports.gSequenceId = gSequenceId; /** * @see VariablesView.simpleValueEvalMacro */ function simpleValueEvalMacro(item, currentString) { return VariablesView.simpleValueEvalMacro(item, currentString, "_self"); } /** * @see VariablesView.overrideValueEvalMacro */ function overrideValueEvalMacro(item, currentString) { return VariablesView.overrideValueEvalMacro(item, currentString, "_self"); } /** * @see VariablesView.getterOrSetterEvalMacro */ function getterOrSetterEvalMacro(item, currentString) { return VariablesView.getterOrSetterEvalMacro(item, currentString, "_self"); } exports.JSTerm = JSTerm;