gecko-dev/devtools/client/webconsole/jsterm.js

1726 строки
54 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft= javascript ts=2 et sw=2 tw=80: */
/* 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 {Cc, Ci, Cu} = require("chrome");
const {Utils: WebConsoleUtils, CONSOLE_WORKER_IDS} =
require("devtools/shared/webconsole/utils");
const promise = require("promise");
const Debugger = require("Debugger");
const Services = require("Services");
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 = "chrome://devtools/locale/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),
panelId: "webConsole_autocompletePopup",
listBoxId: "webConsole_autocompletePopupListBox",
position: "before_start",
theme: "auto",
direction: "ltr",
autoSelect: true
};
this.autocompletePopup = new AutocompletePopup(this.hud.document,
autocompleteOptions);
let doc = this.hud.document;
let inputContainer = doc.querySelector(".jsterm-input-container");
this.completeNode = doc.querySelector(".jsterm-complete-node");
this.inputNode = doc.querySelector(".jsterm-input-node");
if (this.hud.owner._browserConsole &&
!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) {
Cu.reportError("Evaluation error " + response.error + ": " +
response.message);
return;
}
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();
}
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;
}
let msg = new Messages.JavaScriptEvalOutput(response, errorMessage);
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 = function(msg) {
deferred.resolve(msg);
if (callback) {
callback(msg);
}
};
// attempt to execute the content of the inputNode
executeString = executeString || this.getInputValue();
if (!executeString) {
return;
}
let selectedNodeActor = null;
let inspectorSelection = this.hud.owner.getInspectorSelection();
if (inspectorSelection && inspectorSelection.nodeFront) {
selectedNodeActor = inspectorSelection.nodeFront.actorID;
}
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, 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 != Ci.nsIDOMKeyEvent.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.owner._browserConsole ||
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) {
Cu.reportError("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();
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();
}
},
/**
* 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 == Ci.nsIDOMKeyEvent.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 Ci.nsIDOMKeyEvent.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 Ci.nsIDOMKeyEvent.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 Ci.nsIDOMKeyEvent.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 Ci.nsIDOMKeyEvent.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 Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP:
if (this.autocompletePopup.isOpen) {
inputUpdated = this.complete(this.COMPLETE_PAGEUP);
if (inputUpdated) {
this._autocompletePopupNavigated = true;
}
} else {
this.hud.outputWrapper.scrollTop =
Math.max(0,
this.hud.outputWrapper.scrollTop -
this.hud.outputWrapper.clientHeight
);
}
event.preventDefault();
break;
case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN:
if (this.autocompletePopup.isOpen) {
inputUpdated = this.complete(this.COMPLETE_PAGEDOWN);
if (inputUpdated) {
this._autocompletePopupNavigated = true;
}
} else {
this.hud.outputWrapper.scrollTop =
Math.min(this.hud.outputWrapper.scrollHeight,
this.hud.outputWrapper.scrollTop +
this.hud.outputWrapper.clientHeight
);
}
event.preventDefault();
break;
case Ci.nsIDOMKeyEvent.DOM_VK_HOME:
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectedIndex = 0;
event.preventDefault();
} else if (inputValue.length <= 0) {
this.hud.outputWrapper.scrollTop = 0;
event.preventDefault();
}
break;
case Ci.nsIDOMKeyEvent.DOM_VK_END:
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectedIndex =
this.autocompletePopup.itemCount - 1;
event.preventDefault();
} else if (inputValue.length <= 0) {
this.hud.outputWrapper.scrollTop =
this.hud.outputWrapper.scrollHeight;
event.preventDefault();
}
break;
case Ci.nsIDOMKeyEvent.DOM_VK_LEFT:
if (this.autocompletePopup.isOpen || this.lastCompletion.value) {
this.clearCompletion();
}
break;
case Ci.nsIDOMKeyEvent.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 Ci.nsIDOMKeyEvent.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;
}
},
/**
* 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) {
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;
let popup = this.hud.owner.chromeWindow.document
.getElementById("webConsole_autocompletePopup");
if (popup) {
popup.parentNode.removeChild(popup);
}
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;