diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /devtools/client/webconsole/webconsole.js | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/client/webconsole/webconsole.js')
-rw-r--r-- | devtools/client/webconsole/webconsole.js | 3658 |
1 files changed, 3658 insertions, 0 deletions
diff --git a/devtools/client/webconsole/webconsole.js b/devtools/client/webconsole/webconsole.js new file mode 100644 index 000000000..bd7f90a0e --- /dev/null +++ b/devtools/client/webconsole/webconsole.js @@ -0,0 +1,3658 @@ +/* -*- 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/client/webconsole/utils"); +const { getSourceNames } = require("devtools/client/shared/source-utils"); +const BrowserLoaderModule = {}; +Cu.import("resource://devtools/client/shared/browser-loader.js", BrowserLoaderModule); + +const promise = require("promise"); +const Services = require("Services"); +const ErrorDocs = require("devtools/server/actors/errordocs"); +const Telemetry = require("devtools/client/shared/telemetry"); + +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, "ConsoleOutput", "devtools/client/webconsole/console-output", true); +loader.lazyRequireGetter(this, "Messages", "devtools/client/webconsole/console-output", true); +loader.lazyRequireGetter(this, "EnvironmentClient", "devtools/shared/client/main", true); +loader.lazyRequireGetter(this, "ObjectClient", "devtools/shared/client/main", true); +loader.lazyRequireGetter(this, "system", "devtools/shared/system"); +loader.lazyRequireGetter(this, "JSTerm", "devtools/client/webconsole/jsterm", true); +loader.lazyRequireGetter(this, "gSequenceId", "devtools/client/webconsole/jsterm", 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); +loader.lazyRequireGetter(this, "KeyShortcuts", "devtools/client/shared/key-shortcuts", true); +loader.lazyRequireGetter(this, "ZoomKeys", "devtools/client/shared/zoom-keys"); + +const {PluralForm} = require("devtools/shared/plural-form"); +const STRINGS_URI = "devtools/client/locales/webconsole.properties"; +var l10n = new WebConsoleUtils.L10n(STRINGS_URI); + +const XHTML_NS = "http://www.w3.org/1999/xhtml"; + +const MIXED_CONTENT_LEARN_MORE = "https://developer.mozilla.org/docs/Web/Security/Mixed_content"; + +const IGNORED_SOURCE_URLS = ["debugger eval code"]; + +// The amount of time in milliseconds that we wait before performing a live +// search. +const SEARCH_DELAY = 200; + +// The number of lines that are displayed in the console output by default, for +// each category. The user can change this number by adjusting the hidden +// "devtools.hud.loglimit.{network,cssparser,exception,console}" preferences. +const DEFAULT_LOG_LIMIT = 1000; + +// The various categories of messages. We start numbering at zero so we can +// use these as indexes into the MESSAGE_PREFERENCE_KEYS matrix below. +const CATEGORY_NETWORK = 0; +const CATEGORY_CSS = 1; +const CATEGORY_JS = 2; +const CATEGORY_WEBDEV = 3; +// always on +const CATEGORY_INPUT = 4; +// always on +const CATEGORY_OUTPUT = 5; +const CATEGORY_SECURITY = 6; +const CATEGORY_SERVER = 7; + +// The possible message severities. As before, we start at zero so we can use +// these as indexes into MESSAGE_PREFERENCE_KEYS. +const SEVERITY_ERROR = 0; +const SEVERITY_WARNING = 1; +const SEVERITY_INFO = 2; +const SEVERITY_LOG = 3; + +// The fragment of a CSS class name that identifies each category. +const CATEGORY_CLASS_FRAGMENTS = [ + "network", + "cssparser", + "exception", + "console", + "input", + "output", + "security", + "server", +]; + +// The fragment of a CSS class name that identifies each severity. +const SEVERITY_CLASS_FRAGMENTS = [ + "error", + "warn", + "info", + "log", +]; + +// The preference keys to use for each category/severity combination, indexed +// first by category (rows) and then by severity (columns) in the following +// order: +// +// [ Error, Warning, Info, Log ] +// +// Most of these rather idiosyncratic names are historical and predate the +// division of message type into "category" and "severity". +const MESSAGE_PREFERENCE_KEYS = [ + // Network + [ "network", "netwarn", "netxhr", "networkinfo", ], + // CSS + [ "csserror", "cssparser", null, "csslog", ], + // JS + [ "exception", "jswarn", null, "jslog", ], + // Web Developer + [ "error", "warn", "info", "log", ], + // Input + [ null, null, null, null, ], + // Output + [ null, null, null, null, ], + // Security + [ "secerror", "secwarn", null, null, ], + // Server Logging + [ "servererror", "serverwarn", "serverinfo", "serverlog", ], +]; + +// A mapping from the console API log event levels to the Web Console +// severities. +const LEVELS = { + error: SEVERITY_ERROR, + exception: SEVERITY_ERROR, + assert: SEVERITY_ERROR, + warn: SEVERITY_WARNING, + info: SEVERITY_INFO, + log: SEVERITY_LOG, + clear: SEVERITY_LOG, + trace: SEVERITY_LOG, + table: SEVERITY_LOG, + debug: SEVERITY_LOG, + dir: SEVERITY_LOG, + dirxml: SEVERITY_LOG, + group: SEVERITY_LOG, + groupCollapsed: SEVERITY_LOG, + groupEnd: SEVERITY_LOG, + time: SEVERITY_LOG, + timeEnd: SEVERITY_LOG, + count: SEVERITY_LOG +}; + +// This array contains the prefKey for the workers and it must keep them in the +// same order as CONSOLE_WORKER_IDS +const WORKERTYPES_PREFKEYS = + [ "sharedworkers", "serviceworkers", "windowlessworkers" ]; + +// The lowest HTTP response code (inclusive) that is considered an error. +const MIN_HTTP_ERROR_CODE = 400; +// The highest HTTP response code (inclusive) that is considered an error. +const MAX_HTTP_ERROR_CODE = 599; + +// The indent of a console group in pixels. +const GROUP_INDENT = 12; + +// The number of messages to display in a single display update. If we display +// too many messages at once we slow down the Firefox UI too much. +const MESSAGES_IN_INTERVAL = DEFAULT_LOG_LIMIT; + +// The delay (in milliseconds) between display updates - tells how often we +// should *try* to push new messages to screen. This value is optimistic, +// updates won't always happen. Keep this low so the Web Console output feels +// live. +const OUTPUT_INTERVAL = 20; + +// The maximum amount of time (in milliseconds) that can be spent doing cleanup +// inside of the flush output callback. If things don't get cleaned up in this +// time, then it will start again the next time it is called. +const MAX_CLEANUP_TIME = 10; + +// When the output queue has more than MESSAGES_IN_INTERVAL items we throttle +// output updates to this number of milliseconds. So during a lot of output we +// update every N milliseconds given here. +const THROTTLE_UPDATES = 1000; + +// The preference prefix for all of the Web Console filters. +const FILTER_PREFS_PREFIX = "devtools.webconsole.filter."; + +// The minimum font size. +const MIN_FONT_SIZE = 10; + +const PREF_CONNECTION_TIMEOUT = "devtools.debugger.remote-timeout"; +const PREF_PERSISTLOG = "devtools.webconsole.persistlog"; +const PREF_MESSAGE_TIMESTAMP = "devtools.webconsole.timestampMessages"; +const PREF_NEW_FRONTEND_ENABLED = "devtools.webconsole.new-frontend-enabled"; + +/** + * A WebConsoleFrame instance is an interactive console initialized *per target* + * that displays console log data as well as provides an interactive terminal to + * manipulate the target's document content. + * + * The WebConsoleFrame is responsible for the actual Web Console UI + * implementation. + * + * @constructor + * @param object webConsoleOwner + * The WebConsole owner object. + */ +function WebConsoleFrame(webConsoleOwner) { + this.owner = webConsoleOwner; + this.hudId = this.owner.hudId; + this.isBrowserConsole = this.owner._browserConsole; + + this.window = this.owner.iframeWindow; + + this._repeatNodes = {}; + this._outputQueue = []; + this._itemDestroyQueue = []; + this._pruneCategoriesQueue = {}; + this.filterPrefs = {}; + + this.output = new ConsoleOutput(this); + + this.unmountMessage = this.unmountMessage.bind(this); + this._toggleFilter = this._toggleFilter.bind(this); + this.resize = this.resize.bind(this); + this._onPanelSelected = this._onPanelSelected.bind(this); + this._flushMessageQueue = this._flushMessageQueue.bind(this); + this._onToolboxPrefChanged = this._onToolboxPrefChanged.bind(this); + this._onUpdateListeners = this._onUpdateListeners.bind(this); + + this._outputTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this._outputTimerInitialized = false; + + let require = BrowserLoaderModule.BrowserLoader({ + window: this.window, + useOnlyShared: true + }).require; + + this.React = require("devtools/client/shared/vendor/react"); + this.ReactDOM = require("devtools/client/shared/vendor/react-dom"); + this.FrameView = this.React.createFactory(require("devtools/client/shared/components/frame")); + this.StackTraceView = this.React.createFactory(require("devtools/client/shared/components/stack-trace")); + + this._telemetry = new Telemetry(); + + EventEmitter.decorate(this); +} +exports.WebConsoleFrame = WebConsoleFrame; + +WebConsoleFrame.prototype = { + /** + * The WebConsole instance that owns this frame. + * @see hudservice.js::WebConsole + * @type object + */ + owner: null, + + /** + * Proxy between the Web Console and the remote Web Console instance. This + * object holds methods used for connecting, listening and disconnecting from + * the remote server, using the remote debugging protocol. + * + * @see WebConsoleConnectionProxy + * @type object + */ + proxy: null, + + /** + * Getter for the xul:popupset that holds any popups we open. + * @type nsIDOMElement + */ + get popupset() { + return this.owner.mainPopupSet; + }, + + /** + * Holds the initialization promise object. + * @private + * @type object + */ + _initDefer: null, + + /** + * Last time when we displayed any message in the output. + * + * @private + * @type number + * Timestamp in milliseconds since the Unix epoch. + */ + _lastOutputFlush: 0, + + /** + * Message nodes are stored here in a queue for later display. + * + * @private + * @type array + */ + _outputQueue: null, + + /** + * Keep track of the categories we need to prune from time to time. + * + * @private + * @type array + */ + _pruneCategoriesQueue: null, + + /** + * Function invoked whenever the output queue is emptied. This is used by some + * tests. + * + * @private + * @type function + */ + _flushCallback: null, + + /** + * Timer used for flushing the messages output queue. + * + * @private + * @type nsITimer + */ + _outputTimer: null, + _outputTimerInitialized: null, + + /** + * Store for tracking repeated nodes. + * @private + * @type object + */ + _repeatNodes: null, + + /** + * Preferences for filtering messages by type. + * @see this._initDefaultFilterPrefs() + * @type object + */ + filterPrefs: null, + + /** + * Prefix used for filter preferences. + * @private + * @type string + */ + _filterPrefsPrefix: FILTER_PREFS_PREFIX, + + /** + * The nesting depth of the currently active console group. + */ + groupDepth: 0, + + /** + * The current target location. + * @type string + */ + contentLocation: "", + + /** + * The JSTerm object that manage the console's input. + * @see JSTerm + * @type object + */ + jsterm: null, + + /** + * The element that holds all of the messages we display. + * @type nsIDOMElement + */ + outputNode: null, + + /** + * The ConsoleOutput instance that manages all output. + * @type object + */ + output: null, + + /** + * The input element that allows the user to filter messages by string. + * @type nsIDOMElement + */ + filterBox: null, + + /** + * Getter for the debugger WebConsoleClient. + * @type object + */ + get webConsoleClient() { + return this.proxy ? this.proxy.webConsoleClient : null; + }, + + _destroyer: null, + + _saveRequestAndResponseBodies: true, + _throttleData: null, + + // Chevron width at the starting of Web Console's input box. + _chevronWidth: 0, + // Width of the monospace characters in Web Console's input box. + _inputCharWidth: 0, + + /** + * Setter for saving of network request and response bodies. + * + * @param boolean value + * The new value you want to set. + */ + setSaveRequestAndResponseBodies: function (value) { + if (!this.webConsoleClient) { + // Don't continue if the webconsole disconnected. + return promise.resolve(null); + } + + let deferred = promise.defer(); + let newValue = !!value; + let toSet = { + "NetworkMonitor.saveRequestAndResponseBodies": newValue, + }; + + // Make sure the web console client connection is established first. + this.webConsoleClient.setPreferences(toSet, response => { + if (!response.error) { + this._saveRequestAndResponseBodies = newValue; + deferred.resolve(response); + } else { + deferred.reject(response.error); + } + }); + + return deferred.promise; + }, + + /** + * Setter for throttling data. + * + * @param boolean value + * The new value you want to set; @see NetworkThrottleManager. + */ + setThrottleData: function(value) { + if (!this.webConsoleClient) { + // Don't continue if the webconsole disconnected. + return promise.resolve(null); + } + + let deferred = promise.defer(); + let toSet = { + "NetworkMonitor.throttleData": value, + }; + + // Make sure the web console client connection is established first. + this.webConsoleClient.setPreferences(toSet, response => { + if (!response.error) { + this._throttleData = value; + deferred.resolve(response); + } else { + deferred.reject(response.error); + } + }); + + return deferred.promise; + }, + + /** + * Getter for the persistent logging preference. + * @type boolean + */ + get persistLog() { + // For the browser console, we receive tab navigation + // when the original top level window we attached to is closed, + // but we don't want to reset console history and just switch to + // the next available window. + return this.isBrowserConsole || + Services.prefs.getBoolPref(PREF_PERSISTLOG); + }, + + /** + * Initialize the WebConsoleFrame instance. + * @return object + * A promise object that resolves once the frame is ready to use. + */ + init: function () { + this._initUI(); + let connectionInited = this._initConnection(); + + // Don't reject if the history fails to load for some reason. + // This would be fine, the panel will just start with empty history. + let allReady = this.jsterm.historyLoaded.catch(() => {}).then(() => { + return connectionInited; + }); + + // This notification is only used in tests. Don't chain it onto + // the returned promise because the console panel needs to be attached + // to the toolbox before the web-console-created event is receieved. + let notifyObservers = () => { + let id = WebConsoleUtils.supportsString(this.hudId); + Services.obs.notifyObservers(id, "web-console-created", null); + }; + allReady.then(notifyObservers, notifyObservers); + + if (this.NEW_CONSOLE_OUTPUT_ENABLED) { + allReady.then(this.newConsoleOutput.init); + } + + return allReady; + }, + + /** + * Connect to the server using the remote debugging protocol. + * + * @private + * @return object + * A promise object that is resolved/reject based on the connection + * result. + */ + _initConnection: function () { + if (this._initDefer) { + return this._initDefer.promise; + } + + this._initDefer = promise.defer(); + this.proxy = new WebConsoleConnectionProxy(this, this.owner.target); + + this.proxy.connect().then(() => { + // on success + this._initDefer.resolve(this); + }, (reason) => { + // on failure + let node = this.createMessageNode(CATEGORY_JS, SEVERITY_ERROR, + reason.error + ": " + reason.message); + this.outputMessage(CATEGORY_JS, node, [reason]); + this._initDefer.reject(reason); + }); + + return this._initDefer.promise; + }, + + /** + * Find the Web Console UI elements and setup event listeners as needed. + * @private + */ + _initUI: function () { + this.document = this.window.document; + this.rootElement = this.document.documentElement; + this.NEW_CONSOLE_OUTPUT_ENABLED = !this.isBrowserConsole + && !this.owner.target.chrome + && Services.prefs.getBoolPref(PREF_NEW_FRONTEND_ENABLED); + + this.outputNode = this.document.getElementById("output-container"); + this.outputWrapper = this.document.getElementById("output-wrapper"); + this.completeNode = this.document.querySelector(".jsterm-complete-node"); + this.inputNode = this.document.querySelector(".jsterm-input-node"); + + // In the old frontend, the area that scrolls is outputWrapper, but in the new + // frontend this will be reassigned. + this.outputScroller = this.outputWrapper; + + // Update the character width and height needed for the popup offset + // calculations. + this._updateCharSize(); + + this.jsterm = new JSTerm(this); + this.jsterm.init(); + + let toolbox = gDevTools.getToolbox(this.owner.target); + + if (this.NEW_CONSOLE_OUTPUT_ENABLED) { + // @TODO Remove this once JSTerm is handled with React/Redux. + this.window.jsterm = this.jsterm; + + // Remove context menu for now (see Bug 1307239). + this.outputWrapper.removeAttribute("context"); + + // XXX: We should actually stop output from happening on old output + // panel, but for now let's just hide it. + this.experimentalOutputNode = this.outputNode.cloneNode(); + this.experimentalOutputNode.removeAttribute("tabindex"); + this.outputNode.hidden = true; + this.outputNode.parentNode.appendChild(this.experimentalOutputNode); + // @TODO Once the toolbox has been converted to React, see if passing + // in JSTerm is still necessary. + + this.newConsoleOutput = new this.window.NewConsoleOutput( + this.experimentalOutputNode, this.jsterm, toolbox, this.owner, this.document); + + let filterToolbar = this.document.querySelector(".hud-console-filter-toolbar"); + filterToolbar.hidden = true; + } else { + // Register the controller to handle "select all" properly. + this._commandController = new CommandController(this); + this.window.controllers.insertControllerAt(0, this._commandController); + + this._contextMenuHandler = new ConsoleContextMenu(this); + + this._initDefaultFilterPrefs(); + this.filterBox = this.document.querySelector(".hud-filter-box"); + this._setFilterTextBoxEvents(); + this._initFilterButtons(); + let clearButton = + this.document.getElementsByClassName("webconsole-clear-console-button")[0]; + clearButton.addEventListener("command", () => { + this.owner._onClearButton(); + this.jsterm.clearOutput(true); + }); + + } + + this.resize(); + this.window.addEventListener("resize", this.resize, true); + this.jsterm.on("sidebar-opened", this.resize); + this.jsterm.on("sidebar-closed", this.resize); + + if (toolbox) { + toolbox.on("webconsole-selected", this._onPanelSelected); + } + + /* + * Focus the input line whenever the output area is clicked. + */ + this.outputWrapper.addEventListener("click", (event) => { + // Do not focus on middle/right-click or 2+ clicks. + if (event.detail !== 1 || event.button !== 0) { + return; + } + + // Do not focus if something is selected + let selection = this.window.getSelection(); + if (selection && !selection.isCollapsed) { + return; + } + + // Do not focus if a link was clicked + if (event.target.nodeName.toLowerCase() === "a" || + event.target.parentNode.nodeName.toLowerCase() === "a") { + return; + } + + // Do not focus if a search input was clicked on the new frontend + if (this.NEW_CONSOLE_OUTPUT_ENABLED && + event.target.nodeName.toLowerCase() === "input" && + event.target.getAttribute("type").toLowerCase() === "search") { + return; + } + + this.jsterm.focus(); + }); + + // Toggle the timestamp on preference change + gDevTools.on("pref-changed", this._onToolboxPrefChanged); + this._onToolboxPrefChanged("pref-changed", { + pref: PREF_MESSAGE_TIMESTAMP, + newValue: Services.prefs.getBoolPref(PREF_MESSAGE_TIMESTAMP), + }); + + this._initShortcuts(); + + // focus input node + this.jsterm.focus(); + }, + + /** + * Resizes the output node to fit the output wrapped. + * We need this because it makes the layout a lot faster than + * using -moz-box-flex and 100% width. See Bug 1237368. + */ + resize: function () { + if (this.NEW_CONSOLE_OUTPUT_ENABLED) { + this.experimentalOutputNode.style.width = + this.outputWrapper.clientWidth + "px"; + } else { + this.outputNode.style.width = this.outputWrapper.clientWidth + "px"; + } + }, + + /** + * Sets the focus to JavaScript input field when the web console tab is + * selected or when there is a split console present. + * @private + */ + _onPanelSelected: function () { + this.jsterm.focus(); + }, + + /** + * Initialize the default filter preferences. + * @private + */ + _initDefaultFilterPrefs: function () { + let prefs = ["network", "networkinfo", "csserror", "cssparser", "csslog", + "exception", "jswarn", "jslog", "error", "info", "warn", "log", + "secerror", "secwarn", "netwarn", "netxhr", "sharedworkers", + "serviceworkers", "windowlessworkers", "servererror", + "serverwarn", "serverinfo", "serverlog"]; + + for (let pref of prefs) { + this.filterPrefs[pref] = Services.prefs.getBoolPref( + this._filterPrefsPrefix + pref); + } + }, + + _initShortcuts: function() { + var shortcuts = new KeyShortcuts({ + window: this.window + }); + + shortcuts.on(l10n.getStr("webconsole.find.key"), + (name, event) => { + this.filterBox.focus(); + event.preventDefault(); + }); + + let clearShortcut; + if (system.constants.platform === "macosx") { + clearShortcut = l10n.getStr("webconsole.clear.keyOSX"); + } else { + clearShortcut = l10n.getStr("webconsole.clear.key"); + } + shortcuts.on(clearShortcut, + () => this.jsterm.clearOutput(true)); + + if (this.isBrowserConsole) { + shortcuts.on(l10n.getStr("webconsole.close.key"), + this.window.close.bind(this.window)); + + ZoomKeys.register(this.window); + } + }, + + /** + * Attach / detach reflow listeners depending on the checked status + * of the `CSS > Log` menuitem. + * + * @param function [callback=null] + * Optional function to invoke when the listener has been + * added/removed. + */ + _updateReflowActivityListener: function (callback) { + if (this.webConsoleClient) { + let pref = this._filterPrefsPrefix + "csslog"; + if (Services.prefs.getBoolPref(pref)) { + this.webConsoleClient.startListeners(["ReflowActivity"], callback); + } else { + this.webConsoleClient.stopListeners(["ReflowActivity"], callback); + } + } + }, + + /** + * Attach / detach server logging listener depending on the filter + * preferences. If the user isn't interested in the server logs at + * all the listener is not registered. + * + * @param function [callback=null] + * Optional function to invoke when the listener has been + * added/removed. + */ + _updateServerLoggingListener: function (callback) { + if (!this.webConsoleClient) { + return null; + } + + let startListener = false; + let prefs = ["servererror", "serverwarn", "serverinfo", "serverlog"]; + for (let i = 0; i < prefs.length; i++) { + if (this.filterPrefs[prefs[i]]) { + startListener = true; + break; + } + } + + if (startListener) { + this.webConsoleClient.startListeners(["ServerLogging"], callback); + } else { + this.webConsoleClient.stopListeners(["ServerLogging"], callback); + } + }, + + /** + * Sets the events for the filter input field. + * @private + */ + _setFilterTextBoxEvents: function () { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + let timerEvent = this.adjustVisibilityOnSearchStringChange.bind(this); + + let onChange = function _onChange() { + // To improve responsiveness, we let the user finish typing before we + // perform the search. + timer.cancel(); + timer.initWithCallback(timerEvent, SEARCH_DELAY, + Ci.nsITimer.TYPE_ONE_SHOT); + }; + + this.filterBox.addEventListener("command", onChange, false); + this.filterBox.addEventListener("input", onChange, false); + }, + + /** + * Creates one of the filter buttons on the toolbar. + * + * @private + * @param nsIDOMNode aParent + * The node to which the filter button should be appended. + * @param object aDescriptor + * A descriptor that contains info about the button. Contains "name", + * "category", and "prefKey" properties, and optionally a "severities" + * property. + */ + _initFilterButtons: function () { + let categories = this.document + .querySelectorAll(".webconsole-filter-button[category]"); + Array.forEach(categories, function (button) { + button.addEventListener("contextmenu", () => { + button.open = true; + }, false); + button.addEventListener("click", this._toggleFilter, false); + + let someChecked = false; + let severities = button.querySelectorAll("menuitem[prefKey]"); + Array.forEach(severities, function (menuItem) { + menuItem.addEventListener("command", this._toggleFilter, false); + + let prefKey = menuItem.getAttribute("prefKey"); + let checked = this.filterPrefs[prefKey]; + menuItem.setAttribute("checked", checked); + someChecked = someChecked || checked; + }, this); + + button.setAttribute("checked", someChecked); + button.setAttribute("aria-pressed", someChecked); + }, this); + + if (!this.isBrowserConsole) { + // The Browser Console displays nsIConsoleMessages which are messages that + // end up in the JS category, but they are not errors or warnings, they + // are just log messages. The Web Console does not show such messages. + let jslog = this.document.querySelector("menuitem[prefKey=jslog]"); + jslog.hidden = true; + } + + if (Services.appinfo.OS == "Darwin") { + let net = this.document.querySelector("toolbarbutton[category=net]"); + let accesskey = net.getAttribute("accesskeyMacOSX"); + net.setAttribute("accesskey", accesskey); + + let logging = + this.document.querySelector("toolbarbutton[category=logging]"); + logging.removeAttribute("accesskey"); + + let serverLogging = + this.document.querySelector("toolbarbutton[category=server]"); + serverLogging.removeAttribute("accesskey"); + } + }, + + /** + * Calculates the width and height of a single character of the input box. + * This will be used in opening the popup at the correct offset. + * + * @private + */ + _updateCharSize: function () { + let doc = this.document; + let tempLabel = doc.createElementNS(XHTML_NS, "span"); + let style = tempLabel.style; + style.position = "fixed"; + style.padding = "0"; + style.margin = "0"; + style.width = "auto"; + style.color = "transparent"; + WebConsoleUtils.copyTextStyles(this.inputNode, tempLabel); + tempLabel.textContent = "x"; + doc.documentElement.appendChild(tempLabel); + this._inputCharWidth = tempLabel.offsetWidth; + tempLabel.parentNode.removeChild(tempLabel); + // Calculate the width of the chevron placed at the beginning of the input + // box. Remove 4 more pixels to accomodate the padding of the popup. + this._chevronWidth = +doc.defaultView.getComputedStyle(this.inputNode) + .paddingLeft.replace(/[^0-9.]/g, "") - 4; + }, + + /** + * The event handler that is called whenever a user switches a filter on or + * off. + * + * @private + * @param nsIDOMEvent event + * The event that triggered the filter change. + */ + _toggleFilter: function (event) { + let target = event.target; + let tagName = target.tagName; + // Prevent toggle if generated from a contextmenu event (right click) + let isRightClick = (event.button === 2); + if (tagName != event.currentTarget.tagName || isRightClick) { + return; + } + + switch (tagName) { + case "toolbarbutton": { + let originalTarget = event.originalTarget; + let classes = originalTarget.classList; + + if (originalTarget.localName !== "toolbarbutton") { + // Oddly enough, the click event is sent to the menu button when + // selecting a menu item with the mouse. Detect this case and bail + // out. + break; + } + + if (!classes.contains("toolbarbutton-menubutton-button") && + originalTarget.getAttribute("type") === "menu-button") { + // This is a filter button with a drop-down. The user clicked the + // drop-down, so do nothing. (The menu will automatically appear + // without our intervention.) + break; + } + + // Toggle on the targeted filter button, and if the user alt clicked, + // toggle off all other filter buttons and their associated filters. + let state = target.getAttribute("checked") !== "true"; + if (event.getModifierState("Alt")) { + let buttons = this.document + .querySelectorAll(".webconsole-filter-button"); + Array.forEach(buttons, (button) => { + if (button !== target) { + button.setAttribute("checked", false); + button.setAttribute("aria-pressed", false); + this._setMenuState(button, false); + } + }); + state = true; + } + target.setAttribute("checked", state); + target.setAttribute("aria-pressed", state); + + // This is a filter button with a drop-down, and the user clicked the + // main part of the button. Go through all the severities and toggle + // their associated filters. + this._setMenuState(target, state); + + // CSS reflow logging can decrease web page performance. + // Make sure the option is always unchecked when the CSS filter button + // is selected. See bug 971798. + if (target.getAttribute("category") == "css" && state) { + let csslogMenuItem = target.querySelector("menuitem[prefKey=csslog]"); + csslogMenuItem.setAttribute("checked", false); + this.setFilterState("csslog", false); + } + + break; + } + + case "menuitem": { + let state = target.getAttribute("checked") !== "true"; + target.setAttribute("checked", state); + + let prefKey = target.getAttribute("prefKey"); + this.setFilterState(prefKey, state); + + // Adjust the state of the button appropriately. + let menuPopup = target.parentNode; + + let someChecked = false; + let menuItem = menuPopup.firstChild; + while (menuItem) { + if (menuItem.hasAttribute("prefKey") && + menuItem.getAttribute("checked") === "true") { + someChecked = true; + break; + } + menuItem = menuItem.nextSibling; + } + let toolbarButton = menuPopup.parentNode; + toolbarButton.setAttribute("checked", someChecked); + toolbarButton.setAttribute("aria-pressed", someChecked); + break; + } + } + }, + + /** + * Set the menu attributes for a specific toggle button. + * + * @private + * @param XULElement target + * Button with drop down items to be toggled. + * @param boolean state + * True if the menu item is being toggled on, and false otherwise. + */ + _setMenuState: function (target, state) { + let menuItems = target.querySelectorAll("menuitem"); + Array.forEach(menuItems, (item) => { + item.setAttribute("checked", state); + let prefKey = item.getAttribute("prefKey"); + this.setFilterState(prefKey, state); + }); + }, + + /** + * Set the filter state for a specific toggle button. + * + * @param string toggleType + * @param boolean state + * @returns void + */ + setFilterState: function (toggleType, state) { + this.filterPrefs[toggleType] = state; + this.adjustVisibilityForMessageType(toggleType, state); + + Services.prefs.setBoolPref(this._filterPrefsPrefix + toggleType, state); + + if (this._updateListenersTimeout) { + clearTimeout(this._updateListenersTimeout); + } + + this._updateListenersTimeout = setTimeout( + this._onUpdateListeners, 200); + }, + + /** + * Get the filter state for a specific toggle button. + * + * @param string toggleType + * @returns boolean + */ + getFilterState: function (toggleType) { + return this.filterPrefs[toggleType]; + }, + + /** + * Called when a logging filter changes. Allows to stop/start + * listeners according to the current filter state. + */ + _onUpdateListeners: function () { + this._updateReflowActivityListener(); + this._updateServerLoggingListener(); + }, + + /** + * Check that the passed string matches the filter arguments. + * + * @param String str + * to search for filter words in. + * @param String filter + * is a string containing all of the words to filter on. + * @returns boolean + */ + stringMatchesFilters: function (str, filter) { + if (!filter || !str) { + return true; + } + + let searchStr = str.toLowerCase(); + let filterStrings = filter.toLowerCase().split(/\s+/); + return !filterStrings.some(function (f) { + return searchStr.indexOf(f) == -1; + }); + }, + + /** + * Turns the display of log nodes on and off appropriately to reflect the + * adjustment of the message type filter named by @prefKey. + * + * @param string prefKey + * The preference key for the message type being filtered: one of the + * values in the MESSAGE_PREFERENCE_KEYS table. + * @param boolean state + * True if the filter named by @messageType is being turned on; false + * otherwise. + * @returns void + */ + adjustVisibilityForMessageType: function (prefKey, state) { + let outputNode = this.outputNode; + let doc = this.document; + + // Look for message nodes (".message") with the given preference key + // (filter="error", filter="cssparser", etc.) and add or remove the + // "filtered-by-type" class, which turns on or off the display. + + let attribute = WORKERTYPES_PREFKEYS.indexOf(prefKey) == -1 + ? "filter" : "workerType"; + + let xpath = ".//*[contains(@class, 'message') and " + + "@" + attribute + "='" + prefKey + "']"; + let result = doc.evaluate(xpath, outputNode, null, + Ci.nsIDOMXPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null); + for (let i = 0; i < result.snapshotLength; i++) { + let node = result.snapshotItem(i); + if (state) { + node.classList.remove("filtered-by-type"); + } else { + node.classList.add("filtered-by-type"); + } + } + }, + + /** + * Turns the display of log nodes on and off appropriately to reflect the + * adjustment of the search string. + */ + adjustVisibilityOnSearchStringChange: function () { + let nodes = this.outputNode.getElementsByClassName("message"); + let searchString = this.filterBox.value; + + for (let i = 0, n = nodes.length; i < n; ++i) { + let node = nodes[i]; + + // hide nodes that match the strings + let text = node.textContent; + + // if the text matches the words in aSearchString... + if (this.stringMatchesFilters(text, searchString)) { + node.classList.remove("filtered-by-string"); + } else { + node.classList.add("filtered-by-string"); + } + } + + this.resize(); + }, + + /** + * Applies the user's filters to a newly-created message node via CSS + * classes. + * + * @param nsIDOMNode node + * The newly-created message node. + * @return boolean + * True if the message was filtered or false otherwise. + */ + filterMessageNode: function (node) { + let isFiltered = false; + + // Filter by the message type. + let prefKey = MESSAGE_PREFERENCE_KEYS[node.category][node.severity]; + if (prefKey && !this.getFilterState(prefKey)) { + // The node is filtered by type. + node.classList.add("filtered-by-type"); + isFiltered = true; + } + + // Filter by worker type + if ("workerType" in node && !this.getFilterState(node.workerType)) { + node.classList.add("filtered-by-type"); + isFiltered = true; + } + + // Filter on the search string. + let search = this.filterBox.value; + let text = node.clipboardText; + + // if string matches the filter text + if (!this.stringMatchesFilters(text, search)) { + node.classList.add("filtered-by-string"); + isFiltered = true; + } + + if (isFiltered && node.classList.contains("inlined-variables-view")) { + node.classList.add("hidden-message"); + } + + return isFiltered; + }, + + /** + * Merge the attributes of repeated nodes. + * + * @param nsIDOMNode original + * The Original Node. The one being merged into. + */ + mergeFilteredMessageNode: function (original) { + let repeatNode = original.getElementsByClassName("message-repeats")[0]; + if (!repeatNode) { + // no repeat node, return early. + return; + } + + let occurrences = parseInt(repeatNode.getAttribute("value"), 10) + 1; + repeatNode.setAttribute("value", occurrences); + repeatNode.textContent = occurrences; + let str = l10n.getStr("messageRepeats.tooltip2"); + repeatNode.title = PluralForm.get(occurrences, str) + .replace("#1", occurrences); + }, + + /** + * Filter the message node from the output if it is a repeat. + * + * @private + * @param nsIDOMNode node + * The message node to be filtered or not. + * @returns nsIDOMNode|null + * Returns the duplicate node if the message was filtered, null + * otherwise. + */ + _filterRepeatedMessage: function (node) { + let repeatNode = node.getElementsByClassName("message-repeats")[0]; + if (!repeatNode) { + return null; + } + + let uid = repeatNode._uid; + let dupeNode = null; + + if (node.category == CATEGORY_CSS || + node.category == CATEGORY_SECURITY) { + dupeNode = this._repeatNodes[uid]; + if (!dupeNode) { + this._repeatNodes[uid] = node; + } + } else if ((node.category == CATEGORY_WEBDEV || + node.category == CATEGORY_JS) && + node.category != CATEGORY_NETWORK && + !node.classList.contains("inlined-variables-view")) { + let lastMessage = this.outputNode.lastChild; + if (!lastMessage) { + return null; + } + + let lastRepeatNode = + lastMessage.getElementsByClassName("message-repeats")[0]; + if (lastRepeatNode && lastRepeatNode._uid == uid) { + dupeNode = lastMessage; + } + } + + if (dupeNode) { + this.mergeFilteredMessageNode(dupeNode); + // Even though this node was never rendered, we create the location + // nodes before rendering, so we still have to clean up any + // React components + this.unmountMessage(node); + return dupeNode; + } + + return null; + }, + + /** + * Display cached messages that may have been collected before the UI is + * displayed. + * + * @param array remoteMessages + * Array of cached messages coming from the remote Web Console + * content instance. + */ + displayCachedMessages: function (remoteMessages) { + if (!remoteMessages.length) { + return; + } + + remoteMessages.forEach(function (message) { + switch (message._type) { + case "PageError": { + let category = Utils.categoryForScriptError(message); + this.outputMessage(category, this.reportPageError, + [category, message]); + break; + } + case "LogMessage": + this.handleLogMessage(message); + break; + case "ConsoleAPI": + this.outputMessage(CATEGORY_WEBDEV, this.logConsoleAPIMessage, + [message]); + break; + case "NetworkEvent": + this.outputMessage(CATEGORY_NETWORK, this.logNetEvent, [message]); + break; + } + }, this); + }, + + /** + * Logs a message to the Web Console that originates from the Web Console + * server. + * + * @param object message + * The message received from the server. + * @return nsIDOMElement|null + * The message element to display in the Web Console output. + */ + logConsoleAPIMessage: function (message) { + let body = null; + let clipboardText = null; + let sourceURL = message.filename; + let sourceLine = message.lineNumber; + let level = message.level; + let args = message.arguments; + let objectActors = new Set(); + let node = null; + + // Gather the actor IDs. + args.forEach((value) => { + if (WebConsoleUtils.isActorGrip(value)) { + objectActors.add(value.actor); + } + }); + + switch (level) { + case "log": + case "info": + case "warn": + case "error": + case "exception": + case "assert": + case "debug": { + let msg = new Messages.ConsoleGeneric(message); + node = msg.init(this.output).render().element; + break; + } + case "table": { + let msg = new Messages.ConsoleTable(message); + node = msg.init(this.output).render().element; + break; + } + case "trace": { + let msg = new Messages.ConsoleTrace(message); + node = msg.init(this.output).render().element; + break; + } + case "clear": { + body = l10n.getStr("consoleCleared"); + clipboardText = body; + break; + } + case "dir": { + body = { arguments: args }; + let clipboardArray = []; + args.forEach((value) => { + clipboardArray.push(VariablesView.getString(value)); + }); + clipboardText = clipboardArray.join(" "); + break; + } + case "dirxml": { + // We just alias console.dirxml() with console.log(). + message.level = "log"; + return this.logConsoleAPIMessage(message); + } + case "group": + case "groupCollapsed": + clipboardText = body = message.groupName; + this.groupDepth++; + break; + + case "groupEnd": + if (this.groupDepth > 0) { + this.groupDepth--; + } + break; + + case "time": { + let timer = message.timer; + if (!timer) { + return null; + } + if (timer.error) { + console.error(new Error(l10n.getStr(timer.error))); + return null; + } + body = l10n.getFormatStr("timerStarted", [timer.name]); + clipboardText = body; + break; + } + + case "timeEnd": { + let timer = message.timer; + if (!timer) { + return null; + } + let duration = Math.round(timer.duration * 100) / 100; + body = l10n.getFormatStr("timeEnd", [timer.name, duration]); + clipboardText = body; + break; + } + + case "count": { + let counter = message.counter; + if (!counter) { + return null; + } + if (counter.error) { + console.error(l10n.getStr(counter.error)); + return null; + } + let msg = new Messages.ConsoleGeneric(message); + node = msg.init(this.output).render().element; + break; + } + + case "timeStamp": { + // console.timeStamp() doesn't need to display anything. + return null; + } + + default: + console.error(new Error("Unknown Console API log level: " + level)); + return null; + } + + // Release object actors for arguments coming from console API methods that + // we ignore their arguments. + switch (level) { + case "group": + case "groupCollapsed": + case "groupEnd": + case "time": + case "timeEnd": + case "count": + for (let actor of objectActors) { + this._releaseObject(actor); + } + objectActors.clear(); + } + + if (level == "groupEnd") { + // no need to continue + return null; + } + + if (!node) { + node = this.createMessageNode(CATEGORY_WEBDEV, LEVELS[level], body, + sourceURL, sourceLine, clipboardText, + level, message.timeStamp); + if (message.private) { + node.setAttribute("private", true); + } + } + + if (objectActors.size > 0) { + node._objectActors = objectActors; + + if (!node._messageObject) { + let repeatNode = node.getElementsByClassName("message-repeats")[0]; + repeatNode._uid += [...objectActors].join("-"); + } + } + + let workerTypeID = CONSOLE_WORKER_IDS.indexOf(message.workerType); + if (workerTypeID != -1) { + node.workerType = WORKERTYPES_PREFKEYS[workerTypeID]; + node.setAttribute("workerType", WORKERTYPES_PREFKEYS[workerTypeID]); + } + + return node; + }, + + /** + * Handle ConsoleAPICall objects received from the server. This method outputs + * the window.console API call. + * + * @param object message + * The console API message received from the server. + */ + handleConsoleAPICall: function (message) { + this.outputMessage(CATEGORY_WEBDEV, this.logConsoleAPIMessage, [message]); + }, + + /** + * Reports an error in the page source, either JavaScript or CSS. + * + * @param nsIScriptError scriptError + * The error message to report. + * @return nsIDOMElement|undefined + * The message element to display in the Web Console output. + */ + reportPageError: function (category, scriptError) { + // Warnings and legacy strict errors become warnings; other types become + // errors. + let severity = "error"; + if (scriptError.warning || scriptError.strict) { + severity = "warning"; + } else if (scriptError.info) { + severity = "log"; + } + + switch (category) { + case CATEGORY_CSS: + category = "css"; + break; + case CATEGORY_SECURITY: + category = "security"; + break; + default: + category = "js"; + break; + } + + let objectActors = new Set(); + + // Gather the actor IDs. + for (let prop of ["errorMessage", "lineText"]) { + let grip = scriptError[prop]; + if (WebConsoleUtils.isActorGrip(grip)) { + objectActors.add(grip.actor); + } + } + + let errorMessage = scriptError.errorMessage; + if (errorMessage.type && errorMessage.type == "longString") { + errorMessage = errorMessage.initial; + } + + let displayOrigin = scriptError.sourceName; + + // TLS errors are related to the connection and not the resource; therefore + // it makes sense to only display the protcol, host and port (prePath). + // This also means messages are grouped for a single origin. + if (scriptError.category && scriptError.category == "SHA-1 Signature") { + let sourceURI = Services.io.newURI(scriptError.sourceName, null, null) + .QueryInterface(Ci.nsIURL); + displayOrigin = sourceURI.prePath; + } + + // Create a new message + let msg = new Messages.Simple(errorMessage, { + location: { + url: displayOrigin, + line: scriptError.lineNumber, + column: scriptError.columnNumber + }, + stack: scriptError.stacktrace, + category: category, + severity: severity, + timestamp: scriptError.timeStamp, + private: scriptError.private, + filterDuplicates: true + }); + + let node = msg.init(this.output).render().element; + + // Select the body of the message node that is displayed in the console + let msgBody = node.getElementsByClassName("message-body")[0]; + + // Add the more info link node to messages that belong to certain categories + if (scriptError.exceptionDocURL) { + this.addLearnMoreWarningNode(msgBody, scriptError.exceptionDocURL); + } + + // Collect telemetry data regarding JavaScript errors + this._telemetry.logKeyed("DEVTOOLS_JAVASCRIPT_ERROR_DISPLAYED", + scriptError.errorMessageName, + true); + + if (objectActors.size > 0) { + node._objectActors = objectActors; + } + + return node; + }, + + /** + * Handle PageError objects received from the server. This method outputs the + * given error. + * + * @param nsIScriptError pageError + * The error received from the server. + */ + handlePageError: function (pageError) { + let category = Utils.categoryForScriptError(pageError); + this.outputMessage(category, this.reportPageError, [category, pageError]); + }, + + /** + * Handle log messages received from the server. This method outputs the given + * message. + * + * @param object packet + * The message packet received from the server. + */ + handleLogMessage: function (packet) { + if (packet.message) { + this.outputMessage(CATEGORY_JS, this._reportLogMessage, [packet]); + } + }, + + /** + * Display log messages received from the server. + * + * @private + * @param object packet + * The message packet received from the server. + * @return nsIDOMElement + * The message element to render for the given log message. + */ + _reportLogMessage: function (packet) { + let msg = packet.message; + if (msg.type && msg.type == "longString") { + msg = msg.initial; + } + let node = this.createMessageNode(CATEGORY_JS, SEVERITY_LOG, msg, null, + null, null, null, packet.timeStamp); + if (WebConsoleUtils.isActorGrip(packet.message)) { + node._objectActors = new Set([packet.message.actor]); + } + return node; + }, + + /** + * Log network event. + * + * @param object networkInfo + * The network request information to log. + * @return nsIDOMElement|null + * The message element to display in the Web Console output. + */ + logNetEvent: function (networkInfo) { + let actorId = networkInfo.actor; + let request = networkInfo.request; + let clipboardText = request.method + " " + request.url; + let severity = SEVERITY_LOG; + if (networkInfo.isXHR) { + clipboardText = request.method + " XHR " + request.url; + severity = SEVERITY_INFO; + } + let mixedRequest = + WebConsoleUtils.isMixedHTTPSRequest(request.url, this.contentLocation); + if (mixedRequest) { + severity = SEVERITY_WARNING; + } + + let methodNode = this.document.createElementNS(XHTML_NS, "span"); + methodNode.className = "method"; + methodNode.textContent = request.method + " "; + + let messageNode = this.createMessageNode(CATEGORY_NETWORK, severity, + methodNode, null, null, + clipboardText, null, + networkInfo.timeStamp); + if (networkInfo.private) { + messageNode.setAttribute("private", true); + } + messageNode._connectionId = actorId; + messageNode.url = request.url; + + let body = methodNode.parentNode; + body.setAttribute("aria-haspopup", true); + + if (networkInfo.isXHR) { + let xhrNode = this.document.createElementNS(XHTML_NS, "span"); + xhrNode.className = "xhr"; + xhrNode.textContent = l10n.getStr("webConsoleXhrIndicator"); + body.appendChild(xhrNode); + body.appendChild(this.document.createTextNode(" ")); + } + + let displayUrl = request.url; + let pos = displayUrl.indexOf("?"); + if (pos > -1) { + displayUrl = displayUrl.substr(0, pos); + } + + let urlNode = this.document.createElementNS(XHTML_NS, "a"); + urlNode.className = "url"; + urlNode.setAttribute("title", request.url); + urlNode.href = request.url; + urlNode.textContent = displayUrl; + urlNode.draggable = false; + body.appendChild(urlNode); + body.appendChild(this.document.createTextNode(" ")); + + if (mixedRequest) { + messageNode.classList.add("mixed-content"); + this.makeMixedContentNode(body); + } + + let statusNode = this.document.createElementNS(XHTML_NS, "a"); + statusNode.className = "status"; + body.appendChild(statusNode); + + let onClick = () => this.openNetworkPanel(networkInfo.actor); + + this._addMessageLinkCallback(urlNode, onClick); + this._addMessageLinkCallback(statusNode, onClick); + + networkInfo.node = messageNode; + + this._updateNetMessage(actorId); + + if (this.window.NetRequest) { + this.window.NetRequest.onNetworkEvent({ + consoleFrame: this, + response: networkInfo, + node: messageNode, + update: false + }); + } + + return messageNode; + }, + + /** + * Create a mixed content warning Node. + * + * @param linkNode + * Parent to the requested urlNode. + */ + makeMixedContentNode: function (linkNode) { + let mixedContentWarning = + "[" + l10n.getStr("webConsoleMixedContentWarning") + "]"; + + // Mixed content warning message links to a Learn More page + let mixedContentWarningNode = this.document.createElementNS(XHTML_NS, "a"); + mixedContentWarningNode.title = MIXED_CONTENT_LEARN_MORE; + mixedContentWarningNode.href = MIXED_CONTENT_LEARN_MORE; + mixedContentWarningNode.className = "learn-more-link"; + mixedContentWarningNode.textContent = mixedContentWarning; + mixedContentWarningNode.draggable = false; + + linkNode.appendChild(mixedContentWarningNode); + + this._addMessageLinkCallback(mixedContentWarningNode, (event) => { + event.stopPropagation(); + this.owner.openLink(MIXED_CONTENT_LEARN_MORE); + }); + }, + + /* + * Appends a clickable warning node to the node passed + * as a parameter to the function. When a user clicks on the appended + * warning node, the browser navigates to the provided url. + * + * @param node + * The node to which we will be adding a clickable warning node. + * @param url + * The url which points to the page where the user can learn more + * about security issues associated with the specific message that's + * being logged. + */ + addLearnMoreWarningNode: function (node, url) { + let moreInfoLabel = "[" + l10n.getStr("webConsoleMoreInfoLabel") + "]"; + + let warningNode = this.document.createElementNS(XHTML_NS, "a"); + warningNode.title = url.split("?")[0]; + warningNode.href = url; + warningNode.draggable = false; + warningNode.textContent = moreInfoLabel; + warningNode.className = "learn-more-link"; + + this._addMessageLinkCallback(warningNode, (event) => { + event.stopPropagation(); + this.owner.openLink(url); + }); + + node.appendChild(warningNode); + }, + + /** + * Log file activity. + * + * @param string fileURI + * The file URI that was loaded. + * @return nsIDOMElement|undefined + * The message element to display in the Web Console output. + */ + logFileActivity: function (fileURI) { + let urlNode = this.document.createElementNS(XHTML_NS, "a"); + urlNode.setAttribute("title", fileURI); + urlNode.className = "url"; + urlNode.textContent = fileURI; + urlNode.draggable = false; + urlNode.href = fileURI; + + let outputNode = this.createMessageNode(CATEGORY_NETWORK, SEVERITY_LOG, + urlNode, null, null, fileURI); + + this._addMessageLinkCallback(urlNode, () => { + this.owner.viewSource(fileURI); + }); + + return outputNode; + }, + + /** + * Handle the file activity messages coming from the remote Web Console. + * + * @param string fileURI + * The file URI that was requested. + */ + handleFileActivity: function (fileURI) { + this.outputMessage(CATEGORY_NETWORK, this.logFileActivity, [fileURI]); + }, + + /** + * Handle the reflow activity messages coming from the remote Web Console. + * + * @param object msg + * An object holding information about a reflow batch. + */ + logReflowActivity: function (message) { + let {start, end, sourceURL, sourceLine} = message; + let duration = Math.round((end - start) * 100) / 100; + let node = this.document.createElementNS(XHTML_NS, "span"); + if (sourceURL) { + node.textContent = + l10n.getFormatStr("reflow.messageWithLink", [duration]); + let a = this.document.createElementNS(XHTML_NS, "a"); + a.href = "#"; + a.draggable = "false"; + let filename = getSourceNames(sourceURL).short; + let functionName = message.functionName || + l10n.getStr("stacktrace.anonymousFunction"); + a.textContent = l10n.getFormatStr("reflow.messageLinkText", + [functionName, filename, sourceLine]); + this._addMessageLinkCallback(a, () => { + this.owner.viewSourceInDebugger(sourceURL, sourceLine); + }); + node.appendChild(a); + } else { + node.textContent = + l10n.getFormatStr("reflow.messageWithNoLink", [duration]); + } + return this.createMessageNode(CATEGORY_CSS, SEVERITY_LOG, node); + }, + + handleReflowActivity: function (message) { + this.outputMessage(CATEGORY_CSS, this.logReflowActivity, [message]); + }, + + /** + * Inform user that the window.console API has been replaced by a script + * in a content page. + */ + logWarningAboutReplacedAPI: function () { + let node = this.createMessageNode(CATEGORY_JS, SEVERITY_WARNING, + l10n.getStr("ConsoleAPIDisabled")); + this.outputMessage(CATEGORY_JS, node); + }, + + /** + * Handle the network events coming from the remote Web Console. + * + * @param object networkInfo + * The network request information. + */ + handleNetworkEvent: function (networkInfo) { + this.outputMessage(CATEGORY_NETWORK, this.logNetEvent, [networkInfo]); + }, + + /** + * Handle network event updates coming from the server. + * + * @param object networkInfo + * The network request information. + * @param object packet + * Update details. + */ + handleNetworkEventUpdate: function (networkInfo, packet) { + if (networkInfo.node && this._updateNetMessage(packet.from)) { + if (this.window.NetRequest) { + this.window.NetRequest.onNetworkEvent({ + client: this.webConsoleClient, + response: packet, + node: networkInfo.node, + update: true + }); + } + + this.emit("new-messages", new Set([{ + update: true, + node: networkInfo.node, + response: packet, + }])); + } + + // For unit tests we pass the HTTP activity object to the test callback, + // once requests complete. + if (this.owner.lastFinishedRequestCallback && + networkInfo.updates.indexOf("responseContent") > -1 && + networkInfo.updates.indexOf("eventTimings") > -1) { + this.owner.lastFinishedRequestCallback(networkInfo, this); + } + }, + + /** + * Update an output message to reflect the latest state of a network request, + * given a network event actor ID. + * + * @private + * @param string actorId + * The network event actor ID for which you want to update the message. + * @return boolean + * |true| if the message node was updated, or |false| otherwise. + */ + _updateNetMessage: function (actorId) { + let networkInfo = this.webConsoleClient.getNetworkRequest(actorId); + if (!networkInfo || !networkInfo.node) { + return false; + } + + let messageNode = networkInfo.node; + let updates = networkInfo.updates; + let hasEventTimings = updates.indexOf("eventTimings") > -1; + let hasResponseStart = updates.indexOf("responseStart") > -1; + let request = networkInfo.request; + let methodText = (networkInfo.isXHR) ? + request.method + " XHR" : request.method; + let response = networkInfo.response; + let updated = false; + + if (hasEventTimings || hasResponseStart) { + let status = []; + if (response.httpVersion && response.status) { + status = [response.httpVersion, response.status, response.statusText]; + } + if (hasEventTimings) { + status.push(l10n.getFormatStr("NetworkPanel.durationMS", + [networkInfo.totalTime])); + } + let statusText = "[" + status.join(" ") + "]"; + + let statusNode = messageNode.getElementsByClassName("status")[0]; + statusNode.textContent = statusText; + + messageNode.clipboardText = [methodText, request.url, statusText] + .join(" "); + + if (hasResponseStart && response.status >= MIN_HTTP_ERROR_CODE && + response.status <= MAX_HTTP_ERROR_CODE) { + this.setMessageType(messageNode, CATEGORY_NETWORK, SEVERITY_ERROR); + } + + updated = true; + } + + if (messageNode._netPanel) { + messageNode._netPanel.update(); + } + + return updated; + }, + + /** + * Opens the network monitor and highlights the specified request. + * + * @param string requestId + * The actor ID of the network request. + */ + openNetworkPanel: function (requestId) { + let toolbox = gDevTools.getToolbox(this.owner.target); + // The browser console doesn't have a toolbox. + if (!toolbox) { + return; + } + return toolbox.selectTool("netmonitor").then(panel => { + return panel.panelWin.NetMonitorController.inspectRequest(requestId); + }); + }, + + /** + * Handler for page location changes. + * + * @param string uri + * New page location. + * @param string title + * New page title. + */ + onLocationChange: function (uri, title) { + this.contentLocation = uri; + if (this.owner.onLocationChange) { + this.owner.onLocationChange(uri, title); + } + }, + + /** + * Handler for the tabNavigated notification. + * + * @param string event + * Event name. + * @param object packet + * Notification packet received from the server. + */ + handleTabNavigated: function (event, packet) { + if (event == "will-navigate") { + if (this.persistLog) { + if (this.NEW_CONSOLE_OUTPUT_ENABLED) { + // Add a _type to hit convertCachedPacket. + packet._type = true; + this.newConsoleOutput.dispatchMessageAdd(packet); + } else { + let marker = new Messages.NavigationMarker(packet, Date.now()); + this.output.addMessage(marker); + } + } else { + this.jsterm.clearOutput(); + } + } + + if (packet.url) { + this.onLocationChange(packet.url, packet.title); + } + + if (event == "navigate" && !packet.nativeConsoleAPI) { + this.logWarningAboutReplacedAPI(); + } + }, + + /** + * Output a message node. This filters a node appropriately, then sends it to + * the output, regrouping and pruning output as necessary. + * + * Note: this call is async - the given message node may not be displayed when + * you call this method. + * + * @param integer category + * The category of the message you want to output. See the CATEGORY_* + * constants. + * @param function|nsIDOMElement methodOrNode + * The method that creates the message element to send to the output or + * the actual element. If a method is given it will be bound to the HUD + * object and the arguments will be |args|. + * @param array [args] + * If a method is given to output the message element then the method + * will be invoked with the list of arguments given here. The last + * object in this array should be the packet received from the + * back end. + */ + outputMessage: function (category, methodOrNode, args) { + if (!this._outputQueue.length) { + // If the queue is empty we consider that now was the last output flush. + // This avoid an immediate output flush when the timer executes. + this._lastOutputFlush = Date.now(); + } + + this._outputQueue.push([category, methodOrNode, args]); + + this._initOutputTimer(); + }, + + /** + * Try to flush the output message queue. This takes the messages in the + * output queue and displays them. Outputting stops at MESSAGES_IN_INTERVAL. + * Further output is queued to happen later - see OUTPUT_INTERVAL. + * + * @private + */ + _flushMessageQueue: function () { + this._outputTimerInitialized = false; + if (!this._outputTimer) { + return; + } + + let startTime = Date.now(); + let timeSinceFlush = startTime - this._lastOutputFlush; + let shouldThrottle = this._outputQueue.length > MESSAGES_IN_INTERVAL && + timeSinceFlush < THROTTLE_UPDATES; + + // Determine how many messages we can display now. + let toDisplay = Math.min(this._outputQueue.length, MESSAGES_IN_INTERVAL); + + // If there aren't any messages to display (because of throttling or an + // empty queue), then take care of some cleanup. Destroy items that were + // pruned from the outputQueue before being displayed. + if (shouldThrottle || toDisplay < 1) { + while (this._itemDestroyQueue.length) { + if ((Date.now() - startTime) > MAX_CLEANUP_TIME) { + break; + } + this._destroyItem(this._itemDestroyQueue.pop()); + } + + this._initOutputTimer(); + return; + } + + // Try to prune the message queue. + let shouldPrune = false; + if (this._outputQueue.length > toDisplay && this._pruneOutputQueue()) { + toDisplay = Math.min(this._outputQueue.length, toDisplay); + shouldPrune = true; + } + + let batch = this._outputQueue.splice(0, toDisplay); + let outputNode = this.outputNode; + let lastVisibleNode = null; + let scrollNode = this.outputWrapper; + let hudIdSupportsString = WebConsoleUtils.supportsString(this.hudId); + + // We won't bother to try to restore scroll position if this is showing + // a lot of messages at once (and there are still items in the queue). + // It is going to purge whatever you were looking at anyway. + let scrolledToBottom = + shouldPrune || Utils.isOutputScrolledToBottom(outputNode, scrollNode); + + // Output the current batch of messages. + let messages = new Set(); + for (let i = 0; i < batch.length; i++) { + let item = batch[i]; + let result = this._outputMessageFromQueue(hudIdSupportsString, item); + if (result) { + messages.add({ + node: result.isRepeated ? result.isRepeated : result.node, + response: result.message, + update: !!result.isRepeated, + }); + + if (result.visible && result.node == this.outputNode.lastChild) { + lastVisibleNode = result.node; + } + } + } + + let oldScrollHeight = 0; + let removedNodes = 0; + + // Prune messages from the DOM, but only if needed. + if (shouldPrune || !this._outputQueue.length) { + // Only bother measuring the scrollHeight if not scrolled to bottom, + // since the oldScrollHeight will not be used if it is. + if (!scrolledToBottom) { + oldScrollHeight = scrollNode.scrollHeight; + } + + let categories = Object.keys(this._pruneCategoriesQueue); + categories.forEach(function _pruneOutput(category) { + removedNodes += this.pruneOutputIfNecessary(category); + }, this); + this._pruneCategoriesQueue = {}; + } + + let isInputOutput = lastVisibleNode && + (lastVisibleNode.category == CATEGORY_INPUT || + lastVisibleNode.category == CATEGORY_OUTPUT); + + // Scroll to the new node if it is not filtered, and if the output node is + // scrolled at the bottom or if the new node is a jsterm input/output + // message. + if (lastVisibleNode && (scrolledToBottom || isInputOutput)) { + Utils.scrollToVisible(lastVisibleNode); + } else if (!scrolledToBottom && removedNodes > 0 && + oldScrollHeight != scrollNode.scrollHeight) { + // If there were pruned messages and if scroll is not at the bottom, then + // we need to adjust the scroll location. + scrollNode.scrollTop -= oldScrollHeight - scrollNode.scrollHeight; + } + + if (messages.size) { + this.emit("new-messages", messages); + } + + // If the output queue is empty, then run _flushCallback. + if (this._outputQueue.length === 0 && this._flushCallback) { + if (this._flushCallback() === false) { + this._flushCallback = null; + } + } + + this._initOutputTimer(); + + // Resize the output area in case a vertical scrollbar has been added + this.resize(); + + this._lastOutputFlush = Date.now(); + }, + + /** + * Initialize the output timer. + * @private + */ + _initOutputTimer: function () { + let panelIsDestroyed = !this._outputTimer; + let alreadyScheduled = this._outputTimerInitialized; + let nothingToDo = !this._itemDestroyQueue.length && + !this._outputQueue.length; + + // Don't schedule a callback in the following cases: + if (panelIsDestroyed || alreadyScheduled || nothingToDo) { + return; + } + + this._outputTimerInitialized = true; + this._outputTimer.initWithCallback(this._flushMessageQueue, + OUTPUT_INTERVAL, + Ci.nsITimer.TYPE_ONE_SHOT); + }, + + /** + * Output a message from the queue. + * + * @private + * @param nsISupportsString hudIdSupportsString + * The HUD ID as an nsISupportsString. + * @param array item + * An item from the output queue - this item represents a message. + * @return object + * An object that holds the following properties: + * - node: the DOM element of the message. + * - isRepeated: the DOM element of the original message, if this is + * a repeated message, otherwise null. + * - visible: boolean that tells if the message is visible. + */ + _outputMessageFromQueue: function (hudIdSupportsString, item) { + let [, methodOrNode, args] = item; + + // The last object in the args array should be message + // object or response packet received from the server. + let message = (args && args.length) ? args[args.length - 1] : null; + + let node = typeof methodOrNode == "function" ? + methodOrNode.apply(this, args || []) : + methodOrNode; + if (!node) { + return null; + } + + let isFiltered = this.filterMessageNode(node); + + let isRepeated = this._filterRepeatedMessage(node); + + // If a clear message is processed while the webconsole is opened, the UI + // should be cleared. + // Do not clear the output if the current frame is owned by a Browser Console. + if (message && message.level == "clear" && !this.isBrowserConsole) { + // Do not clear the consoleStorage here as it has been cleared already + // by the clear method, only clear the UI. + this.jsterm.clearOutput(false); + } + + let visible = !isRepeated && !isFiltered; + if (!isRepeated) { + this.outputNode.appendChild(node); + this._pruneCategoriesQueue[node.category] = true; + + let nodeID = node.getAttribute("id"); + Services.obs.notifyObservers(hudIdSupportsString, + "web-console-message-created", nodeID); + } + + if (node._onOutput) { + node._onOutput(); + delete node._onOutput; + } + + return { + visible: visible, + node: node, + isRepeated: isRepeated, + message: message + }; + }, + + /** + * Prune the queue of messages to display. This avoids displaying messages + * that will be removed at the end of the queue anyway. + * @private + */ + _pruneOutputQueue: function () { + let nodes = {}; + + // Group the messages per category. + this._outputQueue.forEach(function (item, index) { + let [category] = item; + if (!(category in nodes)) { + nodes[category] = []; + } + nodes[category].push(index); + }, this); + + let pruned = 0; + + // Loop through the categories we found and prune if needed. + for (let category in nodes) { + let limit = Utils.logLimitForCategory(category); + let indexes = nodes[category]; + if (indexes.length > limit) { + let n = Math.max(0, indexes.length - limit); + pruned += n; + for (let i = n - 1; i >= 0; i--) { + this._itemDestroyQueue.push(this._outputQueue[indexes[i]]); + this._outputQueue.splice(indexes[i], 1); + } + } + } + + return pruned; + }, + + /** + * Destroy an item that was once in the outputQueue but isn't needed + * after all. + * + * @private + * @param array item + * The item you want to destroy. Does not remove it from the output + * queue. + */ + _destroyItem: function (item) { + // TODO: handle object releasing in a more elegant way once all console + // messages use the new API - bug 778766. + let [category, methodOrNode, args] = item; + if (typeof methodOrNode != "function" && methodOrNode._objectActors) { + for (let actor of methodOrNode._objectActors) { + this._releaseObject(actor); + } + methodOrNode._objectActors.clear(); + } + + if (methodOrNode == this.output._flushMessageQueue && + args[0]._objectActors) { + for (let arg of args) { + if (!arg._objectActors) { + continue; + } + for (let actor of arg._objectActors) { + this._releaseObject(actor); + } + arg._objectActors.clear(); + } + } + + if (category == CATEGORY_NETWORK) { + let connectionId = null; + if (methodOrNode == this.logNetEvent) { + connectionId = args[0].actor; + } else if (typeof methodOrNode != "function") { + connectionId = methodOrNode._connectionId; + } + if (connectionId && + this.webConsoleClient.hasNetworkRequest(connectionId)) { + this.webConsoleClient.removeNetworkRequest(connectionId); + this._releaseObject(connectionId); + } + } else if (category == CATEGORY_WEBDEV && + methodOrNode == this.logConsoleAPIMessage) { + args[0].arguments.forEach((value) => { + if (WebConsoleUtils.isActorGrip(value)) { + this._releaseObject(value.actor); + } + }); + } else if (category == CATEGORY_JS && + methodOrNode == this.reportPageError) { + let pageError = args[1]; + for (let prop of ["errorMessage", "lineText"]) { + let grip = pageError[prop]; + if (WebConsoleUtils.isActorGrip(grip)) { + this._releaseObject(grip.actor); + } + } + } else if (category == CATEGORY_JS && + methodOrNode == this._reportLogMessage) { + if (WebConsoleUtils.isActorGrip(args[0].message)) { + this._releaseObject(args[0].message.actor); + } + } + }, + + /** + * Cleans up a message via a node that may or may not + * have actually been rendered in the DOM. Currently, only + * cleans up React components. + * + * @param nsIDOMNode node + * The message node you want to clean up. + */ + unmountMessage(node) { + // Unmount the Frame component with the message location + let locationNode = node.querySelector(".message-location"); + if (locationNode) { + this.ReactDOM.unmountComponentAtNode(locationNode); + } + + // Unmount the StackTrace component if present in the message + let stacktraceNode = node.querySelector(".stacktrace"); + if (stacktraceNode) { + this.ReactDOM.unmountComponentAtNode(stacktraceNode); + } + }, + + /** + * Ensures that the number of message nodes of type category don't exceed that + * category's line limit by removing old messages as needed. + * + * @param integer category + * The category of message nodes to prune if needed. + * @return number + * The number of removed nodes. + */ + pruneOutputIfNecessary: function (category) { + let logLimit = Utils.logLimitForCategory(category); + let messageNodes = this.outputNode.querySelectorAll(".message[category=" + + CATEGORY_CLASS_FRAGMENTS[category] + "]"); + let n = Math.max(0, messageNodes.length - logLimit); + [...messageNodes].slice(0, n).forEach(this.removeOutputMessage, this); + return n; + }, + + /** + * Remove a given message from the output. + * + * @param nsIDOMNode node + * The message node you want to remove. + */ + removeOutputMessage: function (node) { + if (node._messageObject) { + node._messageObject.destroy(); + } + + if (node._objectActors) { + for (let actor of node._objectActors) { + this._releaseObject(actor); + } + node._objectActors.clear(); + } + + if (node.category == CATEGORY_CSS || + node.category == CATEGORY_SECURITY) { + let repeatNode = node.getElementsByClassName("message-repeats")[0]; + if (repeatNode && repeatNode._uid) { + delete this._repeatNodes[repeatNode._uid]; + } + } else if (node._connectionId && + node.category == CATEGORY_NETWORK) { + this.webConsoleClient.removeNetworkRequest(node._connectionId); + this._releaseObject(node._connectionId); + } else if (node.classList.contains("inlined-variables-view")) { + let view = node._variablesView; + if (view) { + view.controller.releaseActors(); + } + node._variablesView = null; + } + + this.unmountMessage(node); + + node.remove(); + }, + + /** + * Given a category and message body, creates a DOM node to represent an + * incoming message. The timestamp is automatically added. + * + * @param number category + * The category of the message: one of the CATEGORY_* constants. + * @param number severity + * The severity of the message: one of the SEVERITY_* constants; + * @param string|nsIDOMNode body + * The body of the message, either a simple string or a DOM node. + * @param string sourceURL [optional] + * The URL of the source file that emitted the error. + * @param number sourceLine [optional] + * The line number on which the error occurred. If zero or omitted, + * there is no line number associated with this message. + * @param string clipboardText [optional] + * The text that should be copied to the clipboard when this node is + * copied. If omitted, defaults to the body text. If `body` is not + * a string, then the clipboard text must be supplied. + * @param number level [optional] + * The level of the console API message. + * @param number timestamp [optional] + * The timestamp to use for this message node. If omitted, the current + * date and time is used. + * @return nsIDOMNode + * The message node: a DIV ready to be inserted into the Web Console + * output node. + */ + createMessageNode: function (category, severity, body, sourceURL, sourceLine, + clipboardText, level, timestamp) { + if (typeof body != "string" && clipboardText == null && body.innerText) { + clipboardText = body.innerText; + } + + let indentNode = this.document.createElementNS(XHTML_NS, "span"); + indentNode.className = "indent"; + + // Apply the current group by indenting appropriately. + let indent = this.groupDepth * GROUP_INDENT; + indentNode.style.width = indent + "px"; + + // Make the icon container, which is a vertical box. Its purpose is to + // ensure that the icon stays anchored at the top of the message even for + // long multi-line messages. + let iconContainer = this.document.createElementNS(XHTML_NS, "span"); + iconContainer.className = "icon"; + + // Create the message body, which contains the actual text of the message. + let bodyNode = this.document.createElementNS(XHTML_NS, "span"); + bodyNode.className = "message-body-wrapper message-body devtools-monospace"; + + // Store the body text, since it is needed later for the variables view. + let storedBody = body; + // If a string was supplied for the body, turn it into a DOM node and an + // associated clipboard string now. + clipboardText = clipboardText || + (body + (sourceURL ? " @ " + sourceURL : "") + + (sourceLine ? ":" + sourceLine : "")); + + timestamp = timestamp || Date.now(); + + // Create the containing node and append all its elements to it. + let node = this.document.createElementNS(XHTML_NS, "div"); + node.id = "console-msg-" + gSequenceId(); + node.className = "message"; + node.clipboardText = clipboardText; + node.timestamp = timestamp; + this.setMessageType(node, category, severity); + + if (body instanceof Ci.nsIDOMNode) { + bodyNode.appendChild(body); + } else { + let str = undefined; + if (level == "dir") { + str = VariablesView.getString(body.arguments[0]); + } else { + str = body; + } + + if (str !== undefined) { + body = this.document.createTextNode(str); + bodyNode.appendChild(body); + } + } + + // Add the message repeats node only when needed. + let repeatNode = null; + if (category != CATEGORY_INPUT && + category != CATEGORY_OUTPUT && + category != CATEGORY_NETWORK && + !(category == CATEGORY_CSS && severity == SEVERITY_LOG)) { + repeatNode = this.document.createElementNS(XHTML_NS, "span"); + repeatNode.setAttribute("value", "1"); + repeatNode.className = "message-repeats"; + repeatNode.textContent = 1; + repeatNode._uid = [bodyNode.textContent, category, severity, level, + sourceURL, sourceLine].join(":"); + } + + // Create the timestamp. + let timestampNode = this.document.createElementNS(XHTML_NS, "span"); + timestampNode.className = "timestamp devtools-monospace"; + + let timestampString = l10n.timestampString(timestamp); + timestampNode.textContent = timestampString + " "; + + // Create the source location (e.g. www.example.com:6) that sits on the + // right side of the message, if applicable. + let locationNode; + if (sourceURL && IGNORED_SOURCE_URLS.indexOf(sourceURL) == -1) { + locationNode = this.createLocationNode({url: sourceURL, + line: sourceLine}); + } + + node.appendChild(timestampNode); + node.appendChild(indentNode); + node.appendChild(iconContainer); + + // Display the variables view after the message node. + if (level == "dir") { + let options = { + objectActor: storedBody.arguments[0], + targetElement: bodyNode, + hideFilterInput: true, + }; + this.jsterm.openVariablesView(options).then((view) => { + node._variablesView = view; + if (node.classList.contains("hidden-message")) { + node.classList.remove("hidden-message"); + } + }); + + node.classList.add("inlined-variables-view"); + } + + node.appendChild(bodyNode); + if (repeatNode) { + node.appendChild(repeatNode); + } + if (locationNode) { + node.appendChild(locationNode); + } + node.appendChild(this.document.createTextNode("\n")); + + return node; + }, + + /** + * Creates the anchor that displays the textual location of an incoming + * message. + * + * @param {Object} location + * An object containing url, line and column number of the message source. + * @return {Element} + * The new anchor element, ready to be added to the message node. + */ + createLocationNode: function (location) { + let locationNode = this.document.createElementNS(XHTML_NS, "div"); + locationNode.className = "message-location devtools-monospace"; + + // Make the location clickable. + let onClick = ({ url, line }) => { + let category = locationNode.closest(".message").category; + let target = null; + + if (/^Scratchpad\/\d+$/.test(url)) { + target = "scratchpad"; + } else if (category === CATEGORY_CSS) { + target = "styleeditor"; + } else if (category === CATEGORY_JS || category === CATEGORY_WEBDEV) { + target = "jsdebugger"; + } else if (/\.js$/.test(url)) { + // If it ends in .js, let's attempt to open in debugger + // anyway, as this falls back to normal view-source. + target = "jsdebugger"; + } else { + // Point everything else to debugger, if source not available, + // it will fall back to view-source. + target = "jsdebugger"; + } + + switch (target) { + case "scratchpad": + this.owner.viewSourceInScratchpad(url, line); + return; + case "jsdebugger": + this.owner.viewSourceInDebugger(url, line); + return; + case "styleeditor": + this.owner.viewSourceInStyleEditor(url, line); + return; + } + // No matching tool found; use old school view-source + this.owner.viewSource(url, line); + }; + + const toolbox = gDevTools.getToolbox(this.owner.target); + + let { url, line, column } = location; + let source = url ? url.split(" -> ").pop() : ""; + + this.ReactDOM.render(this.FrameView({ + frame: { source, line, column }, + showEmptyPathAsHost: true, + onClick, + sourceMapService: toolbox ? toolbox._sourceMapService : null, + }), locationNode); + + return locationNode; + }, + + /** + * Adjusts the category and severity of the given message. + * + * @param nsIDOMNode messageNode + * The message node to alter. + * @param number category + * The category for the message; one of the CATEGORY_ constants. + * @param number severity + * The severity for the message; one of the SEVERITY_ constants. + * @return void + */ + setMessageType: function (messageNode, category, severity) { + messageNode.category = category; + messageNode.severity = severity; + messageNode.setAttribute("category", CATEGORY_CLASS_FRAGMENTS[category]); + messageNode.setAttribute("severity", SEVERITY_CLASS_FRAGMENTS[severity]); + messageNode.setAttribute("filter", + MESSAGE_PREFERENCE_KEYS[category][severity]); + }, + + /** + * Add the mouse event handlers needed to make a link. + * + * @private + * @param nsIDOMNode node + * The node for which you want to add the event handlers. + * @param function callback + * The function you want to invoke on click. + */ + _addMessageLinkCallback: function (node, callback) { + node.addEventListener("mousedown", (event) => { + this._mousedown = true; + this._startX = event.clientX; + this._startY = event.clientY; + }, false); + + node.addEventListener("click", (event) => { + let mousedown = this._mousedown; + this._mousedown = false; + + event.preventDefault(); + + // Do not allow middle/right-click or 2+ clicks. + if (event.detail != 1 || event.button != 0) { + return; + } + + // If this event started with a mousedown event and it ends at a different + // location, we consider this text selection. + if (mousedown && + (this._startX != event.clientX) && + (this._startY != event.clientY)) { + this._startX = this._startY = undefined; + return; + } + + this._startX = this._startY = undefined; + + callback.call(this, event); + }, false); + }, + + /** + * Handler for the pref-changed event coming from the toolbox. + * Currently this function only handles the timestamps preferences. + * + * @private + * @param object event + * This parameter is a string that holds the event name + * pref-changed in this case. + * @param object data + * This is the pref-changed data object. + */ + _onToolboxPrefChanged: function (event, data) { + if (data.pref == PREF_MESSAGE_TIMESTAMP) { + if (data.newValue) { + this.outputNode.classList.remove("hideTimestamps"); + } else { + this.outputNode.classList.add("hideTimestamps"); + } + } + }, + + /** + * Copies the selected items to the system clipboard. + * + * @param object options + * - linkOnly: + * An optional flag to copy only URL without other meta-information. + * Default is false. + * - contextmenu: + * An optional flag to copy the last clicked item which brought + * up the context menu if nothing is selected. Default is false. + */ + copySelectedItems: function (options) { + options = options || { linkOnly: false, contextmenu: false }; + + // Gather up the selected items and concatenate their clipboard text. + let strings = []; + + let children = this.output.getSelectedMessages(); + if (!children.length && options.contextmenu) { + children = [this._contextMenuHandler.lastClickedMessage]; + } + + for (let item of children) { + // Ensure the selected item hasn't been filtered by type or string. + if (!item.classList.contains("filtered-by-type") && + !item.classList.contains("filtered-by-string")) { + if (options.linkOnly) { + strings.push(item.url); + } else { + strings.push(item.clipboardText); + } + } + } + + clipboardHelper.copyString(strings.join("\n")); + }, + + /** + * Object properties provider. This function gives you the properties of the + * remote object you want. + * + * @param string actor + * The object actor ID from which you want the properties. + * @param function callback + * Function you want invoked once the properties are received. + */ + objectPropertiesProvider: function (actor, callback) { + this.webConsoleClient.inspectObjectProperties(actor, + function (response) { + if (response.error) { + console.error("Failed to retrieve the object properties from the " + + "server. Error: " + response.error); + return; + } + callback(response.properties); + }); + }, + + /** + * Release an actor. + * + * @private + * @param string actor + * The actor ID you want to release. + */ + _releaseObject: function (actor) { + if (this.proxy) { + this.proxy.releaseActor(actor); + } + }, + + /** + * Open the selected item's URL in a new tab. + */ + openSelectedItemInTab: function () { + let item = this.output.getSelectedMessages(1)[0] || + this._contextMenuHandler.lastClickedMessage; + + if (!item || !item.url) { + return; + } + + this.owner.openLink(item.url); + }, + + /** + * Destroy the WebConsoleFrame object. Call this method to avoid memory leaks + * when the Web Console is closed. + * + * @return object + * A promise that is resolved when the WebConsoleFrame instance is + * destroyed. + */ + destroy: function () { + if (this._destroyer) { + return this._destroyer.promise; + } + + this._destroyer = promise.defer(); + + let toolbox = gDevTools.getToolbox(this.owner.target); + if (toolbox) { + toolbox.off("webconsole-selected", this._onPanelSelected); + } + + gDevTools.off("pref-changed", this._onToolboxPrefChanged); + this.window.removeEventListener("resize", this.resize, true); + + this._repeatNodes = {}; + this._outputQueue.forEach(this._destroyItem, this); + this._outputQueue = []; + this._itemDestroyQueue.forEach(this._destroyItem, this); + this._itemDestroyQueue = []; + this._pruneCategoriesQueue = {}; + this.webConsoleClient.clearNetworkRequests(); + + // Unmount any currently living frame components in DOM, since + // currently we only clean up messages in `this.removeOutputMessage`, + // via `this.pruneOutputIfNecessary`. + let liveMessages = this.outputNode.querySelectorAll(".message"); + Array.prototype.forEach.call(liveMessages, this.unmountMessage); + + if (this._outputTimerInitialized) { + this._outputTimerInitialized = false; + this._outputTimer.cancel(); + } + this._outputTimer = null; + if (this.jsterm) { + this.jsterm.off("sidebar-opened", this.resize); + this.jsterm.off("sidebar-closed", this.resize); + this.jsterm.destroy(); + this.jsterm = null; + } + this.output.destroy(); + this.output = null; + + this.React = this.ReactDOM = this.FrameView = null; + + if (this._contextMenuHandler) { + this._contextMenuHandler.destroy(); + this._contextMenuHandler = null; + } + + this._commandController = null; + + let onDestroy = () => { + this._destroyer.resolve(null); + }; + + if (this.proxy) { + this.proxy.disconnect().then(onDestroy); + this.proxy = null; + } else { + onDestroy(); + } + + return this._destroyer.promise; + }, +}; + +/** + * Utils: a collection of globally used functions. + */ +var Utils = { + /** + * Scrolls a node so that it's visible in its containing element. + * + * @param nsIDOMNode node + * The node to make visible. + * @returns void + */ + scrollToVisible: function (node) { + node.scrollIntoView(false); + }, + + /** + * Check if the given output node is scrolled to the bottom. + * + * @param nsIDOMNode outputNode + * @param nsIDOMNode scrollNode + * @return boolean + * True if the output node is scrolled to the bottom, or false + * otherwise. + */ + isOutputScrolledToBottom: function (outputNode, scrollNode) { + let lastNodeHeight = outputNode.lastChild ? + outputNode.lastChild.clientHeight : 0; + return scrollNode.scrollTop + scrollNode.clientHeight >= + scrollNode.scrollHeight - lastNodeHeight / 2; + }, + + /** + * Determine the category of a given nsIScriptError. + * + * @param nsIScriptError scriptError + * The script error you want to determine the category for. + * @return CATEGORY_JS|CATEGORY_CSS|CATEGORY_SECURITY + * Depending on the script error CATEGORY_JS, CATEGORY_CSS, or + * CATEGORY_SECURITY can be returned. + */ + categoryForScriptError: function (scriptError) { + let category = scriptError.category; + + if (/^(?:CSS|Layout)\b/.test(category)) { + return CATEGORY_CSS; + } + + switch (category) { + case "Mixed Content Blocker": + case "Mixed Content Message": + case "CSP": + case "Invalid HSTS Headers": + case "Invalid HPKP Headers": + case "SHA-1 Signature": + case "Insecure Password Field": + case "SSL": + case "CORS": + case "Iframe Sandbox": + case "Tracking Protection": + case "Sub-resource Integrity": + return CATEGORY_SECURITY; + + default: + return CATEGORY_JS; + } + }, + + /** + * Retrieve the limit of messages for a specific category. + * + * @param number category + * The category of messages you want to retrieve the limit for. See the + * CATEGORY_* constants. + * @return number + * The number of messages allowed for the specific category. + */ + logLimitForCategory: function (category) { + let logLimit = DEFAULT_LOG_LIMIT; + + try { + let prefName = CATEGORY_CLASS_FRAGMENTS[category]; + logLimit = Services.prefs.getIntPref("devtools.hud.loglimit." + prefName); + logLimit = Math.max(logLimit, 1); + } catch (e) { + // Ignore any exceptions + } + + return logLimit; + }, +}; + +// CommandController + +/** + * A controller (an instance of nsIController) that makes editing actions + * behave appropriately in the context of the Web Console. + */ +function CommandController(webConsole) { + this.owner = webConsole; +} + +CommandController.prototype = { + /** + * Selects all the text in the HUD output. + */ + selectAll: function () { + this.owner.output.selectAllMessages(); + }, + + /** + * Open the URL of the selected message in a new tab. + */ + openURL: function () { + this.owner.openSelectedItemInTab(); + }, + + copyURL: function () { + this.owner.copySelectedItems({ linkOnly: true, contextmenu: true }); + }, + + /** + * Copies the last clicked message. + */ + copyLastClicked: function () { + this.owner.copySelectedItems({ linkOnly: false, contextmenu: true }); + }, + + supportsCommand: function (command) { + if (!this.owner || !this.owner.output) { + return false; + } + return this.isCommandEnabled(command); + }, + + isCommandEnabled: function (command) { + switch (command) { + case "consoleCmd_openURL": + case "consoleCmd_copyURL": { + // Only enable URL-related actions if node is Net Activity. + let selectedItem = this.owner.output.getSelectedMessages(1)[0] || + this.owner._contextMenuHandler.lastClickedMessage; + return selectedItem && "url" in selectedItem; + } + case "cmd_copy": { + // Only copy if we right-clicked the console and there's no selected + // text. With text selected, we want to fall back onto the default + // copy behavior. + return this.owner._contextMenuHandler.lastClickedMessage && + !this.owner.output.getSelectedMessages(1)[0]; + } + case "cmd_selectAll": + return true; + } + return false; + }, + + doCommand: function (command) { + switch (command) { + case "consoleCmd_openURL": + this.openURL(); + break; + case "consoleCmd_copyURL": + this.copyURL(); + break; + case "cmd_copy": + this.copyLastClicked(); + break; + case "cmd_selectAll": + this.selectAll(); + break; + } + } +}; + +// Web Console connection proxy + +/** + * The WebConsoleConnectionProxy handles the connection between the Web Console + * and the application we connect to through the remote debug protocol. + * + * @constructor + * @param object webConsoleFrame + * The WebConsoleFrame object that owns this connection proxy. + * @param RemoteTarget target + * The target that the console will connect to. + */ +function WebConsoleConnectionProxy(webConsoleFrame, target) { + this.webConsoleFrame = webConsoleFrame; + this.target = target; + + this._onPageError = this._onPageError.bind(this); + this._onLogMessage = this._onLogMessage.bind(this); + this._onConsoleAPICall = this._onConsoleAPICall.bind(this); + this._onNetworkEvent = this._onNetworkEvent.bind(this); + this._onNetworkEventUpdate = this._onNetworkEventUpdate.bind(this); + this._onFileActivity = this._onFileActivity.bind(this); + this._onReflowActivity = this._onReflowActivity.bind(this); + this._onServerLogCall = this._onServerLogCall.bind(this); + this._onTabNavigated = this._onTabNavigated.bind(this); + this._onAttachConsole = this._onAttachConsole.bind(this); + this._onCachedMessages = this._onCachedMessages.bind(this); + this._connectionTimeout = this._connectionTimeout.bind(this); + this._onLastPrivateContextExited = + this._onLastPrivateContextExited.bind(this); +} + +WebConsoleConnectionProxy.prototype = { + /** + * The owning Web Console Frame instance. + * + * @see WebConsoleFrame + * @type object + */ + webConsoleFrame: null, + + /** + * The target that the console connects to. + * @type RemoteTarget + */ + target: null, + + /** + * The DebuggerClient object. + * + * @see DebuggerClient + * @type object + */ + client: null, + + /** + * The WebConsoleClient object. + * + * @see WebConsoleClient + * @type object + */ + webConsoleClient: null, + + /** + * Tells if the connection is established. + * @type boolean + */ + connected: false, + + /** + * Timer used for the connection. + * @private + * @type object + */ + _connectTimer: null, + + _connectDefer: null, + _disconnecter: null, + + /** + * The WebConsoleActor ID. + * + * @private + * @type string + */ + _consoleActor: null, + + /** + * Tells if the window.console object of the remote web page is the native + * object or not. + * @private + * @type boolean + */ + _hasNativeConsoleAPI: false, + + /** + * Initialize a debugger client and connect it to the debugger server. + * + * @return object + * A promise object that is resolved/rejected based on the success of + * the connection initialization. + */ + connect: function () { + if (this._connectDefer) { + return this._connectDefer.promise; + } + + this._connectDefer = promise.defer(); + + let timeout = Services.prefs.getIntPref(PREF_CONNECTION_TIMEOUT); + this._connectTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this._connectTimer.initWithCallback(this._connectionTimeout, + timeout, Ci.nsITimer.TYPE_ONE_SHOT); + + let connPromise = this._connectDefer.promise; + connPromise.then(() => { + this._connectTimer.cancel(); + this._connectTimer = null; + }, () => { + this._connectTimer = null; + }); + + let client = this.client = this.target.client; + + if (this.target.isWorkerTarget) { + // XXXworkers: Not Console API yet inside of workers (Bug 1209353). + } else { + client.addListener("logMessage", this._onLogMessage); + client.addListener("pageError", this._onPageError); + client.addListener("consoleAPICall", this._onConsoleAPICall); + client.addListener("fileActivity", this._onFileActivity); + client.addListener("reflowActivity", this._onReflowActivity); + client.addListener("serverLogCall", this._onServerLogCall); + client.addListener("lastPrivateContextExited", + this._onLastPrivateContextExited); + } + this.target.on("will-navigate", this._onTabNavigated); + this.target.on("navigate", this._onTabNavigated); + + this._consoleActor = this.target.form.consoleActor; + if (this.target.isTabActor) { + let tab = this.target.form; + this.webConsoleFrame.onLocationChange(tab.url, tab.title); + } + this._attachConsole(); + + return connPromise; + }, + + /** + * Connection timeout handler. + * @private + */ + _connectionTimeout: function () { + let error = { + error: "timeout", + message: l10n.getStr("connectionTimeout"), + }; + + this._connectDefer.reject(error); + }, + + /** + * Attach to the Web Console actor. + * @private + */ + _attachConsole: function () { + let listeners = ["PageError", "ConsoleAPI", "NetworkActivity", + "FileActivity"]; + this.client.attachConsole(this._consoleActor, listeners, + this._onAttachConsole); + }, + + /** + * The "attachConsole" response handler. + * + * @private + * @param object response + * The JSON response object received from the server. + * @param object webConsoleClient + * The WebConsoleClient instance for the attached console, for the + * specific tab we work with. + */ + _onAttachConsole: function (response, webConsoleClient) { + if (response.error) { + console.error("attachConsole failed: " + response.error + " " + + response.message); + this._connectDefer.reject(response); + return; + } + + this.webConsoleClient = webConsoleClient; + this._hasNativeConsoleAPI = response.nativeConsoleAPI; + + // There is no way to view response bodies from the Browser Console, so do + // not waste the memory. + let saveBodies = !this.webConsoleFrame.isBrowserConsole; + this.webConsoleFrame.setSaveRequestAndResponseBodies(saveBodies); + + this.webConsoleClient.on("networkEvent", this._onNetworkEvent); + this.webConsoleClient.on("networkEventUpdate", this._onNetworkEventUpdate); + + let msgs = ["PageError", "ConsoleAPI"]; + this.webConsoleClient.getCachedMessages(msgs, this._onCachedMessages); + + this.webConsoleFrame._onUpdateListeners(); + }, + + /** + * Dispatch a message add on the new frontend and emit an event for tests. + */ + dispatchMessageAdd: function(packet) { + this.webConsoleFrame.newConsoleOutput.dispatchMessageAdd(packet); + }, + + /** + * Batched dispatch of messages. + */ + dispatchMessagesAdd: function(packets) { + this.webConsoleFrame.newConsoleOutput.dispatchMessagesAdd(packets); + }, + + /** + * The "cachedMessages" response handler. + * + * @private + * @param object response + * The JSON response object received from the server. + */ + _onCachedMessages: function (response) { + if (response.error) { + console.error("Web Console getCachedMessages error: " + response.error + + " " + response.message); + this._connectDefer.reject(response); + return; + } + + if (!this._connectTimer) { + // This happens if the promise is rejected (eg. a timeout), but the + // connection attempt is successful, nonetheless. + console.error("Web Console getCachedMessages error: invalid state."); + } + + let messages = + response.messages.concat(...this.webConsoleClient.getNetworkEvents()); + messages.sort((a, b) => a.timeStamp - b.timeStamp); + + if (this.webConsoleFrame.NEW_CONSOLE_OUTPUT_ENABLED) { + // Filter out CSS page errors. + messages = messages.filter(message => !(message._type == "PageError" + && Utils.categoryForScriptError(message) === CATEGORY_CSS)); + this.dispatchMessagesAdd(messages); + } else { + this.webConsoleFrame.displayCachedMessages(messages); + if (!this._hasNativeConsoleAPI) { + this.webConsoleFrame.logWarningAboutReplacedAPI(); + } + } + + this.connected = true; + this._connectDefer.resolve(this); + }, + + /** + * The "pageError" message type handler. We redirect any page errors to the UI + * for displaying. + * + * @private + * @param string type + * Message type. + * @param object packet + * The message received from the server. + */ + _onPageError: function (type, packet) { + if (this.webConsoleFrame && packet.from == this._consoleActor) { + if (this.webConsoleFrame.NEW_CONSOLE_OUTPUT_ENABLED) { + let category = Utils.categoryForScriptError(packet.pageError); + if (category !== CATEGORY_CSS) { + this.dispatchMessageAdd(packet); + } + return; + } + this.webConsoleFrame.handlePageError(packet.pageError); + } + }, + + /** + * The "logMessage" message type handler. We redirect any message to the UI + * for displaying. + * + * @private + * @param string type + * Message type. + * @param object packet + * The message received from the server. + */ + _onLogMessage: function (type, packet) { + if (this.webConsoleFrame && packet.from == this._consoleActor) { + this.webConsoleFrame.handleLogMessage(packet); + } + }, + + /** + * The "consoleAPICall" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param string type + * Message type. + * @param object packet + * The message received from the server. + */ + _onConsoleAPICall: function (type, packet) { + if (this.webConsoleFrame && packet.from == this._consoleActor) { + if (this.webConsoleFrame.NEW_CONSOLE_OUTPUT_ENABLED) { + this.dispatchMessageAdd(packet); + } else { + this.webConsoleFrame.handleConsoleAPICall(packet.message); + } + } + }, + + /** + * The "networkEvent" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param string type + * Message type. + * @param object networkInfo + * The network request information. + */ + _onNetworkEvent: function (type, networkInfo) { + if (this.webConsoleFrame) { + if (this.webConsoleFrame.NEW_CONSOLE_OUTPUT_ENABLED) { + this.dispatchMessageAdd(networkInfo); + } else { + this.webConsoleFrame.handleNetworkEvent(networkInfo); + } + } + }, + + /** + * The "networkEventUpdate" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param string type + * Message type. + * @param object packet + * The message received from the server. + * @param object networkInfo + * The network request information. + */ + _onNetworkEventUpdate: function (type, { packet, networkInfo }) { + if (this.webConsoleFrame) { + this.webConsoleFrame.handleNetworkEventUpdate(networkInfo, packet); + } + }, + + /** + * The "fileActivity" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param string type + * Message type. + * @param object packet + * The message received from the server. + */ + _onFileActivity: function (type, packet) { + if (this.webConsoleFrame && packet.from == this._consoleActor) { + this.webConsoleFrame.handleFileActivity(packet.uri); + } + }, + + _onReflowActivity: function (type, packet) { + if (this.webConsoleFrame && packet.from == this._consoleActor) { + this.webConsoleFrame.handleReflowActivity(packet); + } + }, + + /** + * The "serverLogCall" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param string type + * Message type. + * @param object packet + * The message received from the server. + */ + _onServerLogCall: function (type, packet) { + if (this.webConsoleFrame && packet.from == this._consoleActor) { + this.webConsoleFrame.handleConsoleAPICall(packet.message); + } + }, + + /** + * The "lastPrivateContextExited" message type handler. When this message is + * received the Web Console UI is cleared. + * + * @private + * @param string type + * Message type. + * @param object packet + * The message received from the server. + */ + _onLastPrivateContextExited: function (type, packet) { + if (this.webConsoleFrame && packet.from == this._consoleActor) { + this.webConsoleFrame.jsterm.clearPrivateMessages(); + } + }, + + /** + * The "will-navigate" and "navigate" event handlers. We redirect any message + * to the UI for displaying. + * + * @private + * @param string event + * Event type. + * @param object packet + * The message received from the server. + */ + _onTabNavigated: function (event, packet) { + if (!this.webConsoleFrame) { + return; + } + + this.webConsoleFrame.handleTabNavigated(event, packet); + }, + + /** + * Release an object actor. + * + * @param string actor + * The actor ID to send the request to. + */ + releaseActor: function (actor) { + if (this.client) { + this.client.release(actor); + } + }, + + /** + * Disconnect the Web Console from the remote server. + * + * @return object + * A promise object that is resolved when disconnect completes. + */ + disconnect: function () { + if (this._disconnecter) { + return this._disconnecter.promise; + } + + this._disconnecter = promise.defer(); + + if (!this.client) { + this._disconnecter.resolve(null); + return this._disconnecter.promise; + } + + this.client.removeListener("logMessage", this._onLogMessage); + this.client.removeListener("pageError", this._onPageError); + this.client.removeListener("consoleAPICall", this._onConsoleAPICall); + this.client.removeListener("fileActivity", this._onFileActivity); + this.client.removeListener("reflowActivity", this._onReflowActivity); + this.client.removeListener("serverLogCall", this._onServerLogCall); + this.client.removeListener("lastPrivateContextExited", + this._onLastPrivateContextExited); + this.webConsoleClient.off("networkEvent", this._onNetworkEvent); + this.webConsoleClient.off("networkEventUpdate", this._onNetworkEventUpdate); + this.target.off("will-navigate", this._onTabNavigated); + this.target.off("navigate", this._onTabNavigated); + + this.client = null; + this.webConsoleClient = null; + this.target = null; + this.connected = false; + this.webConsoleFrame = null; + this._disconnecter.resolve(null); + + return this._disconnecter.promise; + }, +}; + +// Context Menu + +/* + * ConsoleContextMenu this used to handle the visibility of context menu items. + * + * @constructor + * @param object owner + * The WebConsoleFrame instance that owns this object. + */ +function ConsoleContextMenu(owner) { + this.owner = owner; + this.popup = this.owner.document.getElementById("output-contextmenu"); + this.build = this.build.bind(this); + this.popup.addEventListener("popupshowing", this.build); +} + +ConsoleContextMenu.prototype = { + lastClickedMessage: null, + + /* + * Handle to show/hide context menu item. + */ + build: function (event) { + let metadata = this.getSelectionMetadata(event.rangeParent); + for (let element of this.popup.children) { + element.hidden = this.shouldHideMenuItem(element, metadata); + } + }, + + /* + * Get selection information from the view. + * + * @param nsIDOMElement clickElement + * The DOM element the user clicked on. + * @return object + * Selection metadata. + */ + getSelectionMetadata: function (clickElement) { + let metadata = { + selectionType: "", + selection: new Set(), + }; + let selectedItems = this.owner.output.getSelectedMessages(); + if (!selectedItems.length) { + let clickedItem = this.owner.output.getMessageForElement(clickElement); + if (clickedItem) { + this.lastClickedMessage = clickedItem; + selectedItems = [clickedItem]; + } + } + + metadata.selectionType = selectedItems.length > 1 ? "multiple" : "single"; + + let selection = metadata.selection; + for (let item of selectedItems) { + switch (item.category) { + case CATEGORY_NETWORK: + selection.add("network"); + break; + case CATEGORY_CSS: + selection.add("css"); + break; + case CATEGORY_JS: + selection.add("js"); + break; + case CATEGORY_WEBDEV: + selection.add("webdev"); + break; + case CATEGORY_SERVER: + selection.add("server"); + break; + } + } + + return metadata; + }, + + /* + * Determine if an item should be hidden. + * + * @param nsIDOMElement menuItem + * @param object metadata + * @return boolean + * Whether the given item should be hidden or not. + */ + shouldHideMenuItem: function (menuItem, metadata) { + let selectionType = menuItem.getAttribute("selectiontype"); + if (selectionType && !metadata.selectionType == selectionType) { + return true; + } + + let selection = menuItem.getAttribute("selection"); + if (!selection) { + return false; + } + + let shouldHide = true; + let itemData = selection.split("|"); + for (let type of metadata.selection) { + // check whether this menu item should show or not. + if (itemData.indexOf(type) !== -1) { + shouldHide = false; + break; + } + } + + return shouldHide; + }, + + /** + * Destroy the ConsoleContextMenu object instance. + */ + destroy: function () { + this.popup.removeEventListener("popupshowing", this.build); + this.popup = null; + this.owner = null; + this.lastClickedMessage = null; + }, +}; |