/* -*- 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 = Components.interfaces; const Cu = Components.utils; const DBG_STRINGS_URI = "chrome://browser/locale/devtools/debugger.properties"; const LAZY_EMPTY_DELAY = 150; // ms const LAZY_EXPAND_DELAY = 50; // ms const SCROLL_PAGE_SIZE_DEFAULT = 0; const APPEND_PAGE_SIZE_DEFAULT = 500; const PAGE_SIZE_SCROLL_HEIGHT_RATIO = 100; const PAGE_SIZE_MAX_JUMPS = 30; const SEARCH_ACTION_MAX_DELAY = 300; // ms const ITEM_FLASH_DURATION = 300 // ms Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); Cu.import("resource://gre/modules/devtools/event-emitter.js"); Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm"); Cu.import("resource://gre/modules/Task.jsm"); let {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {}); XPCOMUtils.defineLazyModuleGetter(this, "devtools", "resource://gre/modules/devtools/Loader.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", "resource://gre/modules/PluralForm.jsm"); XPCOMUtils.defineLazyServiceGetter(this, "clipboardHelper", "@mozilla.org/widget/clipboardhelper;1", "nsIClipboardHelper"); Object.defineProperty(this, "WebConsoleUtils", { get: function() { return devtools.require("devtools/toolkit/webconsole/utils").Utils; }, configurable: true, enumerable: true }); Object.defineProperty(this, "NetworkHelper", { get: function() { return devtools.require("devtools/toolkit/webconsole/network-helper"); }, configurable: true, enumerable: true }); this.EXPORTED_SYMBOLS = ["VariablesView", "escapeHTML"]; /** * Debugger localization strings. */ const STR = Services.strings.createBundle(DBG_STRINGS_URI); /** * A tree view for inspecting scopes, objects and properties. * Iterable via "for (let [id, scope] of instance) { }". * Requires the devtools common.css and debugger.css skin stylesheets. * * To allow replacing variable or property values in this view, provide an * "eval" function property. To allow replacing variable or property names, * provide a "switch" function. To handle deleting variables or properties, * provide a "delete" function. * * @param nsIDOMNode aParentNode * The parent node to hold this view. * @param object aFlags [optional] * An object contaning initialization options for this view. * e.g. { lazyEmpty: true, searchEnabled: true ... } */ this.VariablesView = function VariablesView(aParentNode, aFlags = {}) { this._store = []; // Can't use a Map because Scope names needn't be unique. this._itemsByElement = new WeakMap(); this._prevHierarchy = new Map(); this._currHierarchy = new Map(); this._parent = aParentNode; this._parent.classList.add("variables-view-container"); this._parent.classList.add("theme-body"); this._appendEmptyNotice(); this._onSearchboxInput = this._onSearchboxInput.bind(this); this._onSearchboxKeyPress = this._onSearchboxKeyPress.bind(this); this._onViewKeyPress = this._onViewKeyPress.bind(this); this._onViewKeyDown = this._onViewKeyDown.bind(this); // Create an internal scrollbox container. this._list = this.document.createElement("scrollbox"); this._list.setAttribute("orient", "vertical"); this._list.addEventListener("keypress", this._onViewKeyPress, false); this._list.addEventListener("keydown", this._onViewKeyDown, false); this._parent.appendChild(this._list); for (let name in aFlags) { this[name] = aFlags[name]; } EventEmitter.decorate(this); }; VariablesView.prototype = { /** * Helper setter for populating this container with a raw object. * * @param object aObject * The raw object to display. You can only provide this object * if you want the variables view to work in sync mode. */ set rawObject(aObject) { this.empty(); this.addScope() .addItem("", { enumerable: true }) .populate(aObject, { sorted: true }); }, /** * Adds a scope to contain any inspected variables. * * This new scope will be considered the parent of any other scope * added afterwards. * * @param string aName * The scope's name (e.g. "Local", "Global" etc.). * @return Scope * The newly created Scope instance. */ addScope: function(aName = "") { this._removeEmptyNotice(); this._toggleSearchVisibility(true); let scope = new Scope(this, aName); this._store.push(scope); this._itemsByElement.set(scope._target, scope); this._currHierarchy.set(aName, scope); scope.header = !!aName; return scope; }, /** * Removes all items from this container. * * @param number aTimeout [optional] * The number of milliseconds to delay the operation if * lazy emptying of this container is enabled. */ empty: function(aTimeout = this.lazyEmptyDelay) { // If there are no items in this container, emptying is useless. if (!this._store.length) { return; } this._store.length = 0; this._itemsByElement.clear(); this._prevHierarchy = this._currHierarchy; this._currHierarchy = new Map(); // Don't clear, this is just simple swapping. // Check if this empty operation may be executed lazily. if (this.lazyEmpty && aTimeout > 0) { this._emptySoon(aTimeout); return; } while (this._list.hasChildNodes()) { this._list.firstChild.remove(); } this._appendEmptyNotice(); this._toggleSearchVisibility(false); }, /** * Emptying this container and rebuilding it immediately afterwards would * result in a brief redraw flicker, because the previously expanded nodes * may get asynchronously re-expanded, after fetching the prototype and * properties from a server. * * To avoid such behaviour, a normal container list is rebuild, but not * immediately attached to the parent container. The old container list * is kept around for a short period of time, hopefully accounting for the * data fetching delay. In the meantime, any operations can be executed * normally. * * @see VariablesView.empty * @see VariablesView.commitHierarchy */ _emptySoon: function(aTimeout) { let prevList = this._list; let currList = this._list = this.document.createElement("scrollbox"); this.window.setTimeout(() => { prevList.removeEventListener("keypress", this._onViewKeyPress, false); prevList.removeEventListener("keydown", this._onViewKeyDown, false); currList.addEventListener("keypress", this._onViewKeyPress, false); currList.addEventListener("keydown", this._onViewKeyDown, false); currList.setAttribute("orient", "vertical"); this._parent.removeChild(prevList); this._parent.appendChild(currList); if (!this._store.length) { this._appendEmptyNotice(); this._toggleSearchVisibility(false); } }, aTimeout); }, /** * Optional DevTools toolbox containing this VariablesView. Used to * communicate with the inspector and highlighter. */ toolbox: null, /** * The controller for this VariablesView, if it has one. */ controller: null, /** * The amount of time (in milliseconds) it takes to empty this view lazily. */ lazyEmptyDelay: LAZY_EMPTY_DELAY, /** * Specifies if this view may be emptied lazily. * @see VariablesView.prototype.empty */ lazyEmpty: false, /** * Specifies if nodes in this view may be searched lazily. */ lazySearch: true, /** * The number of elements in this container to jump when Page Up or Page Down * keys are pressed. If falsy, then the page size will be based on the * container height. */ scrollPageSize: SCROLL_PAGE_SIZE_DEFAULT, /** * The maximum number of elements allowed in a scope, variable or property * that allows pagination when appending children. */ appendPageSize: APPEND_PAGE_SIZE_DEFAULT, /** * Function called each time a variable or property's value is changed via * user interaction. If null, then value changes are disabled. * * This property is applied recursively onto each scope in this view and * affects only the child nodes when they're created. */ eval: null, /** * Function called each time a variable or property's name is changed via * user interaction. If null, then name changes are disabled. * * This property is applied recursively onto each scope in this view and * affects only the child nodes when they're created. */ switch: null, /** * Function called each time a variable or property is deleted via * user interaction. If null, then deletions are disabled. * * This property is applied recursively onto each scope in this view and * affects only the child nodes when they're created. */ delete: null, /** * Function called each time a property is added via user interaction. If * null, then property additions are disabled. * * This property is applied recursively onto each scope in this view and * affects only the child nodes when they're created. */ new: null, /** * Specifies if after an eval or switch operation, the variable or property * which has been edited should be disabled. */ preventDisableOnChange: false, /** * Specifies if, whenever a variable or property descriptor is available, * configurable, enumerable, writable, frozen, sealed and extensible * attributes should not affect presentation. * * This flag is applied recursively onto each scope in this view and * affects only the child nodes when they're created. */ preventDescriptorModifiers: false, /** * The tooltip text shown on a variable or property's value if an |eval| * function is provided, in order to change the variable or property's value. * * This flag is applied recursively onto each scope in this view and * affects only the child nodes when they're created. */ editableValueTooltip: STR.GetStringFromName("variablesEditableValueTooltip"), /** * The tooltip text shown on a variable or property's name if a |switch| * function is provided, in order to change the variable or property's name. * * This flag is applied recursively onto each scope in this view and * affects only the child nodes when they're created. */ editableNameTooltip: STR.GetStringFromName("variablesEditableNameTooltip"), /** * The tooltip text shown on a variable or property's edit button if an * |eval| function is provided and a getter/setter descriptor is present, * in order to change the variable or property to a plain value. * * This flag is applied recursively onto each scope in this view and * affects only the child nodes when they're created. */ editButtonTooltip: STR.GetStringFromName("variablesEditButtonTooltip"), /** * The tooltip text shown on a variable or property's value if that value is * a DOMNode that can be highlighted and selected in the inspector. * * This flag is applied recursively onto each scope in this view and * affects only the child nodes when they're created. */ domNodeValueTooltip: STR.GetStringFromName("variablesDomNodeValueTooltip"), /** * The tooltip text shown on a variable or property's delete button if a * |delete| function is provided, in order to delete the variable or property. * * This flag is applied recursively onto each scope in this view and * affects only the child nodes when they're created. */ deleteButtonTooltip: STR.GetStringFromName("variablesCloseButtonTooltip"), /** * Specifies the context menu attribute set on variables and properties. * * This flag is applied recursively onto each scope in this view and * affects only the child nodes when they're created. */ contextMenuId: "", /** * The separator label between the variables or properties name and value. * * This flag is applied recursively onto each scope in this view and * affects only the child nodes when they're created. */ separatorStr: STR.GetStringFromName("variablesSeparatorLabel"), /** * Specifies if enumerable properties and variables should be displayed. * These variables and properties are visible by default. * @param boolean aFlag */ set enumVisible(aFlag) { this._enumVisible = aFlag; for (let scope of this._store) { scope._enumVisible = aFlag; } }, /** * Specifies if non-enumerable properties and variables should be displayed. * These variables and properties are visible by default. * @param boolean aFlag */ set nonEnumVisible(aFlag) { this._nonEnumVisible = aFlag; for (let scope of this._store) { scope._nonEnumVisible = aFlag; } }, /** * Specifies if only enumerable properties and variables should be displayed. * Both types of these variables and properties are visible by default. * @param boolean aFlag */ set onlyEnumVisible(aFlag) { if (aFlag) { this.enumVisible = true; this.nonEnumVisible = false; } else { this.enumVisible = true; this.nonEnumVisible = true; } }, /** * Sets if the variable and property searching is enabled. * @param boolean aFlag */ set searchEnabled(aFlag) aFlag ? this._enableSearch() : this._disableSearch(), /** * Gets if the variable and property searching is enabled. * @return boolean */ get searchEnabled() !!this._searchboxContainer, /** * Sets the text displayed for the searchbox in this container. * @param string aValue */ set searchPlaceholder(aValue) { if (this._searchboxNode) { this._searchboxNode.setAttribute("placeholder", aValue); } this._searchboxPlaceholder = aValue; }, /** * Gets the text displayed for the searchbox in this container. * @return string */ get searchPlaceholder() this._searchboxPlaceholder, /** * Enables variable and property searching in this view. * Use the "searchEnabled" setter to enable searching. */ _enableSearch: function() { // If searching was already enabled, no need to re-enable it again. if (this._searchboxContainer) { return; } let document = this.document; let ownerNode = this._parent.parentNode; let container = this._searchboxContainer = document.createElement("hbox"); container.className = "devtools-toolbar"; // Hide the variables searchbox container if there are no variables or // properties to display. container.hidden = !this._store.length; let searchbox = this._searchboxNode = document.createElement("textbox"); searchbox.className = "variables-view-searchinput devtools-searchinput"; searchbox.setAttribute("placeholder", this._searchboxPlaceholder); searchbox.setAttribute("type", "search"); searchbox.setAttribute("flex", "1"); searchbox.addEventListener("input", this._onSearchboxInput, false); searchbox.addEventListener("keypress", this._onSearchboxKeyPress, false); container.appendChild(searchbox); ownerNode.insertBefore(container, this._parent); }, /** * Disables variable and property searching in this view. * Use the "searchEnabled" setter to disable searching. */ _disableSearch: function() { // If searching was already disabled, no need to re-disable it again. if (!this._searchboxContainer) { return; } this._searchboxContainer.remove(); this._searchboxNode.removeEventListener("input", this._onSearchboxInput, false); this._searchboxNode.removeEventListener("keypress", this._onSearchboxKeyPress, false); this._searchboxContainer = null; this._searchboxNode = null; }, /** * Sets the variables searchbox container hidden or visible. * It's hidden by default. * * @param boolean aVisibleFlag * Specifies the intended visibility. */ _toggleSearchVisibility: function(aVisibleFlag) { // If searching was already disabled, there's no need to hide it. if (!this._searchboxContainer) { return; } this._searchboxContainer.hidden = !aVisibleFlag; }, /** * Listener handling the searchbox input event. */ _onSearchboxInput: function() { this.scheduleSearch(this._searchboxNode.value); }, /** * Listener handling the searchbox key press event. */ _onSearchboxKeyPress: function(e) { switch(e.keyCode) { case e.DOM_VK_RETURN: this._onSearchboxInput(); return; case e.DOM_VK_ESCAPE: this._searchboxNode.value = ""; this._onSearchboxInput(); return; } }, /** * Schedules searching for variables or properties matching the query. * * @param string aToken * The variable or property to search for. * @param number aWait * The amount of milliseconds to wait until draining. */ scheduleSearch: function(aToken, aWait) { // Check if this search operation may not be executed lazily. if (!this.lazySearch) { this._doSearch(aToken); return; } // The amount of time to wait for the requests to settle. let maxDelay = SEARCH_ACTION_MAX_DELAY; let delay = aWait === undefined ? maxDelay / aToken.length : aWait; // Allow requests to settle down first. setNamedTimeout("vview-search", delay, () => this._doSearch(aToken)); }, /** * Performs a case insensitive search for variables or properties matching * the query, and hides non-matched items. * * If aToken is falsy, then all the scopes are unhidden and expanded, * while the available variables and properties inside those scopes are * just unhidden. * * @param string aToken * The variable or property to search for. */ _doSearch: function(aToken) { for (let scope of this._store) { switch (aToken) { case "": case null: case undefined: scope.expand(); scope._performSearch(""); break; default: scope._performSearch(aToken.toLowerCase()); break; } } }, /** * Find the first item in the tree of visible items in this container that * matches the predicate. Searches in visual order (the order seen by the * user). Descends into each scope to check the scope and its children. * * @param function aPredicate * A function that returns true when a match is found. * @return Scope | Variable | Property * The first visible scope, variable or property, or null if nothing * is found. */ _findInVisibleItems: function(aPredicate) { for (let scope of this._store) { let result = scope._findInVisibleItems(aPredicate); if (result) { return result; } } return null; }, /** * Find the last item in the tree of visible items in this container that * matches the predicate. Searches in reverse visual order (opposite of the * order seen by the user). Descends into each scope to check the scope and * its children. * * @param function aPredicate * A function that returns true when a match is found. * @return Scope | Variable | Property * The last visible scope, variable or property, or null if nothing * is found. */ _findInVisibleItemsReverse: function(aPredicate) { for (let i = this._store.length - 1; i >= 0; i--) { let scope = this._store[i]; let result = scope._findInVisibleItemsReverse(aPredicate); if (result) { return result; } } return null; }, /** * Gets the scope at the specified index. * * @param number aIndex * The scope's index. * @return Scope * The scope if found, undefined if not. */ getScopeAtIndex: function(aIndex) { return this._store[aIndex]; }, /** * Recursively searches this container for the scope, variable or property * displayed by the specified node. * * @param nsIDOMNode aNode * The node to search for. * @return Scope | Variable | Property * The matched scope, variable or property, or null if nothing is found. */ getItemForNode: function(aNode) { return this._itemsByElement.get(aNode); }, /** * Gets the scope owning a Variable or Property. * * @param Variable | Property * The variable or property to retrieven the owner scope for. * @return Scope * The owner scope. */ getOwnerScopeForVariableOrProperty: function(aItem) { if (!aItem) { return null; } // If this is a Scope, return it. if (!(aItem instanceof Variable)) { return aItem; } // If this is a Variable or Property, find its owner scope. if (aItem instanceof Variable && aItem.ownerView) { return this.getOwnerScopeForVariableOrProperty(aItem.ownerView); } return null; }, /** * Gets the parent scopes for a specified Variable or Property. * The returned list will not include the owner scope. * * @param Variable | Property * The variable or property for which to find the parent scopes. * @return array * A list of parent Scopes. */ getParentScopesForVariableOrProperty: function(aItem) { let scope = this.getOwnerScopeForVariableOrProperty(aItem); return this._store.slice(0, Math.max(this._store.indexOf(scope), 0)); }, /** * Gets the currently focused scope, variable or property in this view. * * @return Scope | Variable | Property * The focused scope, variable or property, or null if nothing is found. */ getFocusedItem: function() { let focused = this.document.commandDispatcher.focusedElement; return this.getItemForNode(focused); }, /** * Focuses the first visible scope, variable, or property in this container. */ focusFirstVisibleItem: function() { let focusableItem = this._findInVisibleItems(item => item.focusable); if (focusableItem) { this._focusItem(focusableItem); } this._parent.scrollTop = 0; this._parent.scrollLeft = 0; }, /** * Focuses the last visible scope, variable, or property in this container. */ focusLastVisibleItem: function() { let focusableItem = this._findInVisibleItemsReverse(item => item.focusable); if (focusableItem) { this._focusItem(focusableItem); } this._parent.scrollTop = this._parent.scrollHeight; this._parent.scrollLeft = 0; }, /** * Focuses the next scope, variable or property in this view. */ focusNextItem: function() { this.focusItemAtDelta(+1); }, /** * Focuses the previous scope, variable or property in this view. */ focusPrevItem: function() { this.focusItemAtDelta(-1); }, /** * Focuses another scope, variable or property in this view, based on * the index distance from the currently focused item. * * @param number aDelta * A scalar specifying by how many items should the selection change. */ focusItemAtDelta: function(aDelta) { let direction = aDelta > 0 ? "advanceFocus" : "rewindFocus"; let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta)); while (distance--) { if (!this._focusChange(direction)) { break; // Out of bounds. } } }, /** * Focuses the next or previous scope, variable or property in this view. * * @param string aDirection * Either "advanceFocus" or "rewindFocus". * @return boolean * False if the focus went out of bounds and the first or last element * in this view was focused instead. */ _focusChange: function(aDirection) { let commandDispatcher = this.document.commandDispatcher; let prevFocusedElement = commandDispatcher.focusedElement; let currFocusedItem = null; do { commandDispatcher.suppressFocusScroll = true; commandDispatcher[aDirection](); // Make sure the newly focused item is a part of this view. // If the focus goes out of bounds, revert the previously focused item. if (!(currFocusedItem = this.getFocusedItem())) { prevFocusedElement.focus(); return false; } } while (!currFocusedItem.focusable); // Focus remained within bounds. return true; }, /** * Focuses a scope, variable or property and makes sure it's visible. * * @param aItem Scope | Variable | Property * The item to focus. * @param boolean aCollapseFlag * True if the focused item should also be collapsed. * @return boolean * True if the item was successfully focused. */ _focusItem: function(aItem, aCollapseFlag) { if (!aItem.focusable) { return false; } if (aCollapseFlag) { aItem.collapse(); } aItem._target.focus(); this.boxObject.ensureElementIsVisible(aItem._arrow); return true; }, /** * Listener handling a key press event on the view. */ _onViewKeyPress: function(e) { let item = this.getFocusedItem(); // Prevent scrolling when pressing navigation keys. ViewHelpers.preventScrolling(e); switch (e.keyCode) { case e.DOM_VK_UP: // Always rewind focus. this.focusPrevItem(true); return; case e.DOM_VK_DOWN: // Always advance focus. this.focusNextItem(true); return; case e.DOM_VK_LEFT: // Collapse scopes, variables and properties before rewinding focus. if (item._isExpanded && item._isArrowVisible) { item.collapse(); } else { this._focusItem(item.ownerView); } return; case e.DOM_VK_RIGHT: // Nothing to do here if this item never expands. if (!item._isArrowVisible) { return; } // Expand scopes, variables and properties before advancing focus. if (!item._isExpanded) { item.expand(); } else { this.focusNextItem(true); } return; case e.DOM_VK_PAGE_UP: // Rewind a certain number of elements based on the container height. this.focusItemAtDelta(-(this.scrollPageSize || Math.min(Math.floor(this._list.scrollHeight / PAGE_SIZE_SCROLL_HEIGHT_RATIO), PAGE_SIZE_MAX_JUMPS))); return; case e.DOM_VK_PAGE_DOWN: // Advance a certain number of elements based on the container height. this.focusItemAtDelta(+(this.scrollPageSize || Math.min(Math.floor(this._list.scrollHeight / PAGE_SIZE_SCROLL_HEIGHT_RATIO), PAGE_SIZE_MAX_JUMPS))); return; case e.DOM_VK_HOME: this.focusFirstVisibleItem(); return; case e.DOM_VK_END: this.focusLastVisibleItem(); return; case e.DOM_VK_RETURN: // Start editing the value or name of the Variable or Property. if (item instanceof Variable) { if (e.metaKey || e.altKey || e.shiftKey) { item._activateNameInput(); } else { item._activateValueInput(); } } return; case e.DOM_VK_DELETE: case e.DOM_VK_BACK_SPACE: // Delete the Variable or Property if allowed. if (item instanceof Variable) { item._onDelete(e); } return; case e.DOM_VK_INSERT: item._onAddProperty(e); return; } }, /** * Listener handling a key down event on the view. */ _onViewKeyDown: function(e) { if (e.keyCode == e.DOM_VK_C) { // Copy current selection to clipboard. if (e.ctrlKey || e.metaKey) { let item = this.getFocusedItem(); clipboardHelper.copyString( item._nameString + item.separatorStr + item._valueString ); } } }, /** * Sets the text displayed in this container when there are no available items. * @param string aValue */ set emptyText(aValue) { if (this._emptyTextNode) { this._emptyTextNode.setAttribute("value", aValue); } this._emptyTextValue = aValue; this._appendEmptyNotice(); }, /** * Creates and appends a label signaling that this container is empty. */ _appendEmptyNotice: function() { if (this._emptyTextNode || !this._emptyTextValue) { return; } let label = this.document.createElement("label"); label.className = "variables-view-empty-notice"; label.setAttribute("value", this._emptyTextValue); this._parent.appendChild(label); this._emptyTextNode = label; }, /** * Removes the label signaling that this container is empty. */ _removeEmptyNotice: function() { if (!this._emptyTextNode) { return; } this._parent.removeChild(this._emptyTextNode); this._emptyTextNode = null; }, /** * Gets if all values should be aligned together. * @return boolean */ get alignedValues() { return this._alignedValues; }, /** * Sets if all values should be aligned together. * @param boolean aFlag */ set alignedValues(aFlag) { this._alignedValues = aFlag; if (aFlag) { this._parent.setAttribute("aligned-values", ""); } else { this._parent.removeAttribute("aligned-values"); } }, /** * Gets if action buttons (like delete) should be placed at the beginning or * end of a line. * @return boolean */ get actionsFirst() { return this._actionsFirst; }, /** * Sets if action buttons (like delete) should be placed at the beginning or * end of a line. * @param boolean aFlag */ set actionsFirst(aFlag) { this._actionsFirst = aFlag; if (aFlag) { this._parent.setAttribute("actions-first", ""); } else { this._parent.removeAttribute("actions-first"); } }, /** * Gets the parent node holding this view. * @return nsIDOMNode */ get boxObject() this._list.boxObject.QueryInterface(Ci.nsIScrollBoxObject), /** * Gets the parent node holding this view. * @return nsIDOMNode */ get parentNode() this._parent, /** * Gets the owner document holding this view. * @return nsIHTMLDocument */ get document() this._document || (this._document = this._parent.ownerDocument), /** * Gets the default window holding this view. * @return nsIDOMWindow */ get window() this._window || (this._window = this.document.defaultView), _document: null, _window: null, _store: null, _itemsByElement: null, _prevHierarchy: null, _currHierarchy: null, _enumVisible: true, _nonEnumVisible: true, _alignedValues: false, _actionsFirst: false, _parent: null, _list: null, _searchboxNode: null, _searchboxContainer: null, _searchboxPlaceholder: "", _emptyTextNode: null, _emptyTextValue: "" }; VariablesView.NON_SORTABLE_CLASSES = [ "Array", "Int8Array", "Uint8Array", "Uint8ClampedArray", "Int16Array", "Uint16Array", "Int32Array", "Uint32Array", "Float32Array", "Float64Array", "NodeList" ]; /** * Determine whether an object's properties should be sorted based on its class. * * @param string aClassName * The class of the object. */ VariablesView.isSortable = function(aClassName) { return VariablesView.NON_SORTABLE_CLASSES.indexOf(aClassName) == -1; }; /** * Generates the string evaluated when performing simple value changes. * * @param Variable | Property aItem * The current variable or property. * @param string aCurrentString * The trimmed user inputted string. * @param string aPrefix [optional] * Prefix for the symbolic name. * @return string * The string to be evaluated. */ VariablesView.simpleValueEvalMacro = function(aItem, aCurrentString, aPrefix = "") { return aPrefix + aItem.symbolicName + "=" + aCurrentString; }; /** * Generates the string evaluated when overriding getters and setters with * plain values. * * @param Property aItem * The current getter or setter property. * @param string aCurrentString * The trimmed user inputted string. * @param string aPrefix [optional] * Prefix for the symbolic name. * @return string * The string to be evaluated. */ VariablesView.overrideValueEvalMacro = function(aItem, aCurrentString, aPrefix = "") { let property = "\"" + aItem._nameString + "\""; let parent = aPrefix + aItem.ownerView.symbolicName || "this"; return "Object.defineProperty(" + parent + "," + property + "," + "{ value: " + aCurrentString + ", enumerable: " + parent + ".propertyIsEnumerable(" + property + ")" + ", configurable: true" + ", writable: true" + "})"; }; /** * Generates the string evaluated when performing getters and setters changes. * * @param Property aItem * The current getter or setter property. * @param string aCurrentString * The trimmed user inputted string. * @param string aPrefix [optional] * Prefix for the symbolic name. * @return string * The string to be evaluated. */ VariablesView.getterOrSetterEvalMacro = function(aItem, aCurrentString, aPrefix = "") { let type = aItem._nameString; let propertyObject = aItem.ownerView; let parentObject = propertyObject.ownerView; let property = "\"" + propertyObject._nameString + "\""; let parent = aPrefix + parentObject.symbolicName || "this"; switch (aCurrentString) { case "": case "null": case "undefined": let mirrorType = type == "get" ? "set" : "get"; let mirrorLookup = type == "get" ? "__lookupSetter__" : "__lookupGetter__"; // If the parent object will end up without any getter or setter, // morph it into a plain value. if ((type == "set" && propertyObject.getter.type == "undefined") || (type == "get" && propertyObject.setter.type == "undefined")) { // Make sure the right getter/setter to value override macro is applied // to the target object. return propertyObject.evaluationMacro(propertyObject, "undefined", aPrefix); } // Construct and return the getter/setter removal evaluation string. // e.g: Object.defineProperty(foo, "bar", { // get: foo.__lookupGetter__("bar"), // set: undefined, // enumerable: true, // configurable: true // }) return "Object.defineProperty(" + parent + "," + property + "," + "{" + mirrorType + ":" + parent + "." + mirrorLookup + "(" + property + ")" + "," + type + ":" + undefined + ", enumerable: " + parent + ".propertyIsEnumerable(" + property + ")" + ", configurable: true" + "})"; default: // Wrap statements inside a function declaration if not already wrapped. if (!aCurrentString.startsWith("function")) { let header = "function(" + (type == "set" ? "value" : "") + ")"; let body = ""; // If there's a return statement explicitly written, always use the // standard function definition syntax if (aCurrentString.contains("return ")) { body = "{" + aCurrentString + "}"; } // If block syntax is used, use the whole string as the function body. else if (aCurrentString.startsWith("{")) { body = aCurrentString; } // Prefer an expression closure. else { body = "(" + aCurrentString + ")"; } aCurrentString = header + body; } // Determine if a new getter or setter should be defined. let defineType = type == "get" ? "__defineGetter__" : "__defineSetter__"; // Make sure all quotes are escaped in the expression's syntax, let defineFunc = "eval(\"(" + aCurrentString.replace(/"/g, "\\$&") + ")\")"; // Construct and return the getter/setter evaluation string. // e.g: foo.__defineGetter__("bar", eval("(function() { return 42; })")) return parent + "." + defineType + "(" + property + "," + defineFunc + ")"; } }; /** * Function invoked when a getter or setter is deleted. * * @param Property aItem * The current getter or setter property. */ VariablesView.getterOrSetterDeleteCallback = function(aItem) { aItem._disable(); // Make sure the right getter/setter to value override macro is applied // to the target object. aItem.ownerView.eval(aItem, ""); return true; // Don't hide the element. }; /** * A Scope is an object holding Variable instances. * Iterable via "for (let [name, variable] of instance) { }". * * @param VariablesView aView * The view to contain this scope. * @param string aName * The scope's name. * @param object aFlags [optional] * Additional options or flags for this scope. */ function Scope(aView, aName, aFlags = {}) { this.ownerView = aView; this._onClick = this._onClick.bind(this); this._openEnum = this._openEnum.bind(this); this._openNonEnum = this._openNonEnum.bind(this); // Inherit properties and flags from the parent view. You can override // each of these directly onto any scope, variable or property instance. this.scrollPageSize = aView.scrollPageSize; this.appendPageSize = aView.appendPageSize; this.eval = aView.eval; this.switch = aView.switch; this.delete = aView.delete; this.new = aView.new; this.preventDisableOnChange = aView.preventDisableOnChange; this.preventDescriptorModifiers = aView.preventDescriptorModifiers; this.editableNameTooltip = aView.editableNameTooltip; this.editableValueTooltip = aView.editableValueTooltip; this.editButtonTooltip = aView.editButtonTooltip; this.deleteButtonTooltip = aView.deleteButtonTooltip; this.domNodeValueTooltip = aView.domNodeValueTooltip; this.contextMenuId = aView.contextMenuId; this.separatorStr = aView.separatorStr; this._init(aName.trim(), aFlags); } Scope.prototype = { /** * Whether this Scope should be prefetched when it is remoted. */ shouldPrefetch: true, /** * Whether this Scope should paginate its contents. */ allowPaginate: false, /** * The class name applied to this scope's target element. */ targetClassName: "variables-view-scope", /** * Create a new Variable that is a child of this Scope. * * @param string aName * The name of the new Property. * @param object aDescriptor * The variable's descriptor. * @return Variable * The newly created child Variable. */ _createChild: function(aName, aDescriptor) { return new Variable(this, aName, aDescriptor); }, /** * Adds a child to contain any inspected properties. * * @param string aName * The child's name. * @param object aDescriptor * Specifies the value and/or type & class of the child, * or 'get' & 'set' accessor properties. If the type is implicit, * it will be inferred from the value. If this parameter is omitted, * a property without a value will be added (useful for branch nodes). * e.g. - { value: 42 } * - { value: true } * - { value: "nasu" } * - { value: { type: "undefined" } } * - { value: { type: "null" } } * - { value: { type: "object", class: "Object" } } * - { get: { type: "object", class: "Function" }, * set: { type: "undefined" } } * @param boolean aRelaxed [optional] * Pass true if name duplicates should be allowed. * You probably shouldn't do it. Use this with caution. * @return Variable * The newly created Variable instance, null if it already exists. */ addItem: function(aName = "", aDescriptor = {}, aRelaxed = false) { if (this._store.has(aName) && !aRelaxed) { return null; } let child = this._createChild(aName, aDescriptor); this._store.set(aName, child); this._variablesView._itemsByElement.set(child._target, child); this._variablesView._currHierarchy.set(child.absoluteName, child); child.header = !!aName; return child; }, /** * Adds items for this variable. * * @param object aItems * An object containing some { name: descriptor } data properties, * specifying the value and/or type & class of the variable, * or 'get' & 'set' accessor properties. If the type is implicit, * it will be inferred from the value. * e.g. - { someProp0: { value: 42 }, * someProp1: { value: true }, * someProp2: { value: "nasu" }, * someProp3: { value: { type: "undefined" } }, * someProp4: { value: { type: "null" } }, * someProp5: { value: { type: "object", class: "Object" } }, * someProp6: { get: { type: "object", class: "Function" }, * set: { type: "undefined" } } } * @param object aOptions [optional] * Additional options for adding the properties. Supported options: * - sorted: true to sort all the properties before adding them * - callback: function invoked after each item is added * @param string aKeysType [optional] * Helper argument in the case of paginated items. Can be either * "just-strings" or "just-numbers". Humans shouldn't use this argument. */ addItems: function(aItems, aOptions = {}, aKeysType = "") { let names = Object.keys(aItems); // Building the view when inspecting an object with a very large number of // properties may take a long time. To avoid blocking the UI, group // the items into several lazily populated pseudo-items. let exceedsThreshold = names.length >= this.appendPageSize; let shouldPaginate = exceedsThreshold && aKeysType != "just-strings"; if (shouldPaginate && this.allowPaginate) { // Group the items to append into two separate arrays, one containing // number-like keys, the other one containing string keys. if (aKeysType == "just-numbers") { var numberKeys = names; var stringKeys = []; } else { var numberKeys = []; var stringKeys = []; for (let name of names) { // Be very careful. Avoid Infinity, NaN and non Natural number keys. let coerced = +name; if (Number.isInteger(coerced) && coerced > -1) { numberKeys.push(name); } else { stringKeys.push(name); } } } // This object contains a very large number of properties, but they're // almost all strings that can't be coerced to numbers. Don't paginate. if (numberKeys.length < this.appendPageSize) { this.addItems(aItems, aOptions, "just-strings"); return; } // Slices a section of the { name: descriptor } data properties. let paginate = (aArray, aBegin = 0, aEnd = aArray.length) => { let store = {} for (let i = aBegin; i < aEnd; i++) { let name = aArray[i]; store[name] = aItems[name]; } return store; }; // Creates a pseudo-item that populates itself with the data properties // from the corresponding page range. let createRangeExpander = (aArray, aBegin, aEnd, aOptions, aKeyTypes) => { let rangeVar = this.addItem(aArray[aBegin] + Scope.ellipsis + aArray[aEnd - 1]); rangeVar.onexpand = () => { let pageItems = paginate(aArray, aBegin, aEnd); rangeVar.addItems(pageItems, aOptions, aKeyTypes); } rangeVar.showArrow(); rangeVar.target.setAttribute("pseudo-item", ""); }; // Divide the number keys into quarters. let page = +Math.round(numberKeys.length / 4).toPrecision(1); createRangeExpander(numberKeys, 0, page, aOptions, "just-numbers"); createRangeExpander(numberKeys, page, page * 2, aOptions, "just-numbers"); createRangeExpander(numberKeys, page * 2, page * 3, aOptions, "just-numbers"); createRangeExpander(numberKeys, page * 3, numberKeys.length, aOptions, "just-numbers"); // Append all the string keys together. this.addItems(paginate(stringKeys), aOptions, "just-strings"); return; } // Sort all of the properties before adding them, if preferred. if (aOptions.sorted && aKeysType != "just-numbers") { names.sort(); } // Add the properties to the current scope. for (let name of names) { let descriptor = aItems[name]; let item = this.addItem(name, descriptor); if (aOptions.callback) { aOptions.callback(item, descriptor.value); } } }, /** * Remove this Scope from its parent and remove all children recursively. */ remove: function() { let view = this._variablesView; view._store.splice(view._store.indexOf(this), 1); view._itemsByElement.delete(this._target); view._currHierarchy.delete(this._nameString); this._target.remove(); for (let variable of this._store.values()) { variable.remove(); } }, /** * Gets the variable in this container having the specified name. * * @param string aName * The name of the variable to get. * @return Variable * The matched variable, or null if nothing is found. */ get: function(aName) { return this._store.get(aName); }, /** * Recursively searches for the variable or property in this container * displayed by the specified node. * * @param nsIDOMNode aNode * The node to search for. * @return Variable | Property * The matched variable or property, or null if nothing is found. */ find: function(aNode) { for (let [, variable] of this._store) { let match; if (variable._target == aNode) { match = variable; } else { match = variable.find(aNode); } if (match) { return match; } } return null; }, /** * Determines if this scope is a direct child of a parent variables view, * scope, variable or property. * * @param VariablesView | Scope | Variable | Property * The parent to check. * @return boolean * True if the specified item is a direct child, false otherwise. */ isChildOf: function(aParent) { return this.ownerView == aParent; }, /** * Determines if this scope is a descendant of a parent variables view, * scope, variable or property. * * @param VariablesView | Scope | Variable | Property * The parent to check. * @return boolean * True if the specified item is a descendant, false otherwise. */ isDescendantOf: function(aParent) { if (this.isChildOf(aParent)) { return true; } // Recurse to parent if it is a Scope, Variable, or Property. if (this.ownerView instanceof Scope) { return this.ownerView.isDescendantOf(aParent); } return false; }, /** * Shows the scope. */ show: function() { this._target.hidden = false; this._isContentVisible = true; if (this.onshow) { this.onshow(this); } }, /** * Hides the scope. */ hide: function() { this._target.hidden = true; this._isContentVisible = false; if (this.onhide) { this.onhide(this); } }, /** * Expands the scope, showing all the added details. */ expand: function() { if (this._isExpanded || this._isLocked) { return; } if (this._variablesView._enumVisible) { this._openEnum(); } if (this._variablesView._nonEnumVisible) { Services.tm.currentThread.dispatch({ run: this._openNonEnum }, 0); } this._isExpanded = true; if (this.onexpand) { this.onexpand(this); } }, /** * Collapses the scope, hiding all the added details. */ collapse: function() { if (!this._isExpanded || this._isLocked) { return; } this._arrow.removeAttribute("open"); this._enum.removeAttribute("open"); this._nonenum.removeAttribute("open"); this._isExpanded = false; if (this.oncollapse) { this.oncollapse(this); } }, /** * Toggles between the scope's collapsed and expanded state. */ toggle: function(e) { if (e && e.button != 0) { // Only allow left-click to trigger this event. return; } this.expanded ^= 1; // Make sure the scope and its contents are visibile. for (let [, variable] of this._store) { variable.header = true; variable._matched = true; } if (this.ontoggle) { this.ontoggle(this); } }, /** * Shows the scope's title header. */ showHeader: function() { if (this._isHeaderVisible || !this._nameString) { return; } this._target.removeAttribute("untitled"); this._isHeaderVisible = true; }, /** * Hides the scope's title header. * This action will automatically expand the scope. */ hideHeader: function() { if (!this._isHeaderVisible) { return; } this.expand(); this._target.setAttribute("untitled", ""); this._isHeaderVisible = false; }, /** * Shows the scope's expand/collapse arrow. */ showArrow: function() { if (this._isArrowVisible) { return; } this._arrow.removeAttribute("invisible"); this._isArrowVisible = true; }, /** * Hides the scope's expand/collapse arrow. */ hideArrow: function() { if (!this._isArrowVisible) { return; } this._arrow.setAttribute("invisible", ""); this._isArrowVisible = false; }, /** * Gets the visibility state. * @return boolean */ get visible() this._isContentVisible, /** * Gets the expanded state. * @return boolean */ get expanded() this._isExpanded, /** * Gets the header visibility state. * @return boolean */ get header() this._isHeaderVisible, /** * Gets the twisty visibility state. * @return boolean */ get twisty() this._isArrowVisible, /** * Gets the expand lock state. * @return boolean */ get locked() this._isLocked, /** * Sets the visibility state. * @param boolean aFlag */ set visible(aFlag) aFlag ? this.show() : this.hide(), /** * Sets the expanded state. * @param boolean aFlag */ set expanded(aFlag) aFlag ? this.expand() : this.collapse(), /** * Sets the header visibility state. * @param boolean aFlag */ set header(aFlag) aFlag ? this.showHeader() : this.hideHeader(), /** * Sets the twisty visibility state. * @param boolean aFlag */ set twisty(aFlag) aFlag ? this.showArrow() : this.hideArrow(), /** * Sets the expand lock state. * @param boolean aFlag */ set locked(aFlag) this._isLocked = aFlag, /** * Specifies if this target node may be focused. * @return boolean */ get focusable() { // Check if this target node is actually visibile. if (!this._nameString || !this._isContentVisible || !this._isHeaderVisible || !this._isMatch) { return false; } // Check if all parent objects are expanded. let item = this; // Recurse while parent is a Scope, Variable, or Property while ((item = item.ownerView) && item instanceof Scope) { if (!item._isExpanded) { return false; } } return true; }, /** * Focus this scope. */ focus: function() { this._variablesView._focusItem(this); }, /** * Adds an event listener for a certain event on this scope's title. * @param string aName * @param function aCallback * @param boolean aCapture */ addEventListener: function(aName, aCallback, aCapture) { this._title.addEventListener(aName, aCallback, aCapture); }, /** * Removes an event listener for a certain event on this scope's title. * @param string aName * @param function aCallback * @param boolean aCapture */ removeEventListener: function(aName, aCallback, aCapture) { this._title.removeEventListener(aName, aCallback, aCapture); }, /** * Gets the id associated with this item. * @return string */ get id() this._idString, /** * Gets the name associated with this item. * @return string */ get name() this._nameString, /** * Gets the displayed value for this item. * @return string */ get displayValue() this._valueString, /** * Gets the class names used for the displayed value. * @return string */ get displayValueClassName() this._valueClassName, /** * Gets the element associated with this item. * @return nsIDOMNode */ get target() this._target, /** * Initializes this scope's id, view and binds event listeners. * * @param string aName * The scope's name. * @param object aFlags [optional] * Additional options or flags for this scope. */ _init: function(aName, aFlags) { this._idString = generateId(this._nameString = aName); this._displayScope(aName, this.targetClassName, "devtools-toolbar"); this._addEventListeners(); this.parentNode.appendChild(this._target); }, /** * Creates the necessary nodes for this scope. * * @param string aName * The scope's name. * @param string aTargetClassName * A custom class name for this scope's target element. * @param string aTitleClassName [optional] * A custom class name for this scope's title element. */ _displayScope: function(aName, aTargetClassName, aTitleClassName = "") { let document = this.document; let element = this._target = document.createElement("vbox"); element.id = this._idString; element.className = aTargetClassName; let arrow = this._arrow = document.createElement("hbox"); arrow.className = "arrow theme-twisty"; let name = this._name = document.createElement("label"); name.className = "plain name"; name.setAttribute("value", aName); let title = this._title = document.createElement("hbox"); title.className = "title " + aTitleClassName; title.setAttribute("align", "center"); let enumerable = this._enum = document.createElement("vbox"); let nonenum = this._nonenum = document.createElement("vbox"); enumerable.className = "variables-view-element-details enum"; nonenum.className = "variables-view-element-details nonenum"; title.appendChild(arrow); title.appendChild(name); element.appendChild(title); element.appendChild(enumerable); element.appendChild(nonenum); }, /** * Adds the necessary event listeners for this scope. */ _addEventListeners: function() { this._title.addEventListener("mousedown", this._onClick, false); }, /** * The click listener for this scope's title. */ _onClick: function(e) { if (this.editing || e.button != 0 || e.target == this._editNode || e.target == this._deleteNode || e.target == this._addPropertyNode) { return; } this.toggle(); this.focus(); }, /** * Opens the enumerable items container. */ _openEnum: function() { this._arrow.setAttribute("open", ""); this._enum.setAttribute("open", ""); }, /** * Opens the non-enumerable items container. */ _openNonEnum: function() { this._nonenum.setAttribute("open", ""); }, /** * Specifies if enumerable properties and variables should be displayed. * @param boolean aFlag */ set _enumVisible(aFlag) { for (let [, variable] of this._store) { variable._enumVisible = aFlag; if (!this._isExpanded) { continue; } if (aFlag) { this._enum.setAttribute("open", ""); } else { this._enum.removeAttribute("open"); } } }, /** * Specifies if non-enumerable properties and variables should be displayed. * @param boolean aFlag */ set _nonEnumVisible(aFlag) { for (let [, variable] of this._store) { variable._nonEnumVisible = aFlag; if (!this._isExpanded) { continue; } if (aFlag) { this._nonenum.setAttribute("open", ""); } else { this._nonenum.removeAttribute("open"); } } }, /** * Performs a case insensitive search for variables or properties matching * the query, and hides non-matched items. * * @param string aLowerCaseQuery * The lowercased name of the variable or property to search for. */ _performSearch: function(aLowerCaseQuery) { for (let [, variable] of this._store) { let currentObject = variable; let lowerCaseName = variable._nameString.toLowerCase(); let lowerCaseValue = variable._valueString.toLowerCase(); // Non-matched variables or properties require a corresponding attribute. if (!lowerCaseName.contains(aLowerCaseQuery) && !lowerCaseValue.contains(aLowerCaseQuery)) { variable._matched = false; } // Variable or property is matched. else { variable._matched = true; // If the variable was ever expanded, there's a possibility it may // contain some matched properties, so make sure they're visible // ("expand downwards"). if (variable._store.size) { variable.expand(); } // If the variable is contained in another Scope, Variable, or Property, // the parent may not be a match, thus hidden. It should be visible // ("expand upwards"). while ((variable = variable.ownerView) && variable instanceof Scope) { variable._matched = true; variable.expand(); } } // Proceed with the search recursively inside this variable or property. if (currentObject._store.size || currentObject.getter || currentObject.setter) { currentObject._performSearch(aLowerCaseQuery); } } }, /** * Sets if this object instance is a matched or non-matched item. * @param boolean aStatus */ set _matched(aStatus) { if (this._isMatch == aStatus) { return; } if (aStatus) { this._isMatch = true; this.target.removeAttribute("unmatched"); } else { this._isMatch = false; this.target.setAttribute("unmatched", ""); } }, /** * Find the first item in the tree of visible items in this item that matches * the predicate. Searches in visual order (the order seen by the user). * Tests itself, then descends into first the enumerable children and then * the non-enumerable children (since they are presented in separate groups). * * @param function aPredicate * A function that returns true when a match is found. * @return Scope | Variable | Property * The first visible scope, variable or property, or null if nothing * is found. */ _findInVisibleItems: function(aPredicate) { if (aPredicate(this)) { return this; } if (this._isExpanded) { if (this._variablesView._enumVisible) { for (let item of this._enumItems) { let result = item._findInVisibleItems(aPredicate); if (result) { return result; } } } if (this._variablesView._nonEnumVisible) { for (let item of this._nonEnumItems) { let result = item._findInVisibleItems(aPredicate); if (result) { return result; } } } } return null; }, /** * Find the last item in the tree of visible items in this item that matches * the predicate. Searches in reverse visual order (opposite of the order * seen by the user). Descends into first the non-enumerable children, then * the enumerable children (since they are presented in separate groups), and * finally tests itself. * * @param function aPredicate * A function that returns true when a match is found. * @return Scope | Variable | Property * The last visible scope, variable or property, or null if nothing * is found. */ _findInVisibleItemsReverse: function(aPredicate) { if (this._isExpanded) { if (this._variablesView._nonEnumVisible) { for (let i = this._nonEnumItems.length - 1; i >= 0; i--) { let item = this._nonEnumItems[i]; let result = item._findInVisibleItemsReverse(aPredicate); if (result) { return result; } } } if (this._variablesView._enumVisible) { for (let i = this._enumItems.length - 1; i >= 0; i--) { let item = this._enumItems[i]; let result = item._findInVisibleItemsReverse(aPredicate); if (result) { return result; } } } } if (aPredicate(this)) { return this; } return null; }, /** * Gets top level variables view instance. * @return VariablesView */ get _variablesView() this._topView || (this._topView = (function(self) { let parentView = self.ownerView; let topView; while (topView = parentView.ownerView) { parentView = topView; } return parentView; })(this)), /** * Gets the parent node holding this scope. * @return nsIDOMNode */ get parentNode() this.ownerView._list, /** * Gets the owner document holding this scope. * @return nsIHTMLDocument */ get document() this._document || (this._document = this.ownerView.document), /** * Gets the default window holding this scope. * @return nsIDOMWindow */ get window() this._window || (this._window = this.ownerView.window), _topView: null, _document: null, _window: null, ownerView: null, eval: null, switch: null, delete: null, new: null, preventDisableOnChange: false, preventDescriptorModifiers: false, editing: false, editableNameTooltip: "", editableValueTooltip: "", editButtonTooltip: "", deleteButtonTooltip: "", domNodeValueTooltip: "", contextMenuId: "", separatorStr: "", _store: null, _enumItems: null, _nonEnumItems: null, _fetched: false, _committed: false, _isLocked: false, _isExpanded: false, _isContentVisible: true, _isHeaderVisible: true, _isArrowVisible: true, _isMatch: true, _idString: "", _nameString: "", _target: null, _arrow: null, _name: null, _title: null, _enum: null, _nonenum: null, }; // Creating maps and arrays thousands of times for variables or properties // with a large number of children fills up a lot of memory. Make sure // these are instantiated only if needed. DevToolsUtils.defineLazyPrototypeGetter(Scope.prototype, "_store", Map); DevToolsUtils.defineLazyPrototypeGetter(Scope.prototype, "_enumItems", Array); DevToolsUtils.defineLazyPrototypeGetter(Scope.prototype, "_nonEnumItems", Array); // An ellipsis symbol (usually "…") used for localization. XPCOMUtils.defineLazyGetter(Scope, "ellipsis", () => Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data); /** * A Variable is a Scope holding Property instances. * Iterable via "for (let [name, property] of instance) { }". * * @param Scope aScope * The scope to contain this variable. * @param string aName * The variable's name. * @param object aDescriptor * The variable's descriptor. */ function Variable(aScope, aName, aDescriptor) { this._setTooltips = this._setTooltips.bind(this); this._activateNameInput = this._activateNameInput.bind(this); this._activateValueInput = this._activateValueInput.bind(this); this.openNodeInInspector = this.openNodeInInspector.bind(this); this.highlightDomNode = this.highlightDomNode.bind(this); this.unhighlightDomNode = this.unhighlightDomNode.bind(this); // Treat safe getter descriptors as descriptors with a value. if ("getterValue" in aDescriptor) { aDescriptor.value = aDescriptor.getterValue; delete aDescriptor.get; delete aDescriptor.set; } Scope.call(this, aScope, aName, this._initialDescriptor = aDescriptor); this.setGrip(aDescriptor.value); } Variable.prototype = Heritage.extend(Scope.prototype, { /** * Whether this Variable should be prefetched when it is remoted. */ get shouldPrefetch() { return this.name == "window" || this.name == "this"; }, /** * Whether this Variable should paginate its contents. */ get allowPaginate() { return this.name != "window" && this.name != "this"; }, /** * The class name applied to this variable's target element. */ targetClassName: "variables-view-variable variable-or-property", /** * Create a new Property that is a child of Variable. * * @param string aName * The name of the new Property. * @param object aDescriptor * The property's descriptor. * @return Property * The newly created child Property. */ _createChild: function(aName, aDescriptor) { return new Property(this, aName, aDescriptor); }, /** * Remove this Variable from its parent and remove all children recursively. */ remove: function() { if (this._linkedToInspector) { this.unhighlightDomNode(); this._valueLabel.removeEventListener("mouseover", this.highlightDomNode, false); this._valueLabel.removeEventListener("mouseout", this.unhighlightDomNode, false); this._openInspectorNode.removeEventListener("mousedown", this.openNodeInInspector, false); } this.ownerView._store.delete(this._nameString); this._variablesView._itemsByElement.delete(this._target); this._variablesView._currHierarchy.delete(this.absoluteName); this._target.remove(); for (let property of this._store.values()) { property.remove(); } }, /** * Populates this variable to contain all the properties of an object. * * @param object aObject * The raw object you want to display. * @param object aOptions [optional] * Additional options for adding the properties. Supported options: * - sorted: true to sort all the properties before adding them * - expanded: true to expand all the properties after adding them */ populate: function(aObject, aOptions = {}) { // Retrieve the properties only once. if (this._fetched) { return; } this._fetched = true; let propertyNames = Object.getOwnPropertyNames(aObject); let prototype = Object.getPrototypeOf(aObject); // Sort all of the properties before adding them, if preferred. if (aOptions.sorted) { propertyNames.sort(); } // Add all the variable properties. for (let name of propertyNames) { let descriptor = Object.getOwnPropertyDescriptor(aObject, name); if (descriptor.get || descriptor.set) { let prop = this._addRawNonValueProperty(name, descriptor); if (aOptions.expanded) { prop.expanded = true; } } else { let prop = this._addRawValueProperty(name, descriptor, aObject[name]); if (aOptions.expanded) { prop.expanded = true; } } } // Add the variable's __proto__. if (prototype) { this._addRawValueProperty("__proto__", {}, prototype); } }, /** * Populates a specific variable or property instance to contain all the * properties of an object * * @param Variable | Property aVar * The target variable to populate. * @param object aObject [optional] * The raw object you want to display. If unspecified, the object is * assumed to be defined in a _sourceValue property on the target. */ _populateTarget: function(aVar, aObject = aVar._sourceValue) { aVar.populate(aObject); }, /** * Adds a property for this variable based on a raw value descriptor. * * @param string aName * The property's name. * @param object aDescriptor * Specifies the exact property descriptor as returned by a call to * Object.getOwnPropertyDescriptor. * @param object aValue * The raw property value you want to display. * @return Property * The newly added property instance. */ _addRawValueProperty: function(aName, aDescriptor, aValue) { let descriptor = Object.create(aDescriptor); descriptor.value = VariablesView.getGrip(aValue); let propertyItem = this.addItem(aName, descriptor); propertyItem._sourceValue = aValue; // Add an 'onexpand' callback for the property, lazily handling // the addition of new child properties. if (!VariablesView.isPrimitive(descriptor)) { propertyItem.onexpand = this._populateTarget; } return propertyItem; }, /** * Adds a property for this variable based on a getter/setter descriptor. * * @param string aName * The property's name. * @param object aDescriptor * Specifies the exact property descriptor as returned by a call to * Object.getOwnPropertyDescriptor. * @return Property * The newly added property instance. */ _addRawNonValueProperty: function(aName, aDescriptor) { let descriptor = Object.create(aDescriptor); descriptor.get = VariablesView.getGrip(aDescriptor.get); descriptor.set = VariablesView.getGrip(aDescriptor.set); return this.addItem(aName, descriptor); }, /** * Gets this variable's path to the topmost scope in the form of a string * meant for use via eval() or a similar approach. * For example, a symbolic name may look like "arguments['0']['foo']['bar']". * @return string */ get symbolicName() { return this._nameString; }, /** * Gets full path to this variable, including name of the scope. * @return string */ get absoluteName() { if (this._absoluteName) { return this._absoluteName; } this._absoluteName = this.ownerView._nameString + "[\"" + this._nameString + "\"]"; return this._absoluteName; }, /** * Gets this variable's symbolic path to the topmost scope. * @return array * @see Variable._buildSymbolicPath */ get symbolicPath() { if (this._symbolicPath) { return this._symbolicPath; } this._symbolicPath = this._buildSymbolicPath(); return this._symbolicPath; }, /** * Build this variable's path to the topmost scope in form of an array of * strings, one for each segment of the path. * For example, a symbolic path may look like ["0", "foo", "bar"]. * @return array */ _buildSymbolicPath: function(path = []) { if (this.name) { path.unshift(this.name); if (this.ownerView instanceof Variable) { return this.ownerView._buildSymbolicPath(path); } } return path; }, /** * Returns this variable's value from the descriptor if available. * @return any */ get value() this._initialDescriptor.value, /** * Returns this variable's getter from the descriptor if available. * @return object */ get getter() this._initialDescriptor.get, /** * Returns this variable's getter from the descriptor if available. * @return object */ get setter() this._initialDescriptor.set, /** * Sets the specific grip for this variable (applies the text content and * class name to the value label). * * The grip should contain the value or the type & class, as defined in the * remote debugger protocol. For convenience, undefined and null are * both considered types. * * @param any aGrip * Specifies the value and/or type & class of the variable. * e.g. - 42 * - true * - "nasu" * - { type: "undefined" } * - { type: "null" } * - { type: "object", class: "Object" } */ setGrip: function(aGrip) { // Don't allow displaying grip information if there's no name available // or the grip is malformed. if (!this._nameString || aGrip === undefined || aGrip === null) { return; } // Getters and setters should display grip information in sub-properties. if (this.getter || this.setter) { return; } let prevGrip = this._valueGrip; if (prevGrip) { this._valueLabel.classList.remove(VariablesView.getClass(prevGrip)); } this._valueGrip = aGrip; this._valueString = VariablesView.getString(aGrip, { concise: true, noEllipsis: true, }); this._valueClassName = VariablesView.getClass(aGrip); this._valueLabel.classList.add(this._valueClassName); this._valueLabel.setAttribute("value", this._valueString); this._separatorLabel.hidden = false; // DOMNodes get special treatment since they can be linked to the inspector if (this._valueGrip.preview && this._valueGrip.preview.kind === "DOMNode") { this._linkToInspector(); } }, /** * Marks this variable as overridden. * * @param boolean aFlag * Whether this variable is overridden or not. */ setOverridden: function(aFlag) { if (aFlag) { this._target.setAttribute("overridden", ""); } else { this._target.removeAttribute("overridden"); } }, /** * Briefly flashes this variable. * * @param number aDuration [optional] * An optional flash animation duration. */ flash: function(aDuration = ITEM_FLASH_DURATION) { let fadeInDelay = this._variablesView.lazyEmptyDelay + 1; let fadeOutDelay = fadeInDelay + aDuration; setNamedTimeout("vview-flash-in" + this.absoluteName, fadeInDelay, () => this._target.setAttribute("changed", "")); setNamedTimeout("vview-flash-out" + this.absoluteName, fadeOutDelay, () => this._target.removeAttribute("changed")); }, /** * Initializes this variable's id, view and binds event listeners. * * @param string aName * The variable's name. * @param object aDescriptor * The variable's descriptor. */ _init: function(aName, aDescriptor) { this._idString = generateId(this._nameString = aName); this._displayScope(aName, this.targetClassName); this._displayVariable(); this._customizeVariable(); this._prepareTooltips(); this._setAttributes(); this._addEventListeners(); if (this._initialDescriptor.enumerable || this._nameString == "this" || this._nameString == "" || this._nameString == "") { this.ownerView._enum.appendChild(this._target); this.ownerView._enumItems.push(this); } else { this.ownerView._nonenum.appendChild(this._target); this.ownerView._nonEnumItems.push(this); } }, /** * Creates the necessary nodes for this variable. */ _displayVariable: function() { let document = this.document; let descriptor = this._initialDescriptor; let separatorLabel = this._separatorLabel = document.createElement("label"); separatorLabel.className = "plain separator"; separatorLabel.setAttribute("value", this.separatorStr + " "); let valueLabel = this._valueLabel = document.createElement("label"); valueLabel.className = "plain value"; valueLabel.setAttribute("flex", "1"); valueLabel.setAttribute("crop", "center"); this._title.appendChild(separatorLabel); this._title.appendChild(valueLabel); if (VariablesView.isPrimitive(descriptor)) { this.hideArrow(); } // If no value will be displayed, we don't need the separator. if (!descriptor.get && !descriptor.set && !("value" in descriptor)) { separatorLabel.hidden = true; } // If this is a getter/setter property, create two child pseudo-properties // called "get" and "set" that display the corresponding functions. if (descriptor.get || descriptor.set) { separatorLabel.hidden = true; valueLabel.hidden = true; // Changing getter/setter names is never allowed. this.switch = null; // Getter/setter properties require special handling when it comes to // evaluation and deletion. if (this.ownerView.eval) { this.delete = VariablesView.getterOrSetterDeleteCallback; this.evaluationMacro = VariablesView.overrideValueEvalMacro; } // Deleting getters and setters individually is not allowed if no // evaluation method is provided. else { this.delete = null; this.evaluationMacro = null; } let getter = this.addItem("get", { value: descriptor.get }); let setter = this.addItem("set", { value: descriptor.set }); getter.evaluationMacro = VariablesView.getterOrSetterEvalMacro; setter.evaluationMacro = VariablesView.getterOrSetterEvalMacro; getter.hideArrow(); setter.hideArrow(); this.expand(); } }, /** * Adds specific nodes for this variable based on custom flags. */ _customizeVariable: function() { let ownerView = this.ownerView; let descriptor = this._initialDescriptor; if (ownerView.eval && this.getter || this.setter) { let editNode = this._editNode = this.document.createElement("toolbarbutton"); editNode.className = "plain variables-view-edit"; editNode.addEventListener("mousedown", this._onEdit.bind(this), false); this._title.insertBefore(editNode, this._spacer); } if (ownerView.delete) { let deleteNode = this._deleteNode = this.document.createElement("toolbarbutton"); deleteNode.className = "plain variables-view-delete"; deleteNode.addEventListener("click", this._onDelete.bind(this), false); this._title.appendChild(deleteNode); } if (ownerView.new) { let addPropertyNode = this._addPropertyNode = this.document.createElement("toolbarbutton"); addPropertyNode.className = "plain variables-view-add-property"; addPropertyNode.addEventListener("mousedown", this._onAddProperty.bind(this), false); this._title.appendChild(addPropertyNode); // Can't add properties to primitive values, hide the node in those cases. if (VariablesView.isPrimitive(descriptor)) { addPropertyNode.setAttribute("invisible", ""); } } if (ownerView.contextMenuId) { this._title.setAttribute("context", ownerView.contextMenuId); } if (ownerView.preventDescriptorModifiers) { return; } if (!descriptor.writable && !ownerView.getter && !ownerView.setter) { let nonWritableIcon = this.document.createElement("hbox"); nonWritableIcon.className = "plain variable-or-property-non-writable-icon"; nonWritableIcon.setAttribute("optional-visibility", ""); this._title.appendChild(nonWritableIcon); } if (descriptor.value && typeof descriptor.value == "object") { if (descriptor.value.frozen) { let frozenLabel = this.document.createElement("label"); frozenLabel.className = "plain variable-or-property-frozen-label"; frozenLabel.setAttribute("optional-visibility", ""); frozenLabel.setAttribute("value", "F"); this._title.appendChild(frozenLabel); } if (descriptor.value.sealed) { let sealedLabel = this.document.createElement("label"); sealedLabel.className = "plain variable-or-property-sealed-label"; sealedLabel.setAttribute("optional-visibility", ""); sealedLabel.setAttribute("value", "S"); this._title.appendChild(sealedLabel); } if (!descriptor.value.extensible) { let nonExtensibleLabel = this.document.createElement("label"); nonExtensibleLabel.className = "plain variable-or-property-non-extensible-label"; nonExtensibleLabel.setAttribute("optional-visibility", ""); nonExtensibleLabel.setAttribute("value", "N"); this._title.appendChild(nonExtensibleLabel); } } }, /** * Prepares all tooltips for this variable. */ _prepareTooltips: function() { this._target.addEventListener("mouseover", this._setTooltips, false); }, /** * Sets all tooltips for this variable. */ _setTooltips: function() { this._target.removeEventListener("mouseover", this._setTooltips, false); let ownerView = this.ownerView; if (ownerView.preventDescriptorModifiers) { return; } let tooltip = this.document.createElement("tooltip"); tooltip.id = "tooltip-" + this._idString; tooltip.setAttribute("orient", "horizontal"); let labels = [ "configurable", "enumerable", "writable", "frozen", "sealed", "extensible", "overridden", "WebIDL"]; for (let type of labels) { let labelElement = this.document.createElement("label"); labelElement.className = type; labelElement.setAttribute("value", STR.GetStringFromName(type + "Tooltip")); tooltip.appendChild(labelElement); } this._target.appendChild(tooltip); this._target.setAttribute("tooltip", tooltip.id); if (this._editNode && ownerView.eval) { this._editNode.setAttribute("tooltiptext", ownerView.editButtonTooltip); } if (this._openInspectorNode && this._linkedToInspector) { this._openInspectorNode.setAttribute("tooltiptext", this.ownerView.domNodeValueTooltip); } if (this._valueLabel && ownerView.eval) { this._valueLabel.setAttribute("tooltiptext", ownerView.editableValueTooltip); } if (this._name && ownerView.switch) { this._name.setAttribute("tooltiptext", ownerView.editableNameTooltip); } if (this._deleteNode && ownerView.delete) { this._deleteNode.setAttribute("tooltiptext", ownerView.deleteButtonTooltip); } }, /** * Get the parent variablesview toolbox, if any. */ get toolbox() { return this._variablesView.toolbox; }, /** * Checks if this variable is a DOMNode and is part of a variablesview that * has been linked to the toolbox, so that highlighting and jumping to the * inspector can be done. */ _isLinkableToInspector: function() { let isDomNode = this._valueGrip && this._valueGrip.preview.kind === "DOMNode"; let hasBeenLinked = this._linkedToInspector; let hasToolbox = !!this.toolbox; return isDomNode && !hasBeenLinked && hasToolbox; }, /** * If the variable is a DOMNode, and if a toolbox is set, then link it to the * inspector (highlight on hover, and jump to markup-view on click) */ _linkToInspector: function() { if (!this._isLinkableToInspector()) { return; } // Listen to value mouseover/click events to highlight and jump this._valueLabel.addEventListener("mouseover", this.highlightDomNode, false); this._valueLabel.addEventListener("mouseout", this.unhighlightDomNode, false); // Add a button to open the node in the inspector this._openInspectorNode = this.document.createElement("toolbarbutton"); this._openInspectorNode.className = "plain variables-view-open-inspector"; this._openInspectorNode.addEventListener("mousedown", this.openNodeInInspector, false); this._title.appendChild(this._openInspectorNode); this._linkedToInspector = true; }, /** * In case this variable is a DOMNode and part of a variablesview that has been * linked to the toolbox's inspector, then select the corresponding node in * the inspector, and switch the inspector tool in the toolbox * @return a promise that resolves when the node is selected and the inspector * has been switched to and is ready */ openNodeInInspector: function(event) { if (!this.toolbox) { return promise.reject(new Error("Toolbox not available")); } event && event.stopPropagation(); return Task.spawn(function*() { yield this.toolbox.initInspector(); let nodeFront = this._nodeFront; if (!nodeFront) { nodeFront = yield this.toolbox.walker.getNodeActorFromObjectActor(this._valueGrip.actor); } if (nodeFront) { yield this.toolbox.selectTool("inspector"); let inspectorReady = promise.defer(); this.toolbox.getPanel("inspector").once("inspector-updated", inspectorReady.resolve); yield this.toolbox.selection.setNodeFront(nodeFront, "variables-view"); yield inspectorReady.promise; } }.bind(this)); }, /** * In case this variable is a DOMNode and part of a variablesview that has been * linked to the toolbox's inspector, then highlight the corresponding node */ highlightDomNode: function() { if (this.toolbox) { if (this._nodeFront) { // If the nodeFront has been retrieved before, no need to ask the server // again for it this.toolbox.highlighterUtils.highlightNodeFront(this._nodeFront); return; } this.toolbox.highlighterUtils.highlightDomValueGrip(this._valueGrip).then(front => { this._nodeFront = front; }); } }, /** * Unhighlight a previously highlit node * @see highlightDomNode */ unhighlightDomNode: function() { if (this.toolbox) { this.toolbox.highlighterUtils.unhighlight(); } }, /** * Sets a variable's configurable, enumerable and writable attributes, * and specifies if it's a 'this', '', '' or '__proto__' * reference. */ _setAttributes: function() { let ownerView = this.ownerView; if (ownerView.preventDescriptorModifiers) { return; } let descriptor = this._initialDescriptor; let target = this._target; let name = this._nameString; if (ownerView.eval) { target.setAttribute("editable", ""); } if (!descriptor.configurable) { target.setAttribute("non-configurable", ""); } if (!descriptor.enumerable) { target.setAttribute("non-enumerable", ""); } if (!descriptor.writable && !ownerView.getter && !ownerView.setter) { target.setAttribute("non-writable", ""); } if (descriptor.value && typeof descriptor.value == "object") { if (descriptor.value.frozen) { target.setAttribute("frozen", ""); } if (descriptor.value.sealed) { target.setAttribute("sealed", ""); } if (!descriptor.value.extensible) { target.setAttribute("non-extensible", ""); } } if (descriptor && "getterValue" in descriptor) { target.setAttribute("safe-getter", ""); } if (name == "this") { target.setAttribute("self", ""); } else if (name == "") { target.setAttribute("exception", ""); target.setAttribute("pseudo-item", ""); } else if (name == "") { target.setAttribute("return", ""); target.setAttribute("pseudo-item", ""); } else if (name == "__proto__") { target.setAttribute("proto", ""); target.setAttribute("pseudo-item", ""); } if (Object.keys(descriptor).length == 0) { target.setAttribute("pseudo-item", ""); } }, /** * Adds the necessary event listeners for this variable. */ _addEventListeners: function() { this._name.addEventListener("dblclick", this._activateNameInput, false); this._valueLabel.addEventListener("mousedown", this._activateValueInput, false); this._title.addEventListener("mousedown", this._onClick, false); }, /** * Makes this variable's name editable. */ _activateNameInput: function(e) { if (!this._variablesView.alignedValues) { this._separatorLabel.hidden = true; this._valueLabel.hidden = true; } EditableName.create(this, { onSave: aKey => { if (!this._variablesView.preventDisableOnChange) { this._disable(); } this.ownerView.switch(this, aKey); }, onCleanup: () => { if (!this._variablesView.alignedValues) { this._separatorLabel.hidden = false; this._valueLabel.hidden = false; } } }, e); }, /** * Makes this variable's value editable. */ _activateValueInput: function(e) { EditableValue.create(this, { onSave: aString => { if (this._linkedToInspector) { this.unhighlightDomNode(); } if (!this._variablesView.preventDisableOnChange) { this._disable(); } this.ownerView.eval(this, aString); } }, e); }, /** * Disables this variable prior to a new name switch or value evaluation. */ _disable: function() { // Prevent the variable from being collapsed or expanded. this.hideArrow(); // Hide any nodes that may offer information about the variable. for (let node of this._title.childNodes) { node.hidden = node != this._arrow && node != this._name; } this._enum.hidden = true; this._nonenum.hidden = true; }, /** * The current macro used to generate the string evaluated when performing * a variable or property value change. */ evaluationMacro: VariablesView.simpleValueEvalMacro, /** * The click listener for the edit button. */ _onEdit: function(e) { if (e.button != 0) { return; } e.preventDefault(); e.stopPropagation(); this._activateValueInput(); }, /** * The click listener for the delete button. */ _onDelete: function(e) { if ("button" in e && e.button != 0) { return; } e.preventDefault(); e.stopPropagation(); if (this.ownerView.delete) { if (!this.ownerView.delete(this)) { this.hide(); } } }, /** * The click listener for the add property button. */ _onAddProperty: function(e) { if ("button" in e && e.button != 0) { return; } e.preventDefault(); e.stopPropagation(); this.expanded = true; let item = this.addItem(" ", { value: undefined, configurable: true, enumerable: true, writable: true }, true); // Force showing the separator. item._separatorLabel.hidden = false; EditableNameAndValue.create(item, { onSave: ([aKey, aValue]) => { if (!this._variablesView.preventDisableOnChange) { this._disable(); } this.ownerView.new(this, aKey, aValue); } }, e); }, _symbolicName: null, _symbolicPath: null, _absoluteName: null, _initialDescriptor: null, _separatorLabel: null, _valueLabel: null, _spacer: null, _editNode: null, _deleteNode: null, _addPropertyNode: null, _tooltip: null, _valueGrip: null, _valueString: "", _valueClassName: "", _prevExpandable: false, _prevExpanded: false }); /** * A Property is a Variable holding additional child Property instances. * Iterable via "for (let [name, property] of instance) { }". * * @param Variable aVar * The variable to contain this property. * @param string aName * The property's name. * @param object aDescriptor * The property's descriptor. */ function Property(aVar, aName, aDescriptor) { Variable.call(this, aVar, aName, aDescriptor); } Property.prototype = Heritage.extend(Variable.prototype, { /** * The class name applied to this property's target element. */ targetClassName: "variables-view-property variable-or-property", /** * @see Variable.symbolicName * @return string */ get symbolicName() { if (this._symbolicName) { return this._symbolicName; } this._symbolicName = this.ownerView.symbolicName + "[\"" + this._nameString + "\"]"; return this._symbolicName; }, /** * @see Variable.absoluteName * @return string */ get absoluteName() { if (this._absoluteName) { return this._absoluteName; } this._absoluteName = this.ownerView.absoluteName + "[\"" + this._nameString + "\"]"; return this._absoluteName; } }); /** * A generator-iterator over the VariablesView, Scopes, Variables and Properties. */ VariablesView.prototype["@@iterator"] = Scope.prototype["@@iterator"] = Variable.prototype["@@iterator"] = Property.prototype["@@iterator"] = function*() { yield* this._store; }; /** * Forget everything recorded about added scopes, variables or properties. * @see VariablesView.commitHierarchy */ VariablesView.prototype.clearHierarchy = function() { this._prevHierarchy.clear(); this._currHierarchy.clear(); }; /** * Perform operations on all the VariablesView Scopes, Variables and Properties * after you've added all the items you wanted. * * Calling this method is optional, and does the following: * - styles the items overridden by other items in parent scopes * - reopens the items which were previously expanded * - flashes the items whose values changed */ VariablesView.prototype.commitHierarchy = function() { for (let [, currItem] of this._currHierarchy) { // Avoid performing expensive operations. if (this.commitHierarchyIgnoredItems[currItem._nameString]) { continue; } let overridden = this.isOverridden(currItem); if (overridden) { currItem.setOverridden(true); } let expanded = !currItem._committed && this.wasExpanded(currItem); if (expanded) { currItem.expand(); } let changed = !currItem._committed && this.hasChanged(currItem); if (changed) { currItem.flash(); } currItem._committed = true; } if (this.oncommit) { this.oncommit(this); } }; // Some variables are likely to contain a very large number of properties. // It would be a bad idea to re-expand them or perform expensive operations. VariablesView.prototype.commitHierarchyIgnoredItems = Heritage.extend(null, { "window": true, "this": true }); /** * Checks if the an item was previously expanded, if it existed in a * previous hierarchy. * * @param Scope | Variable | Property aItem * The item to verify. * @return boolean * Whether the item was expanded. */ VariablesView.prototype.wasExpanded = function(aItem) { if (!(aItem instanceof Scope)) { return false; } let prevItem = this._prevHierarchy.get(aItem.absoluteName || aItem._nameString); return prevItem ? prevItem._isExpanded : false; }; /** * Checks if the an item's displayed value (a representation of the grip) * has changed, if it existed in a previous hierarchy. * * @param Variable | Property aItem * The item to verify. * @return boolean * Whether the item has changed. */ VariablesView.prototype.hasChanged = function(aItem) { // Only analyze Variables and Properties for displayed value changes. // Scopes are just collections of Variables and Properties and // don't have a "value", so they can't change. if (!(aItem instanceof Variable)) { return false; } let prevItem = this._prevHierarchy.get(aItem.absoluteName); return prevItem ? prevItem._valueString != aItem._valueString : false; }; /** * Checks if the an item was previously expanded, if it existed in a * previous hierarchy. * * @param Scope | Variable | Property aItem * The item to verify. * @return boolean * Whether the item was expanded. */ VariablesView.prototype.isOverridden = function(aItem) { // Only analyze Variables for being overridden in different Scopes. if (!(aItem instanceof Variable) || aItem instanceof Property) { return false; } let currVariableName = aItem._nameString; let parentScopes = this.getParentScopesForVariableOrProperty(aItem); for (let otherScope of parentScopes) { for (let [otherVariableName] of otherScope) { if (otherVariableName == currVariableName) { return true; } } } return false; }; /** * Returns true if the descriptor represents an undefined, null or * primitive value. * * @param object aDescriptor * The variable's descriptor. */ VariablesView.isPrimitive = function(aDescriptor) { // For accessor property descriptors, the getter and setter need to be // contained in 'get' and 'set' properties. let getter = aDescriptor.get; let setter = aDescriptor.set; if (getter || setter) { return false; } // As described in the remote debugger protocol, the value grip // must be contained in a 'value' property. let grip = aDescriptor.value; if (typeof grip != "object") { return true; } // For convenience, undefined, null, Infinity, -Infinity, NaN, -0, and long // strings are considered types. let type = grip.type; if (type == "undefined" || type == "null" || type == "Infinity" || type == "-Infinity" || type == "NaN" || type == "-0" || type == "symbol" || type == "longString") { return true; } return false; }; /** * Returns true if the descriptor represents an undefined value. * * @param object aDescriptor * The variable's descriptor. */ VariablesView.isUndefined = function(aDescriptor) { // For accessor property descriptors, the getter and setter need to be // contained in 'get' and 'set' properties. let getter = aDescriptor.get; let setter = aDescriptor.set; if (typeof getter == "object" && getter.type == "undefined" && typeof setter == "object" && setter.type == "undefined") { return true; } // As described in the remote debugger protocol, the value grip // must be contained in a 'value' property. let grip = aDescriptor.value; if (typeof grip == "object" && grip.type == "undefined") { return true; } return false; }; /** * Returns true if the descriptor represents a falsy value. * * @param object aDescriptor * The variable's descriptor. */ VariablesView.isFalsy = function(aDescriptor) { // As described in the remote debugger protocol, the value grip // must be contained in a 'value' property. let grip = aDescriptor.value; if (typeof grip != "object") { return !grip; } // For convenience, undefined, null, NaN, and -0 are all considered types. let type = grip.type; if (type == "undefined" || type == "null" || type == "NaN" || type == "-0") { return true; } return false; }; /** * Returns true if the value is an instance of Variable or Property. * * @param any aValue * The value to test. */ VariablesView.isVariable = function(aValue) { return aValue instanceof Variable; }; /** * Returns a standard grip for a value. * * @param any aValue * The raw value to get a grip for. * @return any * The value's grip. */ VariablesView.getGrip = function(aValue) { switch (typeof aValue) { case "boolean": case "string": return aValue; case "number": if (aValue === Infinity) { return { type: "Infinity" }; } else if (aValue === -Infinity) { return { type: "-Infinity" }; } else if (Number.isNaN(aValue)) { return { type: "NaN" }; } else if (1 / aValue === -Infinity) { return { type: "-0" }; } return aValue; case "undefined": // document.all is also "undefined" if (aValue === undefined) { return { type: "undefined" }; } case "object": if (aValue === null) { return { type: "null" }; } case "function": return { type: "object", class: WebConsoleUtils.getObjectClassName(aValue) }; default: Cu.reportError("Failed to provide a grip for value of " + typeof value + ": " + aValue); return null; } }; /** * Returns a custom formatted property string for a grip. * * @param any aGrip * @see Variable.setGrip * @param object aOptions * Options: * - concise: boolean that tells you want a concisely formatted string. * - noStringQuotes: boolean that tells to not quote strings. * - noEllipsis: boolean that tells to not add an ellipsis after the * initial text of a longString. * @return string * The formatted property string. */ VariablesView.getString = function(aGrip, aOptions = {}) { if (aGrip && typeof aGrip == "object") { switch (aGrip.type) { case "undefined": case "null": case "NaN": case "Infinity": case "-Infinity": case "-0": return aGrip.type; default: let stringifier = VariablesView.stringifiers.byType[aGrip.type]; if (stringifier) { let result = stringifier(aGrip, aOptions); if (result != null) { return result; } } if (aGrip.displayString) { return VariablesView.getString(aGrip.displayString, aOptions); } if (aGrip.type == "object" && aOptions.concise) { return aGrip.class; } return "[" + aGrip.type + " " + aGrip.class + "]"; } } switch (typeof aGrip) { case "string": return VariablesView.stringifiers.byType.string(aGrip, aOptions); case "boolean": return aGrip ? "true" : "false"; case "number": if (!aGrip && 1 / aGrip === -Infinity) { return "-0"; } default: return aGrip + ""; } }; /** * The VariablesView stringifiers are used by VariablesView.getString(). These * are organized by object type, object class and by object actor preview kind. * Some objects share identical ways for previews, for example Arrays, Sets and * NodeLists. * * Any stringifier function must return a string. If null is returned, * then * the default stringifier will be used. When invoked, the stringifier is * given the same two arguments as those given to VariablesView.getString(). */ VariablesView.stringifiers = {}; VariablesView.stringifiers.byType = { string: function(aGrip, {noStringQuotes}) { if (noStringQuotes) { return aGrip; } return '"' + aGrip + '"'; }, longString: function({initial}, {noStringQuotes, noEllipsis}) { let ellipsis = noEllipsis ? "" : Scope.ellipsis; if (noStringQuotes) { return initial + ellipsis; } let result = '"' + initial + '"'; if (!ellipsis) { return result; } return result.substr(0, result.length - 1) + ellipsis + '"'; }, object: function(aGrip, aOptions) { let {preview} = aGrip; let stringifier; if (preview && preview.kind) { stringifier = VariablesView.stringifiers.byObjectKind[preview.kind]; } if (!stringifier && aGrip.class) { stringifier = VariablesView.stringifiers.byObjectClass[aGrip.class]; } if (stringifier) { return stringifier(aGrip, aOptions); } return null; }, symbol: function(aGrip, aOptions) { const name = aGrip.name || ""; return "Symbol(" + name + ")"; }, }; // VariablesView.stringifiers.byType VariablesView.stringifiers.byObjectClass = { Function: function(aGrip, {concise}) { // TODO: Bug 948484 - support arrow functions and ES6 generators let name = aGrip.userDisplayName || aGrip.displayName || aGrip.name || ""; name = VariablesView.getString(name, { noStringQuotes: true }); // TODO: Bug 948489 - Support functions with destructured parameters and // rest parameters let params = aGrip.parameterNames || ""; if (!concise) { return "function " + name + "(" + params + ")"; } return (name || "function ") + "(" + params + ")"; }, RegExp: function({displayString}) { return VariablesView.getString(displayString, { noStringQuotes: true }); }, Date: function({preview}) { if (!preview || !("timestamp" in preview)) { return null; } if (typeof preview.timestamp != "number") { return new Date(preview.timestamp).toString(); // invalid date } return "Date " + new Date(preview.timestamp).toISOString(); }, String: function({displayString}) { if (displayString === undefined) { return null; } return VariablesView.getString(displayString); }, Number: function({preview}) { if (preview === undefined) { return null; } return VariablesView.getString(preview.value); }, }; // VariablesView.stringifiers.byObjectClass VariablesView.stringifiers.byObjectClass.Boolean = VariablesView.stringifiers.byObjectClass.Number; VariablesView.stringifiers.byObjectKind = { ArrayLike: function(aGrip, {concise}) { let {preview} = aGrip; if (concise) { return aGrip.class + "[" + preview.length + "]"; } if (!preview.items) { return null; } let shown = 0, result = [], lastHole = null; for (let item of preview.items) { if (item === null) { if (lastHole !== null) { result[lastHole] += ","; } else { result.push(""); } lastHole = result.length - 1; } else { lastHole = null; result.push(VariablesView.getString(item, { concise: true })); } shown++; } if (shown < preview.length) { let n = preview.length - shown; result.push(VariablesView.stringifiers._getNMoreString(n)); } else if (lastHole !== null) { // make sure we have the right number of commas... result[lastHole] += ","; } let prefix = aGrip.class == "Array" ? "" : aGrip.class + " "; return prefix + "[" + result.join(", ") + "]"; }, MapLike: function(aGrip, {concise}) { let {preview} = aGrip; if (concise || !preview.entries) { let size = typeof preview.size == "number" ? "[" + preview.size + "]" : ""; return aGrip.class + size; } let entries = []; for (let [key, value] of preview.entries) { let keyString = VariablesView.getString(key, { concise: true, noStringQuotes: true, }); let valueString = VariablesView.getString(value, { concise: true }); entries.push(keyString + ": " + valueString); } if (typeof preview.size == "number" && preview.size > entries.length) { let n = preview.size - entries.length; entries.push(VariablesView.stringifiers._getNMoreString(n)); } return aGrip.class + " {" + entries.join(", ") + "}"; }, ObjectWithText: function(aGrip, {concise}) { if (concise) { return aGrip.class; } return aGrip.class + " " + VariablesView.getString(aGrip.preview.text); }, ObjectWithURL: function(aGrip, {concise}) { let result = aGrip.class; let url = aGrip.preview.url; if (!VariablesView.isFalsy({ value: url })) { result += " \u2192 " + WebConsoleUtils.abbreviateSourceURL(url, { onlyCropQuery: !concise }); } return result; }, // Stringifier for any kind of object. Object: function(aGrip, {concise}) { if (concise) { return aGrip.class; } let {preview} = aGrip; let props = []; for (let key of Object.keys(preview.ownProperties || {})) { let value = preview.ownProperties[key]; let valueString = ""; if (value.get) { valueString = "Getter"; } else if (value.set) { valueString = "Setter"; } else { valueString = VariablesView.getString(value.value, { concise: true }); } props.push(key + ": " + valueString); } for (let key of Object.keys(preview.safeGetterValues || {})) { let value = preview.safeGetterValues[key]; let valueString = VariablesView.getString(value.getterValue, { concise: true }); props.push(key + ": " + valueString); } if (!props.length) { return null; } if (preview.ownPropertiesLength) { let previewLength = Object.keys(preview.ownProperties).length; let diff = preview.ownPropertiesLength - previewLength; if (diff > 0) { props.push(VariablesView.stringifiers._getNMoreString(diff)); } } let prefix = aGrip.class != "Object" ? aGrip.class + " " : ""; return prefix + "{" + props.join(", ") + "}"; }, // Object Error: function(aGrip, {concise}) { let {preview} = aGrip; let name = VariablesView.getString(preview.name, { noStringQuotes: true }); if (concise) { return name || aGrip.class; } let msg = name + ": " + VariablesView.getString(preview.message, { noStringQuotes: true }); if (!VariablesView.isFalsy({ value: preview.stack })) { msg += "\n" + STR.GetStringFromName("variablesViewErrorStacktrace") + "\n" + preview.stack; } return msg; }, DOMException: function(aGrip, {concise}) { let {preview} = aGrip; if (concise) { return preview.name || aGrip.class; } let msg = aGrip.class + " [" + preview.name + ": " + VariablesView.getString(preview.message) + "\n" + "code: " + preview.code + "\n" + "nsresult: 0x" + (+preview.result).toString(16); if (preview.filename) { msg += "\nlocation: " + preview.filename; if (preview.lineNumber) { msg += ":" + preview.lineNumber; } } return msg + "]"; }, DOMEvent: function(aGrip, {concise}) { let {preview} = aGrip; if (!preview.type) { return null; } if (concise) { return aGrip.class + " " + preview.type; } let result = preview.type; if (preview.eventKind == "key" && preview.modifiers && preview.modifiers.length) { result += " " + preview.modifiers.join("-"); } let props = []; if (preview.target) { let target = VariablesView.getString(preview.target, { concise: true }); props.push("target: " + target); } for (let prop in preview.properties) { let value = preview.properties[prop]; props.push(prop + ": " + VariablesView.getString(value, { concise: true })); } return result + " {" + props.join(", ") + "}"; }, // DOMEvent DOMNode: function(aGrip, {concise}) { let {preview} = aGrip; switch (preview.nodeType) { case Ci.nsIDOMNode.DOCUMENT_NODE: { let result = aGrip.class; if (preview.location) { let location = WebConsoleUtils.abbreviateSourceURL(preview.location, { onlyCropQuery: !concise }); result += " \u2192 " + location; } return result; } case Ci.nsIDOMNode.ATTRIBUTE_NODE: { let value = VariablesView.getString(preview.value, { noStringQuotes: true }); return preview.nodeName + '="' + escapeHTML(value) + '"'; } case Ci.nsIDOMNode.TEXT_NODE: return preview.nodeName + " " + VariablesView.getString(preview.textContent); case Ci.nsIDOMNode.COMMENT_NODE: { let comment = VariablesView.getString(preview.textContent, { noStringQuotes: true }); return ""; } case Ci.nsIDOMNode.DOCUMENT_FRAGMENT_NODE: { if (concise || !preview.childNodes) { return aGrip.class + "[" + preview.childNodesLength + "]"; } let nodes = []; for (let node of preview.childNodes) { nodes.push(VariablesView.getString(node)); } if (nodes.length < preview.childNodesLength) { let n = preview.childNodesLength - nodes.length; nodes.push(VariablesView.stringifiers._getNMoreString(n)); } return aGrip.class + " [" + nodes.join(", ") + "]"; } case Ci.nsIDOMNode.ELEMENT_NODE: { let attrs = preview.attributes; if (!concise) { let n = 0, result = "<" + preview.nodeName; for (let name in attrs) { let value = VariablesView.getString(attrs[name], { noStringQuotes: true }); result += " " + name + '="' + escapeHTML(value) + '"'; n++; } if (preview.attributesLength > n) { result += " " + Scope.ellipsis; } return result + ">"; } let result = "<" + preview.nodeName; if (attrs.id) { result += "#" + attrs.id; } if (attrs.class) { result += "." + attrs.class.trim().replace(/\s+/, "."); } return result + ">"; } default: return null; } }, // DOMNode }; // VariablesView.stringifiers.byObjectKind /** * Get the "N more…" formatted string, given an N. This is used for displaying * how many elements are not displayed in an object preview (eg. an array). * * @private * @param number aNumber * @return string */ VariablesView.stringifiers._getNMoreString = function(aNumber) { let str = STR.GetStringFromName("variablesViewMoreObjects"); return PluralForm.get(aNumber, str).replace("#1", aNumber); }; /** * Returns a custom class style for a grip. * * @param any aGrip * @see Variable.setGrip * @return string * The custom class style. */ VariablesView.getClass = function(aGrip) { if (aGrip && typeof aGrip == "object") { if (aGrip.preview) { switch (aGrip.preview.kind) { case "DOMNode": return "token-domnode"; } } switch (aGrip.type) { case "undefined": return "token-undefined"; case "null": return "token-null"; case "Infinity": case "-Infinity": case "NaN": case "-0": return "token-number"; case "longString": return "token-string"; } } switch (typeof aGrip) { case "string": return "token-string"; case "boolean": return "token-boolean"; case "number": return "token-number"; default: return "token-other"; } }; /** * A monotonically-increasing counter, that guarantees the uniqueness of scope, * variables and properties ids. * * @param string aName * An optional string to prefix the id with. * @return number * A unique id. */ let generateId = (function() { let count = 0; return function(aName = "") { return aName.toLowerCase().trim().replace(/\s+/g, "-") + (++count); }; })(); /** * Escape some HTML special characters. We do not need full HTML serialization * here, we just want to make strings safe to display in HTML attributes, for * the stringifiers. * * @param string aString * @return string */ function escapeHTML(aString) { return aString.replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } /** * An Editable encapsulates the UI of an edit box that overlays a label, * allowing the user to edit the value. * * @param Variable aVariable * The Variable or Property to make editable. * @param object aOptions * - onSave * The callback to call with the value when editing is complete. * - onCleanup * The callback to call when the editable is removed for any reason. */ function Editable(aVariable, aOptions) { this._variable = aVariable; this._onSave = aOptions.onSave; this._onCleanup = aOptions.onCleanup; } Editable.create = function(aVariable, aOptions, aEvent) { let editable = new this(aVariable, aOptions); editable.activate(aEvent); return editable; }; Editable.prototype = { /** * The class name for targeting this Editable type's label element. Overridden * by inheriting classes. */ className: null, /** * Boolean indicating whether this Editable should activate. Overridden by * inheriting classes. */ shouldActivate: null, /** * The label element for this Editable. Overridden by inheriting classes. */ label: null, /** * Activate this editable by replacing the input box it overlays and * initialize the handlers. * * @param Event e [optional] * Optionally, the Event object that was used to activate the Editable. */ activate: function(e) { if (!this.shouldActivate) { this._onCleanup && this._onCleanup(); return; } let { label } = this; let initialString = label.getAttribute("value"); if (e) { e.preventDefault(); e.stopPropagation(); } // Create a texbox input element which will be shown in the current // element's specified label location. let input = this._input = this._variable.document.createElement("textbox"); input.className = "plain " + this.className; input.setAttribute("value", initialString); input.setAttribute("flex", "1"); // Replace the specified label with a textbox input element. label.parentNode.replaceChild(input, label); this._variable._variablesView.boxObject.ensureElementIsVisible(input); input.select(); // When the value is a string (displayed as "value"), then we probably want // to change it to another string in the textbox, so to avoid typing the "" // again, tackle with the selection bounds just a bit. if (initialString.match(/^".+"$/)) { input.selectionEnd--; input.selectionStart++; } this._onKeypress = this._onKeypress.bind(this); this._onBlur = this._onBlur.bind(this); input.addEventListener("keypress", this._onKeypress); input.addEventListener("blur", this._onBlur); this._prevExpandable = this._variable.twisty; this._prevExpanded = this._variable.expanded; this._variable.collapse(); this._variable.hideArrow(); this._variable.locked = true; this._variable.editing = true; }, /** * Remove the input box and restore the Variable or Property to its previous * state. */ deactivate: function() { this._input.removeEventListener("keypress", this._onKeypress); this._input.removeEventListener("blur", this.deactivate); this._input.parentNode.replaceChild(this.label, this._input); this._input = null; let { boxObject } = this._variable._variablesView; boxObject.scrollBy(-this._variable._target, 0); this._variable.locked = false; this._variable.twisty = this._prevExpandable; this._variable.expanded = this._prevExpanded; this._variable.editing = false; this._onCleanup && this._onCleanup(); }, /** * Save the current value and deactivate the Editable. */ _save: function() { let initial = this.label.getAttribute("value"); let current = this._input.value.trim(); this.deactivate(); if (initial != current) { this._onSave(current); } }, /** * Called when tab is pressed, allowing subclasses to link different * behavior to tabbing if desired. */ _next: function() { this._save(); }, /** * Called when escape is pressed, indicating a cancelling of editing without * saving. */ _reset: function() { this.deactivate(); this._variable.focus(); }, /** * Event handler for when the input loses focus. */ _onBlur: function() { this.deactivate(); }, /** * Event handler for when the input receives a key press. */ _onKeypress: function(e) { e.stopPropagation(); switch (e.keyCode) { case e.DOM_VK_TAB: this._next(); break; case e.DOM_VK_RETURN: this._save(); break; case e.DOM_VK_ESCAPE: this._reset(); break; } }, }; /** * An Editable specific to editing the name of a Variable or Property. */ function EditableName(aVariable, aOptions) { Editable.call(this, aVariable, aOptions); } EditableName.create = Editable.create; EditableName.prototype = Heritage.extend(Editable.prototype, { className: "element-name-input", get label() { return this._variable._name; }, get shouldActivate() { return !!this._variable.ownerView.switch; }, }); /** * An Editable specific to editing the value of a Variable or Property. */ function EditableValue(aVariable, aOptions) { Editable.call(this, aVariable, aOptions); } EditableValue.create = Editable.create; EditableValue.prototype = Heritage.extend(Editable.prototype, { className: "element-value-input", get label() { return this._variable._valueLabel; }, get shouldActivate() { return !!this._variable.ownerView.eval; }, }); /** * An Editable specific to editing the key and value of a new property. */ function EditableNameAndValue(aVariable, aOptions) { EditableName.call(this, aVariable, aOptions); } EditableNameAndValue.create = Editable.create; EditableNameAndValue.prototype = Heritage.extend(EditableName.prototype, { _reset: function(e) { // Hide the Variable or Property if the user presses escape. this._variable.remove(); this.deactivate(); }, _next: function(e) { // Override _next so as to set both key and value at the same time. let key = this._input.value; this.label.setAttribute("value", key); let valueEditable = EditableValue.create(this._variable, { onSave: aValue => { this._onSave([key, aValue]); } }); valueEditable._reset = () => { this._variable.remove(); valueEditable.deactivate(); }; }, _save: function(e) { // Both _save and _next activate the value edit box. this._next(e); } });