diff --git a/browser/devtools/webconsole/console-output.js b/browser/devtools/webconsole/console-output.js index 66a14150cfd..b0ecd465edc 100644 --- a/browser/devtools/webconsole/console-output.js +++ b/browser/devtools/webconsole/console-output.js @@ -10,8 +10,12 @@ const {Cc, Ci, Cu} = require("chrome"); loader.lazyImporter(this, "VariablesView", "resource:///modules/devtools/VariablesView.jsm"); loader.lazyImporter(this, "escapeHTML", "resource:///modules/devtools/VariablesView.jsm"); loader.lazyImporter(this, "gDevTools", "resource:///modules/devtools/gDevTools.jsm"); -loader.lazyImporter(this, "Task","resource://gre/modules/Task.jsm"); +loader.lazyImporter(this, "Task", "resource://gre/modules/Task.jsm"); loader.lazyImporter(this, "PluralForm", "resource://gre/modules/PluralForm.jsm"); +loader.lazyImporter(this, "ObjectClient", "resource://gre/modules/devtools/dbg-client.jsm"); + +loader.lazyRequireGetter(this, "promise"); +loader.lazyRequireGetter(this, "TableWidget", "devtools/shared/widgets/TableWidget", true); const Heritage = require("sdk/core/heritage"); const URI = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService); @@ -81,6 +85,7 @@ const CONSOLE_API_LEVELS_TO_SEVERITIES = { info: "info", log: "log", trace: "log", + table: "log", debug: "log", dir: "log", group: "log", @@ -111,6 +116,12 @@ const RE_CLEANUP_STYLES = [ /['"(]*(?: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. @@ -1616,6 +1627,343 @@ Messages.ConsoleTrace.prototype = Heritage.extend(Messages.Simple.prototype, _renderRepeatNode: function() { }, }); // 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: "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._renderTable = this._renderTable.bind(this); + Messages.Extended.call(this, [this._renderTable], options); + + this._repeatID.consoleApiLevel = packet.level; + this._arguments = packet.arguments; +}; + +Messages.ConsoleTable.prototype = Heritage.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") { + 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"); + } + + 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") { + 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 index of Object.keys(entries || {})) { + let [key, value] = entries[index]; + let item = { + _index: index, + _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") { + 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 index of Object.keys(entries || {})) { + let value = entries[index]; + let item = { + _index : index, + _value: this._renderValueGrip(value, { concise: true }) + }; + + this._data.push(item); + + if (++rowCount == TABLE_ROW_MAX_ITEMS) { + break; + } + } + + deferred.resolve(); + } + + return deferred.promise; + }, + + render: function() + { + Messages.Extended.prototype.render.apply(this, arguments); + this.element.setAttribute("open", true); + return this; + }, + + /** + * Render the table. + * + * @private + * @return DOMElement + */ + _renderTable: 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 title = this.document.createElementNS(XHTML_NS, "span"); + title.className = "message-body devtools-monospace"; + title.appendChild(cmvar); + title.appendChild(this.document.createTextNode(".")); + title.appendChild(cmprop); + title.appendChild(this.document.createTextNode("():")); + + let repeatNode = Messages.Simple.prototype._renderRepeatNode.call(this); + let location = Messages.Simple.prototype._renderLocation.call(this); + if (location) { + location.target = "jsdebugger"; + } + + let body = this.document.createElementNS(XHTML_NS, "span"); + body.className = "message-flex-body"; + body.appendChild(title); + if (repeatNode) { + body.appendChild(repeatNode); + } + if (location) { + body.appendChild(location); + } + body.appendChild(this.document.createTextNode("\n")); + + let result = this.document.createElementNS(XHTML_NS, "div"); + result.appendChild(body); + + 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(); + + // 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; + }, + + _renderBody: function() + { + let body = Messages.Simple.prototype._renderBody.apply(this, arguments); + body.classList.remove("devtools-monospace", "message-body"); + return body; + }, + + // no-op for the message location and .repeats elements. + // |this._renderTable| handles customized message output. + _renderLocation: function() { }, + _renderRepeatNode: function() { }, +}); // Messages.ConsoleTable.prototype + let Widgets = {}; /** @@ -3012,6 +3360,63 @@ Widgets.Stacktrace.prototype = Heritage.extend(Widgets.BaseWidget.prototype, }); // Widgets.Stacktrace.prototype +/** + * 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 = Heritage.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, { + 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++; diff --git a/browser/devtools/webconsole/webconsole.js b/browser/devtools/webconsole/webconsole.js index 611231d32b9..82ac1d5c395 100644 --- a/browser/devtools/webconsole/webconsole.js +++ b/browser/devtools/webconsole/webconsole.js @@ -123,6 +123,7 @@ const LEVELS = { info: SEVERITY_INFO, log: SEVERITY_LOG, trace: SEVERITY_LOG, + table: SEVERITY_LOG, debug: SEVERITY_LOG, dir: SEVERITY_LOG, group: SEVERITY_LOG, @@ -1212,6 +1213,11 @@ WebConsoleFrame.prototype = { node = msg.init(this.output).render().element; break; } + case "table": { + let msg = new Messages.ConsoleTable(aMessage); + node = msg.init(this.output).render().element; + break; + } case "trace": { let msg = new Messages.ConsoleTrace(aMessage); node = msg.init(this.output).render().element; diff --git a/browser/locales/en-US/chrome/browser/devtools/webconsole.properties b/browser/locales/en-US/chrome/browser/devtools/webconsole.properties index a67eaf6e802..d74b96e3f8e 100644 --- a/browser/locales/en-US/chrome/browser/devtools/webconsole.properties +++ b/browser/locales/en-US/chrome/browser/devtools/webconsole.properties @@ -250,3 +250,10 @@ messageToggleDetails=Show/hide message details. # example: 1 empty slot # example: 5 empty slots emptySlotLabel=#1 empty slot;#1 empty slots + +# LOCALIZATION NOTE (table.index, table.iterationIndex, table.key, table.value): +# the column header displayed in the console table widget. +table.index=(index) +table.iterationIndex=(iteration index) +table.key=Key +table.value=Values diff --git a/browser/themes/shared/devtools/webconsole.inc.css b/browser/themes/shared/devtools/webconsole.inc.css index f471a4e8746..6ce4ce24b9c 100644 --- a/browser/themes/shared/devtools/webconsole.inc.css +++ b/browser/themes/shared/devtools/webconsole.inc.css @@ -63,6 +63,10 @@ a { margin: 3px; } +.message-body-wrapper .table-widget-body { + overflow: visible; +} + /* The red bubble that shows the number of times a message is repeated */ .message-repeats { -moz-user-select: none; @@ -223,6 +227,13 @@ a { color: hsl(24,85%,39%); } +.theme-selected .console-string, +.theme-selected .cm-number, +.theme-selected .cm-variable, +.theme-selected .kind-ArrayLike { + color: #f5f7fa !important; /* Selection Text Color */ +} + .message[category=network] > .indent { -moz-border-end: solid #000 6px; } @@ -429,6 +440,10 @@ a { border-radius: 3px; } +.consoletable { + margin: 5px 0 0 0; +} + .theme-light .message[severity=error] .stacktrace { background-color: rgba(255, 255, 255, 0.5); }