diff options
Diffstat (limited to 'devtools/client/webconsole/console-output.js')
-rw-r--r-- | devtools/client/webconsole/console-output.js | 3638 |
1 files changed, 3638 insertions, 0 deletions
diff --git a/devtools/client/webconsole/console-output.js b/devtools/client/webconsole/console-output.js new file mode 100644 index 000000000..52d848494 --- /dev/null +++ b/devtools/client/webconsole/console-output.js @@ -0,0 +1,3638 @@ +/* -*- 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 {Ci, Cu} = require("chrome"); + +loader.lazyImporter(this, "VariablesView", "resource://devtools/client/shared/widgets/VariablesView.jsm"); +loader.lazyImporter(this, "escapeHTML", "resource://devtools/client/shared/widgets/VariablesView.jsm"); + +loader.lazyRequireGetter(this, "promise"); +loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true); +loader.lazyRequireGetter(this, "TableWidget", "devtools/client/shared/widgets/TableWidget", true); +loader.lazyRequireGetter(this, "ObjectClient", "devtools/shared/client/main", true); + +const { extend } = require("sdk/core/heritage"); +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const STRINGS_URI = "devtools/client/locales/webconsole.properties"; + +const WebConsoleUtils = require("devtools/client/webconsole/utils").Utils; +const { getSourceNames } = require("devtools/client/shared/source-utils"); +const {Task} = require("devtools/shared/task"); +const l10n = new WebConsoleUtils.L10n(STRINGS_URI); +const nodeConstants = require("devtools/shared/dom-node-constants"); +const {PluralForm} = require("devtools/shared/plural-form"); + +const MAX_STRING_GRIP_LENGTH = 36; +const {ELLIPSIS} = require("devtools/shared/l10n"); + +const validProtocols = /^(http|https|ftp|data|javascript|resource|chrome):/i; + +// Constants for compatibility with the Web Console output implementation before +// bug 778766. +// TODO: remove these once bug 778766 is fixed. +const COMPAT = { + // The various categories of messages. + CATEGORIES: { + NETWORK: 0, + CSS: 1, + JS: 2, + WEBDEV: 3, + INPUT: 4, + OUTPUT: 5, + SECURITY: 6, + SERVER: 7, + }, + + // The possible message severities. + SEVERITIES: { + ERROR: 0, + WARNING: 1, + INFO: 2, + LOG: 3, + }, + + // The preference keys to use for each category/severity combination, indexed + // first by category (rows) and then by severity (columns). + // + // Most of these rather idiosyncratic names are historical and predate the + // division of message type into "category" and "severity". + /* eslint-disable no-multi-spaces */ + /* eslint-disable max-len */ + /* eslint-disable no-inline-comments */ + PREFERENCE_KEYS: [ + // Error Warning Info Log + [ "network", "netwarn", null, "networkinfo", ], // Network + [ "csserror", "cssparser", null, null, ], // CSS + [ "exception", "jswarn", null, "jslog", ], // JS + [ "error", "warn", "info", "log", ], // Web Developer + [ null, null, null, null, ], // Input + [ null, null, null, null, ], // Output + [ "secerror", "secwarn", null, null, ], // Security + [ "servererror", "serverwarn", "serverinfo", "serverlog", ], // Server Logging + ], + /* eslint-enable no-inline-comments */ + /* eslint-enable max-len */ + /* eslint-enable no-multi-spaces */ + + // The fragment of a CSS class name that identifies each category. + CATEGORY_CLASS_FRAGMENTS: [ "network", "cssparser", "exception", "console", + "input", "output", "security", "server" ], + + // The fragment of a CSS class name that identifies each severity. + SEVERITY_CLASS_FRAGMENTS: [ "error", "warn", "info", "log" ], + + // The indent of a console group in pixels. + GROUP_INDENT: 12, +}; + +// A map from the console API call levels to the Web Console severities. +const CONSOLE_API_LEVELS_TO_SEVERITIES = { + error: "error", + exception: "error", + assert: "error", + warn: "warning", + info: "info", + log: "log", + clear: "log", + trace: "log", + table: "log", + debug: "log", + dir: "log", + dirxml: "log", + group: "log", + groupCollapsed: "log", + groupEnd: "log", + time: "log", + timeEnd: "log", + count: "log" +}; + +// Array of known message source URLs we need to hide from output. +const IGNORED_SOURCE_URLS = ["debugger eval code"]; + +// The maximum length of strings to be displayed by the Web Console. +const MAX_LONG_STRING_LENGTH = 200000; + +// Regular expression that matches the allowed CSS property names when using +// the `window.console` API. +const RE_ALLOWED_STYLES = /^(?:-moz-)?(?:background|border|box|clear|color|cursor|display|float|font|line|margin|padding|text|transition|outline|white-space|word|writing|(?:min-|max-)?width|(?:min-|max-)?height)/; + +// Regular expressions to search and replace with 'notallowed' in the styles +// given to the `window.console` API methods. +const RE_CLEANUP_STYLES = [ + // url(), -moz-element() + /\b(?:url|(?:-moz-)?element)[\s('"]+/gi, + + // various URL protocols + /['"(]*(?:chrome|resource|about|app|data|https?|ftp|file):+\/*/gi, +]; + +// Maximum number of rows to display in console.table(). +const TABLE_ROW_MAX_ITEMS = 1000; + +// Maximum number of columns to display in console.table(). +const TABLE_COLUMN_MAX_ITEMS = 10; + +/** + * The ConsoleOutput object is used to manage output of messages in the Web + * Console. + * + * @constructor + * @param object owner + * The console output owner. This usually the WebConsoleFrame instance. + * Any other object can be used, as long as it has the following + * properties and methods: + * - window + * - document + * - outputMessage(category, methodOrNode[, methodArguments]) + * TODO: this is needed temporarily, until bug 778766 is fixed. + */ +function ConsoleOutput(owner) +{ + this.owner = owner; + this._onFlushOutputMessage = this._onFlushOutputMessage.bind(this); +} + +ConsoleOutput.prototype = { + _dummyElement: null, + + /** + * The output container. + * @type DOMElement + */ + get element() { + return this.owner.outputNode; + }, + + /** + * The document that holds the output. + * @type DOMDocument + */ + get document() { + return this.owner ? this.owner.document : null; + }, + + /** + * The DOM window that holds the output. + * @type Window + */ + get window() { + return this.owner.window; + }, + + /** + * Getter for the debugger WebConsoleClient. + * @type object + */ + get webConsoleClient() { + return this.owner.webConsoleClient; + }, + + /** + * Getter for the current toolbox debuggee target. + * @type Target + */ + get toolboxTarget() { + return this.owner.owner.target; + }, + + /** + * Release an actor. + * + * @private + * @param string actorId + * The actor ID you want to release. + */ + _releaseObject: function (actorId) + { + this.owner._releaseObject(actorId); + }, + + /** + * Add a message to output. + * + * @param object ...args + * Any number of Message objects. + * @return this + */ + addMessage: function (...args) + { + for (let msg of args) { + msg.init(this); + this.owner.outputMessage(msg._categoryCompat, this._onFlushOutputMessage, + [msg]); + } + return this; + }, + + /** + * Message renderer used for compatibility with the current Web Console output + * implementation. This method is invoked for every message object that is + * flushed to output. The message object is initialized and rendered, then it + * is displayed. + * + * TODO: remove this method once bug 778766 is fixed. + * + * @private + * @param object message + * The message object to render. + * @return DOMElement + * The message DOM element that can be added to the console output. + */ + _onFlushOutputMessage: function (message) + { + return message.render().element; + }, + + /** + * Get an array of selected messages. This list is based on the text selection + * start and end points. + * + * @param number [limit] + * Optional limit of selected messages you want. If no value is given, + * all of the selected messages are returned. + * @return array + * Array of DOM elements for each message that is currently selected. + */ + getSelectedMessages: function (limit) + { + let selection = this.window.getSelection(); + if (selection.isCollapsed) { + return []; + } + + if (selection.containsNode(this.element, true)) { + return Array.slice(this.element.children); + } + + let anchor = this.getMessageForElement(selection.anchorNode); + let focus = this.getMessageForElement(selection.focusNode); + if (!anchor || !focus) { + return []; + } + + let start, end; + if (anchor.timestamp > focus.timestamp) { + start = focus; + end = anchor; + } else { + start = anchor; + end = focus; + } + + let result = []; + let current = start; + while (current) { + result.push(current); + if (current == end || (limit && result.length == limit)) { + break; + } + current = current.nextSibling; + } + return result; + }, + + /** + * Find the DOM element of a message for any given descendant. + * + * @param DOMElement elem + * The element to start the search from. + * @return DOMElement|null + * The DOM element of the message, if any. + */ + getMessageForElement: function (elem) + { + while (elem && elem.parentNode) { + if (elem.classList && elem.classList.contains("message")) { + return elem; + } + elem = elem.parentNode; + } + return null; + }, + + /** + * Select all messages. + */ + selectAllMessages: function () + { + let selection = this.window.getSelection(); + selection.removeAllRanges(); + let range = this.document.createRange(); + range.selectNodeContents(this.element); + selection.addRange(range); + }, + + /** + * Add a message to the selection. + * + * @param DOMElement elem + * The message element to select. + */ + selectMessage: function (elem) + { + let selection = this.window.getSelection(); + selection.removeAllRanges(); + let range = this.document.createRange(); + range.selectNodeContents(elem); + selection.addRange(range); + }, + + /** + * Open an URL in a new tab. + * @see WebConsole.openLink() in hudservice.js + */ + openLink: function () + { + this.owner.owner.openLink.apply(this.owner.owner, arguments); + }, + + openLocationInDebugger: function ({url, line}) { + return this.owner.owner.viewSourceInDebugger(url, line); + }, + + /** + * Open the variables view to inspect an object actor. + * @see JSTerm.openVariablesView() in webconsole.js + */ + openVariablesView: function () + { + this.owner.jsterm.openVariablesView.apply(this.owner.jsterm, arguments); + }, + + /** + * Destroy this ConsoleOutput instance. + */ + destroy: function () + { + this._dummyElement = null; + this.owner = null; + }, +}; // ConsoleOutput.prototype + +/** + * Message objects container. + * @type object + */ +var Messages = {}; + +/** + * The BaseMessage object is used for all types of messages. Every kind of + * message should use this object as its base. + * + * @constructor + */ +Messages.BaseMessage = function () +{ + this.widgets = new Set(); + this._onClickAnchor = this._onClickAnchor.bind(this); + this._repeatID = { uid: gSequenceId() }; + this.textContent = ""; +}; + +Messages.BaseMessage.prototype = { + /** + * Reference to the ConsoleOutput owner. + * + * @type object|null + * This is |null| if the message is not yet initialized. + */ + output: null, + + /** + * Reference to the parent message object, if this message is in a group or if + * it is otherwise owned by another message. + * + * @type object|null + */ + parent: null, + + /** + * Message DOM element. + * + * @type DOMElement|null + * This is |null| if the message is not yet rendered. + */ + element: null, + + /** + * Tells if this message is visible or not. + * @type boolean + */ + get visible() { + return this.element && this.element.parentNode; + }, + + /** + * The owner DOM document. + * @type DOMElement + */ + get document() { + return this.output.document; + }, + + /** + * Holds the text-only representation of the message. + * @type string + */ + textContent: null, + + /** + * Set of widgets included in this message. + * @type Set + */ + widgets: null, + + // Properties that allow compatibility with the current Web Console output + // implementation. + _categoryCompat: null, + _severityCompat: null, + _categoryNameCompat: null, + _severityNameCompat: null, + _filterKeyCompat: null, + + /** + * Object that is JSON-ified and used as a non-unique ID for tracking + * duplicate messages. + * @private + * @type object + */ + _repeatID: null, + + /** + * Initialize the message. + * + * @param object output + * The ConsoleOutput owner. + * @param object [parent=null] + * Optional: a different message object that owns this instance. + * @return this + */ + init: function (output, parent = null) + { + this.output = output; + this.parent = parent; + return this; + }, + + /** + * Non-unique ID for this message object used for tracking duplicate messages. + * Different message kinds can identify themselves based their own criteria. + * + * @return string + */ + getRepeatID: function () + { + return JSON.stringify(this._repeatID); + }, + + /** + * Render the message. After this method is invoked the |element| property + * will point to the DOM element of this message. + * @return this + */ + render: function () + { + if (!this.element) { + this.element = this._renderCompat(); + } + return this; + }, + + /** + * Prepare the message container for the Web Console, such that it is + * compatible with the current implementation. + * TODO: remove this once bug 778766 is fixed. + * + * @private + * @return Element + * The DOM element that wraps the message. + */ + _renderCompat: function () + { + let doc = this.output.document; + let container = doc.createElementNS(XHTML_NS, "div"); + container.id = "console-msg-" + gSequenceId(); + container.className = "message"; + if (this.category == "input") { + // Assistive technology tools shouldn't echo input to the user, + // as the user knows what they've just typed. + container.setAttribute("aria-live", "off"); + } + container.category = this._categoryCompat; + container.severity = this._severityCompat; + container.setAttribute("category", this._categoryNameCompat); + container.setAttribute("severity", this._severityNameCompat); + container.setAttribute("filter", this._filterKeyCompat); + container.clipboardText = this.textContent; + container.timestamp = this.timestamp; + container._messageObject = this; + + return container; + }, + + /** + * Add a click callback to a given DOM element. + * + * @private + * @param Element element + * The DOM element to which you want to add a click event handler. + * @param function [callback=this._onClickAnchor] + * Optional click event handler. The default event handler is + * |this._onClickAnchor|. + */ + _addLinkCallback: function (element, callback = this._onClickAnchor) + { + // This is going into the WebConsoleFrame object instance that owns + // the ConsoleOutput object. The WebConsoleFrame owner is the WebConsole + // object instance from hudservice.js. + // TODO: move _addMessageLinkCallback() into ConsoleOutput once bug 778766 + // is fixed. + this.output.owner._addMessageLinkCallback(element, callback); + }, + + /** + * The default |click| event handler for links in the output. This function + * opens the anchor's link in a new tab. + * + * @private + * @param Event event + * The DOM event that invoked this function. + */ + _onClickAnchor: function (event) + { + this.output.openLink(event.target.href); + }, + + destroy: function () + { + // Destroy all widgets that have registered themselves in this.widgets + for (let widget of this.widgets) { + widget.destroy(); + } + this.widgets.clear(); + } +}; + +/** + * The NavigationMarker is used to show a page load event. + * + * @constructor + * @extends Messages.BaseMessage + * @param object response + * The response received from the back end. + * @param number timestamp + * The message date and time, milliseconds elapsed since 1 January 1970 + * 00:00:00 UTC. + */ +Messages.NavigationMarker = function (response, timestamp) { + Messages.BaseMessage.call(this); + + // Store the response packet received from the server. It might + // be useful for extensions customizing the console output. + this.response = response; + this._url = response.url; + this.textContent = "------ " + this._url; + this.timestamp = timestamp; +}; + +Messages.NavigationMarker.prototype = extend(Messages.BaseMessage.prototype, { + /** + * The address of the loading page. + * @private + * @type string + */ + _url: null, + + /** + * Message timestamp. + * + * @type number + * Milliseconds elapsed since 1 January 1970 00:00:00 UTC. + */ + timestamp: 0, + + _categoryCompat: COMPAT.CATEGORIES.NETWORK, + _severityCompat: COMPAT.SEVERITIES.LOG, + _categoryNameCompat: "network", + _severityNameCompat: "info", + _filterKeyCompat: "networkinfo", + + /** + * Prepare the DOM element for this message. + * @return this + */ + render: function () { + if (this.element) { + return this; + } + + let url = this._url; + let pos = url.indexOf("?"); + if (pos > -1) { + url = url.substr(0, pos); + } + + let doc = this.output.document; + let urlnode = doc.createElementNS(XHTML_NS, "a"); + urlnode.className = "url"; + urlnode.textContent = url; + urlnode.title = this._url; + urlnode.href = this._url; + urlnode.draggable = false; + this._addLinkCallback(urlnode); + + let render = Messages.BaseMessage.prototype.render.bind(this); + render().element.appendChild(urlnode); + this.element.classList.add("navigation-marker"); + this.element.url = this._url; + this.element.appendChild(doc.createTextNode("\n")); + + return this; + }, +}); + +/** + * The Simple message is used to show any basic message in the Web Console. + * + * @constructor + * @extends Messages.BaseMessage + * @param string|Node|function message + * The message to display. + * @param object [options] + * Options for this message: + * - category: (string) category that this message belongs to. Defaults + * to no category. + * - severity: (string) severity of the message. Defaults to no severity. + * - timestamp: (number) date and time when the message was recorded. + * Defaults to |Date.now()|. + * - link: (string) if provided, the message will be wrapped in an anchor + * pointing to the given URL here. + * - linkCallback: (function) if provided, the message will be wrapped in + * an anchor. The |linkCallback| function will be added as click event + * handler. + * - location: object that tells the message source: url, line, column + * and lineText. + * - stack: array that tells the message source stack. + * - className: (string) additional element class names for styling + * purposes. + * - private: (boolean) mark this as a private message. + * - filterDuplicates: (boolean) true if you do want this message to be + * filtered as a potential duplicate message, false otherwise. + */ +Messages.Simple = function (message, options = {}) { + Messages.BaseMessage.call(this); + + this.category = options.category; + this.severity = options.severity; + this.location = options.location; + this.stack = options.stack; + this.timestamp = options.timestamp || Date.now(); + this.prefix = options.prefix; + this.private = !!options.private; + + this._message = message; + this._className = options.className; + this._link = options.link; + this._linkCallback = options.linkCallback; + this._filterDuplicates = options.filterDuplicates; + + this._onClickCollapsible = this._onClickCollapsible.bind(this); +}; + +Messages.Simple.prototype = extend(Messages.BaseMessage.prototype, { + /** + * Message category. + * @type string + */ + category: null, + + /** + * Message severity. + * @type string + */ + severity: null, + + /** + * Message source location. Properties: url, line, column, lineText. + * @type object + */ + location: null, + + /** + * Holds the stackframes received from the server. + * + * @private + * @type array + */ + stack: null, + + /** + * Message prefix + * @type string|null + */ + prefix: null, + + /** + * Tells if this message comes from a private browsing context. + * @type boolean + */ + private: false, + + /** + * Custom class name for the DOM element of the message. + * @private + * @type string + */ + _className: null, + + /** + * Message link - if this message is clicked then this URL opens in a new tab. + * @private + * @type string + */ + _link: null, + + /** + * Message click event handler. + * @private + * @type function + */ + _linkCallback: null, + + /** + * Tells if this message should be checked if it is a duplicate of another + * message or not. + */ + _filterDuplicates: false, + + /** + * The raw message displayed by this Message object. This can be a function, + * DOM node or a string. + * + * @private + * @type mixed + */ + _message: null, + + /** + * The message's "attachment" element to be displayed under the message. + * Used for things like stack traces or tables in console.table(). + * + * @private + * @type DOMElement|null + */ + _attachment: null, + + _objectActors: null, + _groupDepthCompat: 0, + + /** + * Message timestamp. + * + * @type number + * Milliseconds elapsed since 1 January 1970 00:00:00 UTC. + */ + timestamp: 0, + + get _categoryCompat() { + return this.category ? + COMPAT.CATEGORIES[this.category.toUpperCase()] : null; + }, + get _severityCompat() { + return this.severity ? + COMPAT.SEVERITIES[this.severity.toUpperCase()] : null; + }, + get _categoryNameCompat() { + return this.category ? + COMPAT.CATEGORY_CLASS_FRAGMENTS[this._categoryCompat] : null; + }, + get _severityNameCompat() { + return this.severity ? + COMPAT.SEVERITY_CLASS_FRAGMENTS[this._severityCompat] : null; + }, + + get _filterKeyCompat() { + return this._categoryCompat !== null && this._severityCompat !== null ? + COMPAT.PREFERENCE_KEYS[this._categoryCompat][this._severityCompat] : + null; + }, + + init: function () + { + Messages.BaseMessage.prototype.init.apply(this, arguments); + this._groupDepthCompat = this.output.owner.groupDepth; + this._initRepeatID(); + return this; + }, + + /** + * Tells if the message can be expanded/collapsed. + * @type boolean + */ + collapsible: false, + + /** + * Getter that tells if this message is collapsed - no details are shown. + * @type boolean + */ + get collapsed() { + return this.collapsible && this.element && !this.element.hasAttribute("open"); + }, + + _initRepeatID: function () + { + if (!this._filterDuplicates) { + return; + } + + // Add the properties we care about for identifying duplicate messages. + let rid = this._repeatID; + delete rid.uid; + + rid.category = this.category; + rid.severity = this.severity; + rid.prefix = this.prefix; + rid.private = this.private; + rid.location = this.location; + rid.link = this._link; + rid.linkCallback = this._linkCallback + ""; + rid.className = this._className; + rid.groupDepth = this._groupDepthCompat; + rid.textContent = ""; + }, + + getRepeatID: function () + { + // No point in returning a string that includes other properties when there + // is a unique ID. + if (this._repeatID.uid) { + return JSON.stringify({ uid: this._repeatID.uid }); + } + + return JSON.stringify(this._repeatID); + }, + + render: function () + { + if (this.element) { + return this; + } + + let timestamp = new Widgets.MessageTimestamp(this, this.timestamp).render(); + + let icon = this.document.createElementNS(XHTML_NS, "span"); + icon.className = "icon"; + icon.title = l10n.getStr("severity." + this._severityNameCompat); + if (this.stack) { + icon.addEventListener("click", this._onClickCollapsible); + } + + let prefixNode; + if (this.prefix) { + prefixNode = this.document.createElementNS(XHTML_NS, "span"); + prefixNode.className = "prefix devtools-monospace"; + prefixNode.textContent = this.prefix + ":"; + } + + // Apply the current group by indenting appropriately. + // TODO: remove this once bug 778766 is fixed. + let indent = this._groupDepthCompat * COMPAT.GROUP_INDENT; + let indentNode = this.document.createElementNS(XHTML_NS, "span"); + indentNode.className = "indent"; + indentNode.style.width = indent + "px"; + + let body = this._renderBody(); + + Messages.BaseMessage.prototype.render.call(this); + if (this._className) { + this.element.className += " " + this._className; + } + + this.element.appendChild(timestamp.element); + this.element.appendChild(indentNode); + this.element.appendChild(icon); + if (prefixNode) { + this.element.appendChild(prefixNode); + } + + if (this.stack) { + let twisty = this.document.createElementNS(XHTML_NS, "a"); + twisty.className = "theme-twisty"; + twisty.href = "#"; + twisty.title = l10n.getStr("messageToggleDetails"); + twisty.addEventListener("click", this._onClickCollapsible); + this.element.appendChild(twisty); + this.collapsible = true; + this.element.setAttribute("collapsible", true); + } + + this.element.appendChild(body); + + this.element.clipboardText = this.element.textContent; + + if (this.private) { + this.element.setAttribute("private", true); + } + + // TODO: handle object releasing in a more elegant way once all console + // messages use the new API - bug 778766. + this.element._objectActors = this._objectActors; + this._objectActors = null; + + return this; + }, + + /** + * Render the message body DOM element. + * @private + * @return Element + */ + _renderBody: function () + { + let bodyWrapper = this.document.createElementNS(XHTML_NS, "span"); + bodyWrapper.className = "message-body-wrapper"; + + let bodyFlex = this.document.createElementNS(XHTML_NS, "span"); + bodyFlex.className = "message-flex-body"; + bodyWrapper.appendChild(bodyFlex); + + let body = this.document.createElementNS(XHTML_NS, "span"); + body.className = "message-body devtools-monospace"; + bodyFlex.appendChild(body); + + let anchor, container = body; + if (this._link || this._linkCallback) { + container = anchor = this.document.createElementNS(XHTML_NS, "a"); + anchor.href = this._link || "#"; + anchor.draggable = false; + this._addLinkCallback(anchor, this._linkCallback); + body.appendChild(anchor); + } + + if (typeof this._message == "function") { + container.appendChild(this._message(this)); + } else if (this._message instanceof Ci.nsIDOMNode) { + container.appendChild(this._message); + } else { + container.textContent = this._message; + } + + // do this before repeatNode is rendered - it has no effect afterwards + this._repeatID.textContent += "|" + container.textContent; + + let repeatNode = this._renderRepeatNode(); + let location = this._renderLocation(); + + if (repeatNode) { + bodyFlex.appendChild(this.document.createTextNode(" ")); + bodyFlex.appendChild(repeatNode); + } + if (location) { + bodyFlex.appendChild(this.document.createTextNode(" ")); + bodyFlex.appendChild(location); + } + + bodyFlex.appendChild(this.document.createTextNode("\n")); + + if (this.stack) { + this._attachment = new Widgets.Stacktrace(this, this.stack).render().element; + } + + if (this._attachment) { + bodyWrapper.appendChild(this._attachment); + } + + return bodyWrapper; + }, + + /** + * Render the repeat bubble DOM element part of the message. + * @private + * @return Element + */ + _renderRepeatNode: function () + { + if (!this._filterDuplicates) { + return null; + } + + let repeatNode = this.document.createElementNS(XHTML_NS, "span"); + repeatNode.setAttribute("value", "1"); + repeatNode.className = "message-repeats"; + repeatNode.textContent = 1; + repeatNode._uid = this.getRepeatID(); + return repeatNode; + }, + + /** + * Render the message source location DOM element. + * @private + * @return Element + */ + _renderLocation: function () + { + if (!this.location) { + return null; + } + + let {url, line, column} = this.location; + if (IGNORED_SOURCE_URLS.indexOf(url) != -1) { + return null; + } + + // The ConsoleOutput owner is a WebConsoleFrame instance from webconsole.js. + // TODO: move createLocationNode() into this file when bug 778766 is fixed. + return this.output.owner.createLocationNode({url, line, column }); + }, + + /** + * The click event handler for the message expander arrow element. This method + * toggles the display of message details. + * + * @private + * @param nsIDOMEvent ev + * The DOM event object. + * @see this.toggleDetails() + */ + _onClickCollapsible: function (ev) + { + ev.preventDefault(); + this.toggleDetails(); + }, + + /** + * Expand/collapse message details. + */ + toggleDetails: function () + { + let twisty = this.element.querySelector(".theme-twisty"); + if (this.element.hasAttribute("open")) { + this.element.removeAttribute("open"); + twisty.removeAttribute("open"); + } else { + this.element.setAttribute("open", true); + twisty.setAttribute("open", true); + } + }, +}); // Messages.Simple.prototype + + +/** + * The Extended message. + * + * @constructor + * @extends Messages.Simple + * @param array messagePieces + * The message to display given as an array of elements. Each array + * element can be a DOM node, function, ObjectActor, LongString or + * a string. + * @param object [options] + * Options for rendering this message: + * - quoteStrings: boolean that tells if you want strings to be wrapped + * in quotes or not. + */ +Messages.Extended = function (messagePieces, options = {}) +{ + Messages.Simple.call(this, null, options); + + this._messagePieces = messagePieces; + + if ("quoteStrings" in options) { + this._quoteStrings = options.quoteStrings; + } + + this._repeatID.quoteStrings = this._quoteStrings; + this._repeatID.messagePieces = JSON.stringify(messagePieces); + this._repeatID.actors = new Set(); // using a set to avoid duplicates +}; + +Messages.Extended.prototype = extend(Messages.Simple.prototype, { + /** + * The message pieces displayed by this message instance. + * @private + * @type array + */ + _messagePieces: null, + + /** + * Boolean that tells if the strings displayed in this message are wrapped. + * @private + * @type boolean + */ + _quoteStrings: true, + + getRepeatID: function () + { + if (this._repeatID.uid) { + return JSON.stringify({ uid: this._repeatID.uid }); + } + + // Sets are not stringified correctly. Temporarily switching to an array. + let actors = this._repeatID.actors; + this._repeatID.actors = [...actors]; + let result = JSON.stringify(this._repeatID); + this._repeatID.actors = actors; + return result; + }, + + render: function () + { + let result = this.document.createDocumentFragment(); + + for (let i = 0; i < this._messagePieces.length; i++) { + let separator = i > 0 ? this._renderBodyPieceSeparator() : null; + if (separator) { + result.appendChild(separator); + } + + let piece = this._messagePieces[i]; + result.appendChild(this._renderBodyPiece(piece)); + } + + this._message = result; + this._messagePieces = null; + return Messages.Simple.prototype.render.call(this); + }, + + /** + * Render the separator between the pieces of the message. + * + * @private + * @return Element + */ + _renderBodyPieceSeparator: function () { return null; }, + + /** + * Render one piece/element of the message array. + * + * @private + * @param mixed piece + * Message element to display - this can be a LongString, ObjectActor, + * DOM node or a function to invoke. + * @return Element + */ + _renderBodyPiece: function (piece, options = {}) + { + if (piece instanceof Ci.nsIDOMNode) { + return piece; + } + if (typeof piece == "function") { + return piece(this); + } + + return this._renderValueGrip(piece, options); + }, + + /** + * Render a grip that represents a value received from the server. This method + * picks the appropriate widget to render the value with. + * + * @private + * @param object grip + * The value grip received from the server. + * @param object options + * Options for displaying the value. Available options: + * - noStringQuotes - boolean that tells the renderer to not use quotes + * around strings. + * - concise - boolean that tells the renderer to compactly display the + * grip. This is typically set to true when the object needs to be + * displayed in an array preview, or as a property value in object + * previews, etc. + * - shorten - boolean that tells the renderer to display a truncated + * grip. + * @return DOMElement + * The DOM element that displays the given grip. + */ + _renderValueGrip: function (grip, options = {}) + { + let isPrimitive = VariablesView.isPrimitive({ value: grip }); + let isActorGrip = WebConsoleUtils.isActorGrip(grip); + let noStringQuotes = !this._quoteStrings; + if ("noStringQuotes" in options) { + noStringQuotes = options.noStringQuotes; + } + + if (isActorGrip) { + this._repeatID.actors.add(grip.actor); + + if (!isPrimitive) { + return this._renderObjectActor(grip, options); + } + if (grip.type == "longString") { + let widget = new Widgets.LongString(this, grip, options).render(); + return widget.element; + } + } + + let unshortenedGrip = grip; + if (options.shorten) { + grip = this.shortenValueGrip(grip); + } + + let result = this.document.createElementNS(XHTML_NS, "span"); + if (isPrimitive) { + if (Widgets.URLString.prototype.containsURL.call(Widgets.URLString.prototype, grip)) { + let widget = new Widgets.URLString(this, grip, unshortenedGrip).render(); + return widget.element; + } + + let className = this.getClassNameForValueGrip(grip); + if (className) { + result.className = className; + } + + result.textContent = VariablesView.getString(grip, { + noStringQuotes: noStringQuotes, + concise: options.concise, + }); + } else { + result.textContent = grip; + } + + return result; + }, + + /** + * Shorten grips of the type string, leaves other grips unmodified. + * + * @param object grip + * Value grip from the server. + * @return object + * Possible values of object: + * - A shortened string, if original grip was of string type. + * - The unmodified input grip, if it wasn't of string type. + */ + shortenValueGrip: function (grip) + { + let shortVal = grip; + if (typeof (grip) == "string") { + shortVal = grip.replace(/(\r\n|\n|\r)/gm, " "); + if (shortVal.length > MAX_STRING_GRIP_LENGTH) { + shortVal = shortVal.substring(0, MAX_STRING_GRIP_LENGTH - 1) + ELLIPSIS; + } + } + + return shortVal; + }, + + /** + * Get a CodeMirror-compatible class name for a given value grip. + * + * @param object grip + * Value grip from the server. + * @return string + * The class name for the grip. + */ + getClassNameForValueGrip: function (grip) + { + let map = { + "number": "cm-number", + "longstring": "console-string", + "string": "console-string", + "regexp": "cm-string-2", + "boolean": "cm-atom", + "-infinity": "cm-atom", + "infinity": "cm-atom", + "null": "cm-atom", + "undefined": "cm-comment", + "symbol": "cm-atom" + }; + + let className = map[typeof grip]; + if (!className && grip && grip.type) { + className = map[grip.type.toLowerCase()]; + } + if (!className && grip && grip.class) { + className = map[grip.class.toLowerCase()]; + } + + return className; + }, + + /** + * Display an object actor with the appropriate renderer. + * + * @private + * @param object objectActor + * The ObjectActor to display. + * @param object options + * Options to use for displaying the ObjectActor. + * @see this._renderValueGrip for the available options. + * @return DOMElement + * The DOM element that displays the object actor. + */ + _renderObjectActor: function (objectActor, options = {}) + { + let widget = Widgets.ObjectRenderers.byClass[objectActor.class]; + + let { preview } = objectActor; + if ((!widget || (widget.canRender && !widget.canRender(objectActor))) + && preview + && preview.kind) { + widget = Widgets.ObjectRenderers.byKind[preview.kind]; + } + + if (!widget || (widget.canRender && !widget.canRender(objectActor))) { + widget = Widgets.JSObject; + } + + let instance = new widget(this, objectActor, options).render(); + return instance.element; + }, +}); // Messages.Extended.prototype + + + +/** + * The JavaScriptEvalOutput message. + * + * @constructor + * @extends Messages.Extended + * @param object evalResponse + * The evaluation response packet received from the server. + * @param string [errorMessage] + * Optional error message to display. + * @param string [errorDocLink] + * Optional error doc URL to link to. + */ +Messages.JavaScriptEvalOutput = function (evalResponse, errorMessage, errorDocLink) +{ + let severity = "log", msg, quoteStrings = true; + + // Store also the response packet from the back end. It might + // be useful to extensions customizing the console output. + this.response = evalResponse; + + if (typeof (errorMessage) !== "undefined") { + severity = "error"; + msg = errorMessage; + quoteStrings = false; + } else { + msg = evalResponse.result; + } + + let options = { + className: "cm-s-mozilla", + timestamp: evalResponse.timestamp, + category: "output", + severity: severity, + quoteStrings: quoteStrings, + }; + + let messages = [msg]; + if (errorDocLink) { + messages.push(errorDocLink); + } + + Messages.Extended.call(this, messages, options); +}; + +Messages.JavaScriptEvalOutput.prototype = Messages.Extended.prototype; + +/** + * The ConsoleGeneric message is used for console API calls. + * + * @constructor + * @extends Messages.Extended + * @param object packet + * The Console API call packet received from the server. + */ +Messages.ConsoleGeneric = function (packet) +{ + let options = { + className: "cm-s-mozilla", + timestamp: packet.timeStamp, + category: packet.category || "webdev", + severity: CONSOLE_API_LEVELS_TO_SEVERITIES[packet.level], + prefix: packet.prefix, + private: packet.private, + filterDuplicates: true, + location: { + url: packet.filename, + line: packet.lineNumber, + column: packet.columnNumber + }, + }; + + switch (packet.level) { + case "count": { + let counter = packet.counter, label = counter.label; + if (!label) { + label = l10n.getStr("noCounterLabel"); + } + Messages.Extended.call(this, [label + ": " + counter.count], options); + break; + } + default: + Messages.Extended.call(this, packet.arguments, options); + break; + } + + this._repeatID.consoleApiLevel = packet.level; + this._repeatID.styles = packet.styles; + this.stack = this._repeatID.stacktrace = packet.stacktrace; + this._styles = packet.styles || []; +}; + +Messages.ConsoleGeneric.prototype = extend(Messages.Extended.prototype, { + _styles: null, + + _renderBodyPieceSeparator: function () + { + return this.document.createTextNode(" "); + }, + + render: function () + { + let result = this.document.createDocumentFragment(); + this._renderBodyPieces(result); + + this._message = result; + this._stacktrace = null; + + Messages.Simple.prototype.render.call(this); + + return this; + }, + + _renderBodyPieces: function (container) + { + let lastStyle = null; + let stylePieces = this._styles.length > 0 ? this._styles.length : 1; + + for (let i = 0; i < this._messagePieces.length; i++) { + // Pieces with an associated style definition come from "%c" formatting. + // For body pieces beyond that, add a separator before each one. + if (i >= stylePieces) { + container.appendChild(this._renderBodyPieceSeparator()); + } + + let piece = this._messagePieces[i]; + let style = this._styles[i]; + + // No long string support. + lastStyle = (style && typeof style == "string") ? + this.cleanupStyle(style) : null; + + container.appendChild(this._renderBodyPiece(piece, lastStyle)); + } + + this._messagePieces = null; + this._styles = null; + }, + + _renderBodyPiece: function (piece, style) + { + // Skip quotes for top-level strings. + let options = { noStringQuotes: true }; + let elem = Messages.Extended.prototype._renderBodyPiece.call(this, piece, options); + let result = elem; + + if (style) { + if (elem.nodeType == nodeConstants.ELEMENT_NODE) { + elem.style = style; + } else { + let span = this.document.createElementNS(XHTML_NS, "span"); + span.style = style; + span.appendChild(elem); + result = span; + } + } + + return result; + }, + + /** + * Given a style attribute value, return a cleaned up version of the string + * such that: + * + * - no external URL is allowed to load. See RE_CLEANUP_STYLES. + * - only some of the properties are allowed, based on a whitelist. See + * RE_ALLOWED_STYLES. + * + * @param string style + * The style string to cleanup. + * @return string + * The style value after cleanup. + */ + cleanupStyle: function (style) + { + for (let r of RE_CLEANUP_STYLES) { + style = style.replace(r, "notallowed"); + } + + let dummy = this.output._dummyElement; + if (!dummy) { + dummy = this.output._dummyElement = + this.document.createElementNS(XHTML_NS, "div"); + } + dummy.style = style; + + let toRemove = []; + for (let i = 0; i < dummy.style.length; i++) { + let prop = dummy.style[i]; + if (!RE_ALLOWED_STYLES.test(prop)) { + toRemove.push(prop); + } + } + + for (let prop of toRemove) { + dummy.style.removeProperty(prop); + } + + style = dummy.style.cssText; + + dummy.style = ""; + + return style; + }, +}); // Messages.ConsoleGeneric.prototype + +/** + * The ConsoleTrace message is used for console.trace() calls. + * + * @constructor + * @extends Messages.Simple + * @param object packet + * The Console API call packet received from the server. + */ +Messages.ConsoleTrace = function (packet) +{ + let options = { + className: "cm-s-mozilla", + timestamp: packet.timeStamp, + category: packet.category || "webdev", + severity: CONSOLE_API_LEVELS_TO_SEVERITIES[packet.level], + private: packet.private, + filterDuplicates: true, + location: { + url: packet.filename, + line: packet.lineNumber, + }, + }; + + Messages.Simple.call(this, null, options); + + this._repeatID.consoleApiLevel = packet.level; + this._stacktrace = this._repeatID.stacktrace = packet.stacktrace; + this._arguments = packet.arguments; +}; + +Messages.ConsoleTrace.prototype = extend(Messages.Simple.prototype, { + /** + * Holds the stackframes received from the server. + * + * @private + * @type array + */ + _stacktrace: null, + + /** + * Holds the arguments the content script passed to the console.trace() + * method. This array is cleared when the message is initialized, and + * associated actors are released. + * + * @private + * @type array + */ + _arguments: null, + + init: function () + { + let result = Messages.Simple.prototype.init.apply(this, arguments); + + // We ignore console.trace() arguments. Release object actors. + if (Array.isArray(this._arguments)) { + for (let arg of this._arguments) { + if (WebConsoleUtils.isActorGrip(arg)) { + this.output._releaseObject(arg.actor); + } + } + } + this._arguments = null; + + return result; + }, + + render: function () { + this._message = this._renderMessage(); + this._attachment = this._renderStack(); + + Messages.Simple.prototype.render.apply(this, arguments); + this.element.setAttribute("open", true); + return this; + }, + + /** + * Render the console messageNode + */ + _renderMessage: function () { + let cmvar = this.document.createElementNS(XHTML_NS, "span"); + cmvar.className = "cm-variable"; + cmvar.textContent = "console"; + + let cmprop = this.document.createElementNS(XHTML_NS, "span"); + cmprop.className = "cm-property"; + cmprop.textContent = "trace"; + + let frag = this.document.createDocumentFragment(); + frag.appendChild(cmvar); + frag.appendChild(this.document.createTextNode(".")); + frag.appendChild(cmprop); + frag.appendChild(this.document.createTextNode("():")); + + return frag; + }, + + /** + * Render the stack frames. + * + * @private + * @return DOMElement + */ + _renderStack: function () { + return new Widgets.Stacktrace(this, this._stacktrace).render().element; + }, +}); // Messages.ConsoleTrace.prototype + +/** + * The ConsoleTable message is used for console.table() calls. + * + * @constructor + * @extends Messages.Extended + * @param object packet + * The Console API call packet received from the server. + */ +Messages.ConsoleTable = function (packet) +{ + let options = { + className: "cm-s-mozilla", + timestamp: packet.timeStamp, + category: packet.category || "webdev", + severity: CONSOLE_API_LEVELS_TO_SEVERITIES[packet.level], + private: packet.private, + filterDuplicates: false, + location: { + url: packet.filename, + line: packet.lineNumber, + }, + }; + + this._populateTableData = this._populateTableData.bind(this); + this._renderMessage = this._renderMessage.bind(this); + Messages.Extended.call(this, [this._renderMessage], options); + + this._repeatID.consoleApiLevel = packet.level; + this._arguments = packet.arguments; +}; + +Messages.ConsoleTable.prototype = extend(Messages.Extended.prototype, { + /** + * Holds the arguments the content script passed to the console.table() + * method. + * + * @private + * @type array + */ + _arguments: null, + + /** + * Array of objects that holds the data to log in the table. + * + * @private + * @type array + */ + _data: null, + + /** + * Key value pair of the id and display name for the columns in the table. + * Refer to the TableWidget API. + * + * @private + * @type object + */ + _columns: null, + + /** + * A promise that resolves when the table data is ready or null if invalid + * arguments are provided. + * + * @private + * @type promise|null + */ + _populatePromise: null, + + init: function () + { + let result = Messages.Extended.prototype.init.apply(this, arguments); + this._data = []; + this._columns = {}; + + this._populatePromise = this._populateTableData(); + + return result; + }, + + /** + * Sets the key value pair of the id and display name for the columns in the + * table. + * + * @private + * @param array|string columns + * Either a string or array containing the names for the columns in + * the output table. + */ + _setColumns: function (columns) + { + if (columns.class == "Array") { + let items = columns.preview.items; + + for (let item of items) { + if (typeof item == "string") { + this._columns[item] = item; + } + } + } else if (typeof columns == "string" && columns) { + this._columns[columns] = columns; + } + }, + + /** + * Retrieves the table data and columns from the arguments received from the + * server. + * + * @return Promise|null + * Returns a promise that resolves when the table data is ready or + * null if the arguments are invalid. + */ + _populateTableData: function () + { + let deferred = promise.defer(); + + if (this._arguments.length <= 0) { + return; + } + + let data = this._arguments[0]; + if (data.class != "Array" && data.class != "Object" && + data.class != "Map" && data.class != "Set" && + data.class != "WeakMap" && data.class != "WeakSet") { + return; + } + + let hasColumnsArg = false; + if (this._arguments.length > 1) { + if (data.class == "Object" || data.class == "Array") { + this._columns["_index"] = l10n.getStr("table.index"); + } else { + this._columns["_index"] = l10n.getStr("table.iterationIndex"); + } + + this._setColumns(this._arguments[1]); + hasColumnsArg = true; + } + + if (data.class == "Object" || data.class == "Array") { + // Get the object properties, and parse the key and value properties into + // the table data and columns. + this.client = new ObjectClient(this.output.owner.jsterm.hud.proxy.client, + data); + this.client.getPrototypeAndProperties(aResponse => { + let {ownProperties} = aResponse; + let rowCount = 0; + let columnCount = 0; + + for (let index of Object.keys(ownProperties || {})) { + // Avoid outputting the length property if the data argument provided + // is an array + if (data.class == "Array" && index == "length") { + continue; + } + + if (!hasColumnsArg) { + this._columns["_index"] = l10n.getStr("table.index"); + } + + if (data.class == "Array") { + if (index == parseInt(index)) { + index = parseInt(index); + } + } + + let property = ownProperties[index].value; + let item = { _index: index }; + + if (property.class == "Object" || property.class == "Array") { + let {preview} = property; + let entries = property.class == "Object" ? + preview.ownProperties : preview.items; + + for (let key of Object.keys(entries)) { + let value = property.class == "Object" ? + preview.ownProperties[key].value : preview.items[key]; + + item[key] = this._renderValueGrip(value, { concise: true }); + + if (!hasColumnsArg && !(key in this._columns) && + (++columnCount <= TABLE_COLUMN_MAX_ITEMS)) { + this._columns[key] = key; + } + } + } else { + // Display the value for any non-object data input. + item["_value"] = this._renderValueGrip(property, { concise: true }); + + if (!hasColumnsArg && !("_value" in this._columns)) { + this._columns["_value"] = l10n.getStr("table.value"); + } + } + + this._data.push(item); + + if (++rowCount == TABLE_ROW_MAX_ITEMS) { + break; + } + } + + deferred.resolve(); + }); + } else if (data.class == "Map" || data.class == "WeakMap") { + let entries = data.preview.entries; + + if (!hasColumnsArg) { + this._columns["_index"] = l10n.getStr("table.iterationIndex"); + this._columns["_key"] = l10n.getStr("table.key"); + this._columns["_value"] = l10n.getStr("table.value"); + } + + let rowCount = 0; + for (let [key, value] of entries) { + let item = { + _index: rowCount, + _key: this._renderValueGrip(key, { concise: true }), + _value: this._renderValueGrip(value, { concise: true }) + }; + + this._data.push(item); + + if (++rowCount == TABLE_ROW_MAX_ITEMS) { + break; + } + } + + deferred.resolve(); + } else if (data.class == "Set" || data.class == "WeakSet") { + let entries = data.preview.items; + + if (!hasColumnsArg) { + this._columns["_index"] = l10n.getStr("table.iterationIndex"); + this._columns["_value"] = l10n.getStr("table.value"); + } + + let rowCount = 0; + for (let entry of entries) { + let item = { + _index : rowCount, + _value: this._renderValueGrip(entry, { concise: true }) + }; + + this._data.push(item); + + if (++rowCount == TABLE_ROW_MAX_ITEMS) { + break; + } + } + + deferred.resolve(); + } + + return deferred.promise; + }, + + render: function () + { + this._attachment = this._renderTable(); + Messages.Extended.prototype.render.apply(this, arguments); + this.element.setAttribute("open", true); + return this; + }, + + _renderMessage: function () { + let cmvar = this.document.createElementNS(XHTML_NS, "span"); + cmvar.className = "cm-variable"; + cmvar.textContent = "console"; + + let cmprop = this.document.createElementNS(XHTML_NS, "span"); + cmprop.className = "cm-property"; + cmprop.textContent = "table"; + + let frag = this.document.createDocumentFragment(); + frag.appendChild(cmvar); + frag.appendChild(this.document.createTextNode(".")); + frag.appendChild(cmprop); + frag.appendChild(this.document.createTextNode("():")); + + return frag; + }, + + /** + * Render the table. + * + * @private + * @return DOMElement + */ + _renderTable: function () { + let result = this.document.createElementNS(XHTML_NS, "div"); + + if (this._populatePromise) { + this._populatePromise.then(() => { + if (this._data.length > 0) { + let widget = new Widgets.Table(this, this._data, this._columns).render(); + result.appendChild(widget.element); + } + + result.scrollIntoView(); + this.output.owner.emit("messages-table-rendered"); + + // Release object actors + if (Array.isArray(this._arguments)) { + for (let arg of this._arguments) { + if (WebConsoleUtils.isActorGrip(arg)) { + this.output._releaseObject(arg.actor); + } + } + } + this._arguments = null; + }); + } + + return result; + }, +}); // Messages.ConsoleTable.prototype + +var Widgets = {}; + +/** + * The base widget class. + * + * @constructor + * @param object message + * The owning message. + */ +Widgets.BaseWidget = function (message) +{ + this.message = message; +}; + +Widgets.BaseWidget.prototype = { + /** + * The owning message object. + * @type object + */ + message: null, + + /** + * The DOM element of the rendered widget. + * @type Element + */ + element: null, + + /** + * Getter for the DOM document that holds the output. + * @type Document + */ + get document() { + return this.message.document; + }, + + /** + * The ConsoleOutput instance that owns this widget instance. + */ + get output() { + return this.message.output; + }, + + /** + * Render the widget DOM element. + * @return this + */ + render: function () { }, + + /** + * Destroy this widget instance. + */ + destroy: function () { }, + + /** + * Helper for creating DOM elements for widgets. + * + * Usage: + * this.el("tag#id.class.names"); // create element "tag" with ID "id" and + * two class names, .class and .names. + * + * this.el("span", { attr1: "value1", ... }) // second argument can be an + * object that holds element attributes and values for the new DOM element. + * + * this.el("p", { attr1: "value1", ... }, "text content"); // the third + * argument can include the default .textContent of the new DOM element. + * + * this.el("p", "text content"); // if the second argument is not an object, + * it will be used as .textContent for the new DOM element. + * + * @param string tagNameIdAndClasses + * Tag name for the new element, optionally followed by an ID and/or + * class names. Examples: "span", "div#fooId", "div.class.names", + * "p#id.class". + * @param string|object [attributesOrTextContent] + * If this argument is an object it will be used to set the attributes + * of the new DOM element. Otherwise, the value becomes the + * .textContent of the new DOM element. + * @param string [textContent] + * If this argument is provided the value is used as the textContent of + * the new DOM element. + * @return DOMElement + * The new DOM element. + */ + el: function (tagNameIdAndClasses) + { + let attrs, text; + if (typeof arguments[1] == "object") { + attrs = arguments[1]; + text = arguments[2]; + } else { + text = arguments[1]; + } + + let tagName = tagNameIdAndClasses.split(/#|\./)[0]; + + let elem = this.document.createElementNS(XHTML_NS, tagName); + for (let name of Object.keys(attrs || {})) { + elem.setAttribute(name, attrs[name]); + } + if (text !== undefined && text !== null) { + elem.textContent = text; + } + + let idAndClasses = tagNameIdAndClasses.match(/([#.][^#.]+)/g); + for (let idOrClass of (idAndClasses || [])) { + if (idOrClass.charAt(0) == "#") { + elem.id = idOrClass.substr(1); + } else { + elem.classList.add(idOrClass.substr(1)); + } + } + + return elem; + }, +}; + +/** + * The timestamp widget. + * + * @constructor + * @param object message + * The owning message. + * @param number timestamp + * The UNIX timestamp to display. + */ +Widgets.MessageTimestamp = function (message, timestamp) +{ + Widgets.BaseWidget.call(this, message); + this.timestamp = timestamp; +}; + +Widgets.MessageTimestamp.prototype = extend(Widgets.BaseWidget.prototype, { + /** + * The UNIX timestamp. + * @type number + */ + timestamp: 0, + + render: function () + { + if (this.element) { + return this; + } + + this.element = this.document.createElementNS(XHTML_NS, "span"); + this.element.className = "timestamp devtools-monospace"; + this.element.textContent = l10n.timestampString(this.timestamp) + " "; + + return this; + }, +}); // Widgets.MessageTimestamp.prototype + + +/** + * The URLString widget, for rendering strings where at least one token is a + * URL. + * + * @constructor + * @param object message + * The owning message. + * @param string str + * The string, which contains at least one valid URL. + * @param string unshortenedStr + * The unshortened form of the string, if it was shortened. + */ +Widgets.URLString = function (message, str, unshortenedStr) +{ + Widgets.BaseWidget.call(this, message); + this.str = str; + this.unshortenedStr = unshortenedStr; +}; + +Widgets.URLString.prototype = extend(Widgets.BaseWidget.prototype, { + /** + * The string to format, which contains at least one valid URL. + * @type string + */ + str: "", + + render: function () + { + if (this.element) { + return this; + } + + // The rendered URLString will be a <span> containing a number of text + // <spans> for non-URL tokens and <a>'s for URL tokens. + this.element = this.el("span", { + class: "console-string" + }); + this.element.appendChild(this._renderText("\"")); + + // As we walk through the tokens of the source string, we make sure to preserve + // the original whitespace that separated the tokens. + let tokens = this.str.split(/\s+/); + let textStart = 0; + let tokenStart; + for (let i = 0; i < tokens.length; i++) { + let token = tokens[i]; + let unshortenedToken; + tokenStart = this.str.indexOf(token, textStart); + if (this._isURL(token)) { + // The last URL in the string might be shortened. If so, get the + // real URL so the rendered link can point to it. + if (i === tokens.length - 1 && this.unshortenedStr) { + unshortenedToken = this.unshortenedStr.slice(tokenStart).split(/\s+/, 1)[0]; + } + this.element.appendChild(this._renderText(this.str.slice(textStart, tokenStart))); + textStart = tokenStart + token.length; + this.element.appendChild(this._renderURL(token, unshortenedToken)); + } + } + + // Clean up any non-URL text at the end of the source string. + this.element.appendChild(this._renderText(this.str.slice(textStart, this.str.length))); + this.element.appendChild(this._renderText("\"")); + + return this; + }, + + /** + * Determines whether a grip is a string containing a URL. + * + * @param string grip + * The grip, which may contain a URL. + * @return boolean + * Whether the grip is a string containing a URL. + */ + containsURL: function (grip) + { + if (typeof grip != "string") { + return false; + } + + let tokens = grip.split(/\s+/); + return tokens.some(this._isURL); + }, + + /** + * Determines whether a string token is a valid URL. + * + * @param string token + * The token. + * @return boolean + * Whenther the token is a URL. + */ + _isURL: function (token) { + try { + if (!validProtocols.test(token)) { + return false; + } + new URL(token); + return true; + } catch (e) { + return false; + } + }, + + /** + * Renders a string as a URL. + * + * @param string url + * The string to be rendered as a url. + * @param string fullUrl + * The unshortened form of the URL, if it was shortened. + * @return DOMElement + * An element containing the rendered string. + */ + _renderURL: function (url, fullUrl) + { + let unshortened = fullUrl || url; + let result = this.el("a", { + class: "url", + title: unshortened, + href: unshortened, + draggable: false + }, url); + this.message._addLinkCallback(result); + return result; + }, + + _renderText: function (text) { + return this.el("span", text); + }, +}); // Widgets.URLString.prototype + +/** + * Widget used for displaying ObjectActors that have no specialised renderers. + * + * @constructor + * @param object message + * The owning message. + * @param object objectActor + * The ObjectActor to display. + * @param object [options] + * Options for displaying the given ObjectActor. See + * Messages.Extended.prototype._renderValueGrip for the available + * options. + */ +Widgets.JSObject = function (message, objectActor, options = {}) +{ + Widgets.BaseWidget.call(this, message); + this.objectActor = objectActor; + this.options = options; + this._onClick = this._onClick.bind(this); +}; + +Widgets.JSObject.prototype = extend(Widgets.BaseWidget.prototype, { + /** + * The ObjectActor displayed by the widget. + * @type object + */ + objectActor: null, + + render: function () + { + if (!this.element) { + this._render(); + } + + return this; + }, + + _render: function () + { + let str = VariablesView.getString(this.objectActor, this.options); + let className = this.message.getClassNameForValueGrip(this.objectActor); + if (!className && this.objectActor.class == "Object") { + className = "cm-variable"; + } + + this.element = this._anchor(str, { className: className }); + }, + + /** + * Render a concise representation of an object. + */ + _renderConciseObject: function () + { + this.element = this._anchor(this.objectActor.class, + { className: "cm-variable" }); + }, + + /** + * Render the `<class> { ` prefix of an object. + */ + _renderObjectPrefix: function () + { + let { kind } = this.objectActor.preview; + this.element = this.el("span.kind-" + kind); + this._anchor(this.objectActor.class, { className: "cm-variable" }); + this._text(" { "); + }, + + /** + * Render the ` }` suffix of an object. + */ + _renderObjectSuffix: function () + { + this._text(" }"); + }, + + /** + * Render an object property. + * + * @param String key + * The property name. + * @param Object value + * The property value, as an RDP grip. + * @param nsIDOMNode container + * The container node to render to. + * @param Boolean needsComma + * True if there was another property before this one and we need to + * separate them with a comma. + * @param Boolean valueIsText + * Add the value as is, don't treat it as a grip and pass it to + * `_renderValueGrip`. + */ + _renderObjectProperty: function (key, value, container, needsComma, valueIsText = false) + { + if (needsComma) { + this._text(", "); + } + + container.appendChild(this.el("span.cm-property", key)); + this._text(": "); + + if (valueIsText) { + this._text(value); + } else { + let valueElem = this.message._renderValueGrip(value, { concise: true, shorten: true }); + container.appendChild(valueElem); + } + }, + + /** + * Render this object's properties. + * + * @param nsIDOMNode container + * The container node to render to. + * @param Boolean needsComma + * True if there was another property before this one and we need to + * separate them with a comma. + */ + _renderObjectProperties: function (container, needsComma) + { + let { preview } = this.objectActor; + let { ownProperties, safeGetterValues } = preview; + + let shown = 0; + + let getValue = desc => { + if (desc.get) { + return "Getter"; + } else if (desc.set) { + return "Setter"; + } else { + return desc.value; + } + }; + + for (let key of Object.keys(ownProperties || {})) { + this._renderObjectProperty(key, getValue(ownProperties[key]), container, + shown > 0 || needsComma, + ownProperties[key].get || ownProperties[key].set); + shown++; + } + + let ownPropertiesShown = shown; + + for (let key of Object.keys(safeGetterValues || {})) { + this._renderObjectProperty(key, safeGetterValues[key].getterValue, + container, shown > 0 || needsComma); + shown++; + } + + if (typeof preview.ownPropertiesLength == "number" && + ownPropertiesShown < preview.ownPropertiesLength) { + this._text(", "); + + let n = preview.ownPropertiesLength - ownPropertiesShown; + let str = VariablesView.stringifiers._getNMoreString(n); + this._anchor(str); + } + }, + + /** + * Render an anchor with a given text content and link. + * + * @private + * @param string text + * Text to show in the anchor. + * @param object [options] + * Available options: + * - onClick (function): "click" event handler.By default a click on + * the anchor opens the variables view for the current object actor + * (this.objectActor). + * - href (string): if given the string is used as a link, and clicks + * on the anchor open the link in a new tab. + * - appendTo (DOMElement): append the element to the given DOM + * element. If not provided, the anchor is appended to |this.element| + * if it is available. If |appendTo| is provided and if it is a falsy + * value, the anchor is not appended to any element. + * @return DOMElement + * The DOM element of the new anchor. + */ + _anchor: function (text, options = {}) + { + if (!options.onClick) { + // If the anchor has an URL, open it in a new tab. If not, show the + // current object actor. + options.onClick = options.href ? this._onClickAnchor : this._onClick; + } + + options.onContextMenu = options.onContextMenu || this._onContextMenu; + + let anchor = this.el("a", { + class: options.className, + draggable: false, + href: options.href || "#", + }, text); + + this.message._addLinkCallback(anchor, options.onClick); + + anchor.addEventListener("contextmenu", options.onContextMenu.bind(this)); + + if (options.appendTo) { + options.appendTo.appendChild(anchor); + } else if (!("appendTo" in options) && this.element) { + this.element.appendChild(anchor); + } + + return anchor; + }, + + openObjectInVariablesView: function () + { + this.output.openVariablesView({ + label: VariablesView.getString(this.objectActor, { concise: true }), + objectActor: this.objectActor, + autofocus: true, + }); + }, + + storeObjectInWindow: function () + { + let evalString = `{ let i = 0; + while (this.hasOwnProperty("temp" + i) && i < 1000) { + i++; + } + this["temp" + i] = _self; + "temp" + i; + }`; + let options = { + selectedObjectActor: this.objectActor.actor, + }; + + this.output.owner.jsterm.requestEvaluation(evalString, options).then((res) => { + this.output.owner.jsterm.focus(); + this.output.owner.jsterm.setInputValue(res.result); + }); + }, + + /** + * The click event handler for objects shown inline. + * @private + */ + _onClick: function () + { + this.openObjectInVariablesView(); + }, + + _onContextMenu: function (ev) { + // TODO offer a nice API for the context menu. + // Probably worth to take a look at Firebug's way + // https://github.com/firebug/firebug/blob/master/extension/content/firebug/chrome/menu.js + let doc = ev.target.ownerDocument; + let cmPopup = doc.getElementById("output-contextmenu"); + + let openInVarViewCmd = doc.getElementById("menu_openInVarView"); + let openVarView = this.openObjectInVariablesView.bind(this); + openInVarViewCmd.addEventListener("command", openVarView); + openInVarViewCmd.removeAttribute("disabled"); + cmPopup.addEventListener("popuphiding", function onPopupHiding() { + cmPopup.removeEventListener("popuphiding", onPopupHiding); + openInVarViewCmd.removeEventListener("command", openVarView); + openInVarViewCmd.setAttribute("disabled", "true"); + }); + + // 'Store as global variable' command isn't supported on pre-44 servers, + // so remove it from the menu in that case. + let storeInGlobalCmd = doc.getElementById("menu_storeAsGlobal"); + if (!this.output.webConsoleClient.traits.selectedObjectActor) { + storeInGlobalCmd.remove(); + } else if (storeInGlobalCmd) { + let storeObjectInWindow = this.storeObjectInWindow.bind(this); + storeInGlobalCmd.addEventListener("command", storeObjectInWindow); + storeInGlobalCmd.removeAttribute("disabled"); + cmPopup.addEventListener("popuphiding", function onPopupHiding() { + cmPopup.removeEventListener("popuphiding", onPopupHiding); + storeInGlobalCmd.removeEventListener("command", storeObjectInWindow); + storeInGlobalCmd.setAttribute("disabled", "true"); + }); + } + }, + + /** + * Add a string to the message. + * + * @private + * @param string str + * String to add. + * @param DOMElement [target = this.element] + * Optional DOM element to append the string to. The default is + * this.element. + */ + _text: function (str, target = this.element) + { + target.appendChild(this.document.createTextNode(str)); + }, +}); // Widgets.JSObject.prototype + +Widgets.ObjectRenderers = {}; +Widgets.ObjectRenderers.byKind = {}; +Widgets.ObjectRenderers.byClass = {}; + +/** + * Add an object renderer. + * + * @param object obj + * An object that represents the renderer. Properties: + * - byClass (string, optional): this renderer will be used for the given + * object class. + * - byKind (string, optional): this renderer will be used for the given + * object kind. + * One of byClass or byKind must be provided. + * - extends (object, optional): the renderer object extends the given + * object. Default: Widgets.JSObject. + * - canRender (function, optional): this method is invoked when + * a candidate object needs to be displayed. The method is invoked as + * a static method, as such, none of the properties of the renderer + * object will be available. You get one argument: the object actor grip + * received from the server. If the method returns true, then this + * renderer is used for displaying the object, otherwise not. + * - initialize (function, optional): the constructor of the renderer + * widget. This function is invoked with the following arguments: the + * owner message object instance, the object actor grip to display, and + * an options object. See Messages.Extended.prototype._renderValueGrip() + * for details about the options object. + * - render (function, required): the method that displays the given + * object actor. + */ +Widgets.ObjectRenderers.add = function (obj) +{ + let extendObj = obj.extends || Widgets.JSObject; + + let constructor = function () { + if (obj.initialize) { + obj.initialize.apply(this, arguments); + } else { + extendObj.apply(this, arguments); + } + }; + + let proto = WebConsoleUtils.cloneObject(obj, false, function (key) { + if (key == "initialize" || key == "canRender" || + (key == "render" && extendObj === Widgets.JSObject)) { + return false; + } + return true; + }); + + if (extendObj === Widgets.JSObject) { + proto._render = obj.render; + } + + constructor.canRender = obj.canRender; + constructor.prototype = extend(extendObj.prototype, proto); + + if (obj.byClass) { + Widgets.ObjectRenderers.byClass[obj.byClass] = constructor; + } else if (obj.byKind) { + Widgets.ObjectRenderers.byKind[obj.byKind] = constructor; + } else { + throw new Error("You are adding an object renderer without any byClass or " + + "byKind property."); + } +}; + + +/** + * The widget used for displaying Date objects. + */ +Widgets.ObjectRenderers.add({ + byClass: "Date", + + render: function () + { + let {preview} = this.objectActor; + this.element = this.el("span.class-" + this.objectActor.class); + + let anchorText = this.objectActor.class; + let anchorClass = "cm-variable"; + if (preview && "timestamp" in preview && typeof preview.timestamp != "number") { + anchorText = new Date(preview.timestamp).toString(); // invalid date + anchorClass = ""; + } + + this._anchor(anchorText, { className: anchorClass }); + + if (!preview || !("timestamp" in preview) || typeof preview.timestamp != "number") { + return; + } + + this._text(" "); + + let elem = this.el("span.cm-string-2", new Date(preview.timestamp).toISOString()); + this.element.appendChild(elem); + }, +}); + +/** + * The widget used for displaying Function objects. + */ +Widgets.ObjectRenderers.add({ + byClass: "Function", + + render: function () + { + let grip = this.objectActor; + this.element = this.el("span.class-" + this.objectActor.class); + + // TODO: Bug 948484 - support arrow functions and ES6 generators + let name = grip.userDisplayName || grip.displayName || grip.name || ""; + name = VariablesView.getString(name, { noStringQuotes: true }); + + let str = this.options.concise ? name || "function " : "function " + name; + + if (this.options.concise) { + this._anchor(name || "function", { + className: name ? "cm-variable" : "cm-keyword", + }); + if (!name) { + this._text(" "); + } + } else if (name) { + this.element.appendChild(this.el("span.cm-keyword", "function")); + this._text(" "); + this._anchor(name, { className: "cm-variable" }); + } else { + this._anchor("function", { className: "cm-keyword" }); + this._text(" "); + } + + this._text("("); + + // TODO: Bug 948489 - Support functions with destructured parameters and + // rest parameters + let params = grip.parameterNames || []; + let shown = 0; + for (let param of params) { + if (shown > 0) { + this._text(", "); + } + this.element.appendChild(this.el("span.cm-def", param)); + shown++; + } + + this._text(")"); + }, + + _onClick: function () { + let location = this.objectActor.location; + if (location && IGNORED_SOURCE_URLS.indexOf(location.url) === -1) { + this.output.openLocationInDebugger(location); + } + else { + this.openObjectInVariablesView(); + } + } +}); // Widgets.ObjectRenderers.byClass.Function + +/** + * The widget used for displaying ArrayLike objects. + */ +Widgets.ObjectRenderers.add({ + byKind: "ArrayLike", + + render: function () + { + let {preview} = this.objectActor; + let {items} = preview; + this.element = this.el("span.kind-" + preview.kind); + + this._anchor(this.objectActor.class, { className: "cm-variable" }); + + if (!items || this.options.concise) { + this._text("["); + this.element.appendChild(this.el("span.cm-number", preview.length)); + this._text("]"); + return this; + } + + this._text(" [ "); + + let isFirst = true; + let emptySlots = 0; + // A helper that renders a comma between items if isFirst == false. + let renderSeparator = () => !isFirst && this._text(", "); + + for (let item of items) { + if (item === null) { + emptySlots++; + } + else { + renderSeparator(); + isFirst = false; + + if (emptySlots) { + this._renderEmptySlots(emptySlots); + emptySlots = 0; + } + + let elem = this.message._renderValueGrip(item, { concise: true, shorten: true }); + this.element.appendChild(elem); + } + } + + if (emptySlots) { + renderSeparator(); + this._renderEmptySlots(emptySlots, false); + } + + let shown = items.length; + if (shown < preview.length) { + this._text(", "); + + let n = preview.length - shown; + let str = VariablesView.stringifiers._getNMoreString(n); + this._anchor(str); + } + + this._text(" ]"); + }, + + _renderEmptySlots: function (aNumSlots, aAppendComma = true) { + let slotLabel = l10n.getStr("emptySlotLabel"); + let slotText = PluralForm.get(aNumSlots, slotLabel); + this._text("<" + slotText.replace("#1", aNumSlots) + ">"); + if (aAppendComma) { + this._text(", "); + } + }, + +}); // Widgets.ObjectRenderers.byKind.ArrayLike + +/** + * The widget used for displaying MapLike objects. + */ +Widgets.ObjectRenderers.add({ + byKind: "MapLike", + + render: function () + { + let {preview} = this.objectActor; + let {entries} = preview; + + let container = this.element = this.el("span.kind-" + preview.kind); + this._anchor(this.objectActor.class, { className: "cm-variable" }); + + if (!entries || this.options.concise) { + if (typeof preview.size == "number") { + this._text("["); + container.appendChild(this.el("span.cm-number", preview.size)); + this._text("]"); + } + return; + } + + this._text(" { "); + + let shown = 0; + for (let [key, value] of entries) { + if (shown > 0) { + this._text(", "); + } + + let keyElem = this.message._renderValueGrip(key, { + concise: true, + noStringQuotes: true, + }); + + // Strings are property names. + if (keyElem.classList && keyElem.classList.contains("console-string")) { + keyElem.classList.remove("console-string"); + keyElem.classList.add("cm-property"); + } + + container.appendChild(keyElem); + + this._text(": "); + + let valueElem = this.message._renderValueGrip(value, { concise: true }); + container.appendChild(valueElem); + + shown++; + } + + if (typeof preview.size == "number" && shown < preview.size) { + this._text(", "); + + let n = preview.size - shown; + let str = VariablesView.stringifiers._getNMoreString(n); + this._anchor(str); + } + + this._text(" }"); + }, +}); // Widgets.ObjectRenderers.byKind.MapLike + +/** + * The widget used for displaying objects with a URL. + */ +Widgets.ObjectRenderers.add({ + byKind: "ObjectWithURL", + + render: function () + { + this.element = this._renderElement(this.objectActor, + this.objectActor.preview.url); + }, + + _renderElement: function (objectActor, url) + { + let container = this.el("span.kind-" + objectActor.preview.kind); + + this._anchor(objectActor.class, { + className: "cm-variable", + appendTo: container, + }); + + if (!VariablesView.isFalsy({ value: url })) { + this._text(" \u2192 ", container); + let shortUrl = getSourceNames(url)[this.options.concise ? "short" : "long"]; + this._anchor(shortUrl, { href: url, appendTo: container }); + } + + return container; + }, +}); // Widgets.ObjectRenderers.byKind.ObjectWithURL + +/** + * The widget used for displaying objects with a string next to them. + */ +Widgets.ObjectRenderers.add({ + byKind: "ObjectWithText", + + render: function () + { + let {preview} = this.objectActor; + this.element = this.el("span.kind-" + preview.kind); + + this._anchor(this.objectActor.class, { className: "cm-variable" }); + + if (!this.options.concise) { + this._text(" "); + this.element.appendChild(this.el("span.theme-fg-color6", + VariablesView.getString(preview.text))); + } + }, +}); + +/** + * The widget used for displaying DOM event previews. + */ +Widgets.ObjectRenderers.add({ + byKind: "DOMEvent", + + render: function () + { + let {preview} = this.objectActor; + + let container = this.element = this.el("span.kind-" + preview.kind); + + this._anchor(preview.type || this.objectActor.class, + { className: "cm-variable" }); + + if (this.options.concise) { + return; + } + + if (preview.eventKind == "key" && preview.modifiers && + preview.modifiers.length) { + this._text(" "); + + let mods = 0; + for (let mod of preview.modifiers) { + if (mods > 0) { + this._text("-"); + } + container.appendChild(this.el("span.cm-keyword", mod)); + mods++; + } + } + + this._text(" { "); + + let shown = 0; + if (preview.target) { + container.appendChild(this.el("span.cm-property", "target")); + this._text(": "); + let target = this.message._renderValueGrip(preview.target, { concise: true }); + container.appendChild(target); + shown++; + } + + for (let key of Object.keys(preview.properties || {})) { + if (shown > 0) { + this._text(", "); + } + + container.appendChild(this.el("span.cm-property", key)); + this._text(": "); + + let value = preview.properties[key]; + let valueElem = this.message._renderValueGrip(value, { concise: true }); + container.appendChild(valueElem); + + shown++; + } + + this._text(" }"); + }, +}); // Widgets.ObjectRenderers.byKind.DOMEvent + +/** + * The widget used for displaying DOM node previews. + */ +Widgets.ObjectRenderers.add({ + byKind: "DOMNode", + + canRender: function (objectActor) { + let {preview} = objectActor; + if (!preview) { + return false; + } + + switch (preview.nodeType) { + case nodeConstants.DOCUMENT_NODE: + case nodeConstants.ATTRIBUTE_NODE: + case nodeConstants.TEXT_NODE: + case nodeConstants.COMMENT_NODE: + case nodeConstants.DOCUMENT_FRAGMENT_NODE: + case nodeConstants.ELEMENT_NODE: + return true; + default: + return false; + } + }, + + render: function () + { + switch (this.objectActor.preview.nodeType) { + case nodeConstants.DOCUMENT_NODE: + this._renderDocumentNode(); + break; + case nodeConstants.ATTRIBUTE_NODE: { + let {preview} = this.objectActor; + this.element = this.el("span.attributeNode.kind-" + preview.kind); + let attr = this._renderAttributeNode(preview.nodeName, preview.value, true); + this.element.appendChild(attr); + break; + } + case nodeConstants.TEXT_NODE: + this._renderTextNode(); + break; + case nodeConstants.COMMENT_NODE: + this._renderCommentNode(); + break; + case nodeConstants.DOCUMENT_FRAGMENT_NODE: + this._renderDocumentFragmentNode(); + break; + case nodeConstants.ELEMENT_NODE: + this._renderElementNode(); + break; + default: + throw new Error("Unsupported nodeType: " + preview.nodeType); + } + }, + + _renderDocumentNode: function () + { + let fn = + Widgets.ObjectRenderers.byKind.ObjectWithURL.prototype._renderElement; + this.element = fn.call(this, this.objectActor, + this.objectActor.preview.location); + this.element.classList.add("documentNode"); + }, + + _renderAttributeNode: function (nodeName, nodeValue, addLink) + { + let value = VariablesView.getString(nodeValue, { noStringQuotes: true }); + + let fragment = this.document.createDocumentFragment(); + if (addLink) { + this._anchor(nodeName, { className: "cm-attribute", appendTo: fragment }); + } else { + fragment.appendChild(this.el("span.cm-attribute", nodeName)); + } + + this._text("=\"", fragment); + fragment.appendChild(this.el("span.theme-fg-color6", escapeHTML(value))); + this._text("\"", fragment); + + return fragment; + }, + + _renderTextNode: function () + { + let {preview} = this.objectActor; + this.element = this.el("span.textNode.kind-" + preview.kind); + + this._anchor(preview.nodeName, { className: "cm-variable" }); + this._text(" "); + + let text = VariablesView.getString(preview.textContent); + this.element.appendChild(this.el("span.console-string", text)); + }, + + _renderCommentNode: function () + { + let {preview} = this.objectActor; + let comment = "<!-- " + VariablesView.getString(preview.textContent, { + noStringQuotes: true, + }) + " -->"; + + this.element = this._anchor(comment, { + className: "kind-" + preview.kind + " commentNode cm-comment", + }); + }, + + _renderDocumentFragmentNode: function () + { + let {preview} = this.objectActor; + let {childNodes} = preview; + let container = this.element = this.el("span.documentFragmentNode.kind-" + + preview.kind); + + this._anchor(this.objectActor.class, { className: "cm-variable" }); + + if (!childNodes || this.options.concise) { + this._text("["); + container.appendChild(this.el("span.cm-number", preview.childNodesLength)); + this._text("]"); + return; + } + + this._text(" [ "); + + let shown = 0; + for (let item of childNodes) { + if (shown > 0) { + this._text(", "); + } + + let elem = this.message._renderValueGrip(item, { concise: true }); + container.appendChild(elem); + shown++; + } + + if (shown < preview.childNodesLength) { + this._text(", "); + + let n = preview.childNodesLength - shown; + let str = VariablesView.stringifiers._getNMoreString(n); + this._anchor(str); + } + + this._text(" ]"); + }, + + _renderElementNode: function () + { + let doc = this.document; + let {attributes, nodeName} = this.objectActor.preview; + + this.element = this.el("span." + "kind-" + this.objectActor.preview.kind + ".elementNode"); + + this._text("<"); + let openTag = this.el("span.cm-tag"); + this.element.appendChild(openTag); + + let tagName = this._anchor(nodeName, { + className: "cm-tag", + appendTo: openTag + }); + + if (this.options.concise) { + if (attributes.id) { + tagName.appendChild(this.el("span.cm-attribute", "#" + attributes.id)); + } + if (attributes.class) { + tagName.appendChild(this.el("span.cm-attribute", "." + attributes.class.split(/\s+/g).join("."))); + } + } else { + for (let name of Object.keys(attributes)) { + let attr = this._renderAttributeNode(" " + name, attributes[name]); + this.element.appendChild(attr); + } + } + + this._text(">"); + + // Register this widget in the owner message so that it gets destroyed when + // the message is destroyed. + this.message.widgets.add(this); + + this.linkToInspector().then(null, e => console.error(e)); + }, + + /** + * If the DOMNode being rendered can be highlit in the page, this function + * will attach mouseover/out event listeners to do so, and the inspector icon + * to open the node in the inspector. + * @return a promise that resolves when the node has been linked to the + * inspector, or rejects if it wasn't (either if no toolbox could be found to + * access the inspector, or if the node isn't present in the inspector, i.e. + * if the node is in a DocumentFragment or not part of the tree, or not of + * type nodeConstants.ELEMENT_NODE). + */ + linkToInspector: Task.async(function* () + { + if (this._linkedToInspector) { + return; + } + + // Checking the node type + if (this.objectActor.preview.nodeType !== nodeConstants.ELEMENT_NODE) { + throw new Error("The object cannot be linked to the inspector as it " + + "isn't an element node"); + } + + // Checking the presence of a toolbox + let target = this.message.output.toolboxTarget; + this.toolbox = gDevTools.getToolbox(target); + if (!this.toolbox) { + // In cases like the browser console, there is no toolbox. + return; + } + + // Checking that the inspector supports the node + yield this.toolbox.initInspector(); + this._nodeFront = yield this.toolbox.walker.getNodeActorFromObjectActor(this.objectActor.actor); + if (!this._nodeFront) { + throw new Error("The object cannot be linked to the inspector, the " + + "corresponding nodeFront could not be found"); + } + + // At this stage, the message may have been cleared already + if (!this.document) { + throw new Error("The object cannot be linked to the inspector, the " + + "message was got cleared away"); + } + + // Check it again as this method is async! + if (this._linkedToInspector) { + return; + } + this._linkedToInspector = true; + + this.highlightDomNode = this.highlightDomNode.bind(this); + this.element.addEventListener("mouseover", this.highlightDomNode, false); + this.unhighlightDomNode = this.unhighlightDomNode.bind(this); + this.element.addEventListener("mouseout", this.unhighlightDomNode, false); + + this._openInspectorNode = this._anchor("", { + className: "open-inspector", + onClick: this.openNodeInInspector.bind(this) + }); + this._openInspectorNode.title = l10n.getStr("openNodeInInspector"); + }), + + /** + * Highlight the DOMNode corresponding to the ObjectActor in the page. + * @return a promise that resolves when the node has been highlighted, or + * rejects if the node cannot be highlighted (detached from the DOM) + */ + highlightDomNode: Task.async(function* () + { + yield this.linkToInspector(); + let isAttached = yield this.toolbox.walker.isInDOMTree(this._nodeFront); + if (isAttached) { + yield this.toolbox.highlighterUtils.highlightNodeFront(this._nodeFront); + } else { + throw null; + } + }), + + /** + * Unhighlight a previously highlit node + * @see highlightDomNode + * @return a promise that resolves when the highlighter has been hidden + */ + unhighlightDomNode: function () + { + return this.linkToInspector().then(() => { + return this.toolbox.highlighterUtils.unhighlight(); + }).then(null, e => console.error(e)); + }, + + /** + * Open the DOMNode corresponding to the ObjectActor in the inspector panel + * @return a promise that resolves when the inspector has been switched to + * and the node has been selected, or rejects if the node cannot be selected + * (detached from the DOM). Note that in any case, the inspector panel will + * be switched to. + */ + openNodeInInspector: Task.async(function* () + { + yield this.linkToInspector(); + yield this.toolbox.selectTool("inspector"); + + let isAttached = yield this.toolbox.walker.isInDOMTree(this._nodeFront); + if (isAttached) { + let onReady = promise.defer(); + this.toolbox.inspector.once("inspector-updated", onReady.resolve); + yield this.toolbox.selection.setNodeFront(this._nodeFront, "console"); + yield onReady.promise; + } else { + throw null; + } + }), + + destroy: function () + { + if (this.toolbox && this._nodeFront) { + this.element.removeEventListener("mouseover", this.highlightDomNode, false); + this.element.removeEventListener("mouseout", this.unhighlightDomNode, false); + this._openInspectorNode.removeEventListener("mousedown", this.openNodeInInspector, true); + + if (this._linkedToInspector) { + this.unhighlightDomNode().then(() => { + this.toolbox = null; + this._nodeFront = null; + }); + } else { + this.toolbox = null; + this._nodeFront = null; + } + } + }, +}); // Widgets.ObjectRenderers.byKind.DOMNode + +/** + * The widget user for displaying Promise objects. + */ +Widgets.ObjectRenderers.add({ + byClass: "Promise", + + render: function () + { + let { ownProperties, safeGetterValues } = this.objectActor.preview || {}; + if ((!ownProperties && !safeGetterValues) || this.options.concise) { + this._renderConciseObject(); + return; + } + + this._renderObjectPrefix(); + let container = this.element; + let addedPromiseInternalProps = false; + + if (this.objectActor.promiseState) { + const { state, value, reason } = this.objectActor.promiseState; + + this._renderObjectProperty("<state>", state, container, false); + addedPromiseInternalProps = true; + + if (state == "fulfilled") { + this._renderObjectProperty("<value>", value, container, true); + } else if (state == "rejected") { + this._renderObjectProperty("<reason>", reason, container, true); + } + } + + this._renderObjectProperties(container, addedPromiseInternalProps); + this._renderObjectSuffix(); + } +}); // Widgets.ObjectRenderers.byClass.Promise + +/* + * A renderer used for wrapped primitive objects. + */ + +function WrappedPrimitiveRenderer() { + let { ownProperties, safeGetterValues } = this.objectActor.preview || {}; + if ((!ownProperties && !safeGetterValues) || this.options.concise) { + this._renderConciseObject(); + return; + } + + this._renderObjectPrefix(); + + let elem = + this.message._renderValueGrip(this.objectActor.preview.wrappedValue); + this.element.appendChild(elem); + + this._renderObjectProperties(this.element, true); + this._renderObjectSuffix(); +} + +/** + * The widget used for displaying Boolean previews. + */ +Widgets.ObjectRenderers.add({ + byClass: "Boolean", + + render: WrappedPrimitiveRenderer, +}); + +/** + * The widget used for displaying Number previews. + */ +Widgets.ObjectRenderers.add({ + byClass: "Number", + + render: WrappedPrimitiveRenderer, +}); + +/** + * The widget used for displaying String previews. + */ +Widgets.ObjectRenderers.add({ + byClass: "String", + + render: WrappedPrimitiveRenderer, +}); + +/** + * The widget used for displaying generic JS object previews. + */ +Widgets.ObjectRenderers.add({ + byKind: "Object", + + render: function () + { + let { ownProperties, safeGetterValues } = this.objectActor.preview || {}; + if ((!ownProperties && !safeGetterValues) || this.options.concise) { + this._renderConciseObject(); + return; + } + + this._renderObjectPrefix(); + this._renderObjectProperties(this.element, false); + this._renderObjectSuffix(); + }, +}); // Widgets.ObjectRenderers.byKind.Object + +/** + * The long string widget. + * + * @constructor + * @param object message + * The owning message. + * @param object longStringActor + * The LongStringActor to display. + * @param object options + * Options, such as noStringQuotes + */ +Widgets.LongString = function (message, longStringActor, options) +{ + Widgets.BaseWidget.call(this, message); + this.longStringActor = longStringActor; + this.noStringQuotes = (options && "noStringQuotes" in options) ? + options.noStringQuotes : !this.message._quoteStrings; + + this._onClick = this._onClick.bind(this); + this._onSubstring = this._onSubstring.bind(this); +}; + +Widgets.LongString.prototype = extend(Widgets.BaseWidget.prototype, { + /** + * The LongStringActor displayed by the widget. + * @type object + */ + longStringActor: null, + + render: function () + { + if (this.element) { + return this; + } + + let result = this.element = this.document.createElementNS(XHTML_NS, "span"); + result.className = "longString console-string"; + this._renderString(this.longStringActor.initial); + result.appendChild(this._renderEllipsis()); + + return this; + }, + + /** + * Render the long string in the widget element. + * @private + * @param string str + * The string to display. + */ + _renderString: function (str) + { + this.element.textContent = VariablesView.getString(str, { + noStringQuotes: this.noStringQuotes, + noEllipsis: true, + }); + }, + + /** + * Render the anchor ellipsis that allows the user to expand the long string. + * + * @private + * @return Element + */ + _renderEllipsis: function () + { + let ellipsis = this.document.createElementNS(XHTML_NS, "a"); + ellipsis.className = "longStringEllipsis"; + ellipsis.textContent = l10n.getStr("longStringEllipsis"); + ellipsis.href = "#"; + ellipsis.draggable = false; + this.message._addLinkCallback(ellipsis, this._onClick); + + return ellipsis; + }, + + /** + * The click event handler for the ellipsis shown after the short string. This + * function expands the element to show the full string. + * @private + */ + _onClick: function () + { + let longString = this.output.webConsoleClient.longString(this.longStringActor); + let toIndex = Math.min(longString.length, MAX_LONG_STRING_LENGTH); + + longString.substring(longString.initial.length, toIndex, this._onSubstring); + }, + + /** + * The longString substring response callback. + * + * @private + * @param object response + * Response packet. + */ + _onSubstring: function (response) + { + if (response.error) { + console.error("LongString substring failure: " + response.error); + return; + } + + this.element.lastChild.remove(); + this.element.classList.remove("longString"); + + this._renderString(this.longStringActor.initial + response.substring); + + this.output.owner.emit("new-messages", new Set([{ + update: true, + node: this.message.element, + response: response, + }])); + + let toIndex = Math.min(this.longStringActor.length, MAX_LONG_STRING_LENGTH); + if (toIndex != this.longStringActor.length) { + this._logWarningAboutStringTooLong(); + } + }, + + /** + * Inform user that the string he tries to view is too long. + * @private + */ + _logWarningAboutStringTooLong: function () + { + let msg = new Messages.Simple(l10n.getStr("longStringTooLong"), { + category: "output", + severity: "warning", + }); + this.output.addMessage(msg); + }, +}); // Widgets.LongString.prototype + + +/** + * The stacktrace widget. + * + * @constructor + * @extends Widgets.BaseWidget + * @param object message + * The owning message. + * @param array stacktrace + * The stacktrace to display, array of frames as supplied by the server, + * over the remote protocol. + */ +Widgets.Stacktrace = function (message, stacktrace) { + Widgets.BaseWidget.call(this, message); + this.stacktrace = stacktrace; +}; + +Widgets.Stacktrace.prototype = extend(Widgets.BaseWidget.prototype, { + /** + * The stackframes received from the server. + * @type array + */ + stacktrace: null, + + render() { + if (this.element) { + return this; + } + + let result = this.element = this.document.createElementNS(XHTML_NS, "div"); + result.className = "stacktrace devtools-monospace"; + + if (this.stacktrace) { + this.output.owner.ReactDOM.render(this.output.owner.StackTraceView({ + stacktrace: this.stacktrace, + onViewSourceInDebugger: frame => this.output.openLocationInDebugger(frame) + }), result); + } + + return this; + } +}); + +/** + * The table widget. + * + * @constructor + * @extends Widgets.BaseWidget + * @param object message + * The owning message. + * @param array data + * Array of objects that holds the data to log in the table. + * @param object columns + * Object containing the key value pair of the id and display name for + * the columns in the table. + */ +Widgets.Table = function (message, data, columns) +{ + Widgets.BaseWidget.call(this, message); + this.data = data; + this.columns = columns; +}; + +Widgets.Table.prototype = extend(Widgets.BaseWidget.prototype, { + /** + * Array of objects that holds the data to output in the table. + * @type array + */ + data: null, + + /** + * Object containing the key value pair of the id and display name for + * the columns in the table. + * @type object + */ + columns: null, + + render: function () { + if (this.element) { + return this; + } + + let result = this.element = this.document.createElementNS(XHTML_NS, "div"); + result.className = "consoletable devtools-monospace"; + + this.table = new TableWidget(result, { + wrapTextInElements: true, + initialColumns: this.columns, + uniqueId: "_index", + firstColumn: "_index" + }); + + for (let row of this.data) { + this.table.push(row); + } + + return this; + } +}); // Widgets.Table.prototype + +function gSequenceId() +{ + return gSequenceId.n++; +} +gSequenceId.n = 0; + +exports.ConsoleOutput = ConsoleOutput; +exports.Messages = Messages; +exports.Widgets = Widgets; |