Source code

Revision control

Other Tools

/* 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 DBG_STRINGS_URI = "devtools/client/locales/debugger.properties";
const LAZY_EMPTY_DELAY = 150; // ms
const SCROLL_PAGE_SIZE_DEFAULT = 0;
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
const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm");
const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
const EventEmitter = require("devtools/shared/event-emitter");
const DevToolsUtils = require("devtools/shared/DevToolsUtils");
const Services = require("Services");
const { getSourceNames } = require("devtools/client/shared/source-utils");
const promise = require("promise");
const defer = require("devtools/shared/defer");
const { extend } = require("devtools/shared/extend");
const {
ViewHelpers,
setNamedTimeout,
} = require("devtools/client/shared/widgets/view-helpers");
const nodeConstants = require("devtools/shared/dom-node-constants");
const { KeyCodes } = require("devtools/client/shared/keycodes");
const { PluralForm } = require("devtools/shared/plural-form");
const { LocalizationHelper, ELLIPSIS } = require("devtools/shared/l10n");
const L10N = new LocalizationHelper(DBG_STRINGS_URI);
const HTML_NS = "http://www.w3.org/1999/xhtml";
XPCOMUtils.defineLazyServiceGetter(
this,
"clipboardHelper",
"@mozilla.org/widget/clipboardhelper;1",
"nsIClipboardHelper"
);
const EXPORTED_SYMBOLS = ["VariablesView", "escapeHTML"];
/**
* 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 Node 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 ... }
*/
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._onSearchboxKeyDown = this._onSearchboxKeyDown.bind(this);
this._onViewKeyDown = this._onViewKeyDown.bind(this);
// Create an internal scrollbox container.
this._list = this.document.createXULElement("scrollbox");
this._list.setAttribute("orient", "vertical");
this._list.addEventListener("keydown", this._onViewKeyDown);
this._parent.appendChild(this._list);
for (const 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(undefined, { 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.).
* @param string aCustomClass
* An additional class name for the containing element.
* @return Scope
* The newly created Scope instance.
*/
addScope: function(aName = "", aCustomClass = "") {
this._removeEmptyNotice();
this._toggleSearchVisibility(true);
const scope = new Scope(this, aName, { customClass: aCustomClass });
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 = new WeakMap();
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) {
const prevList = this._list;
const currList = (this._list = this.document.createXULElement("scrollbox"));
this.window.setTimeout(() => {
prevList.removeEventListener("keydown", this._onViewKeyDown);
currList.addEventListener("keydown", this._onViewKeyDown);
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,
/**
* 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: L10N.getStr("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: L10N.getStr("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: L10N.getStr("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: L10N.getStr("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: L10N.getStr("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: L10N.getStr("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 (const 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 (const 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() {
return !!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() {
return 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;
}
const document = this.document;
const ownerNode = this._parent.parentNode;
const container = (this._searchboxContainer = document.createXULElement(
"hbox"
));
container.className = "devtools-toolbar devtools-input-toolbar";
// Hide the variables searchbox container if there are no variables or
// properties to display.
container.hidden = !this._store.length;
const searchbox = (this._searchboxNode = document.createElementNS(
HTML_NS,
"input"
));
searchbox.className = "variables-view-searchinput devtools-filterinput";
searchbox.setAttribute("placeholder", this._searchboxPlaceholder);
searchbox.addEventListener("input", this._onSearchboxInput);
searchbox.addEventListener("keydown", this._onSearchboxKeyDown);
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);
this._searchboxNode.removeEventListener(
"keydown",
this._onSearchboxKeyDown
);
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 keydown event.
*/
_onSearchboxKeyDown: function(e) {
switch (e.keyCode) {
case KeyCodes.DOM_VK_RETURN:
this._onSearchboxInput();
return;
case KeyCodes.DOM_VK_ESCAPE:
this._searchboxNode.value = "";
this._onSearchboxInput();
}
},
/**
* 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.
const maxDelay = SEARCH_ACTION_MAX_DELAY;
const 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) {
if (this.controller && this.controller.supportsSearch()) {
// Retrieve the main Scope in which we add attributes
const scope = this._store[0]._store.get(undefined);
if (!aToken) {
// Prune the view from old previous content
// so that we delete the intermediate search results
// we created in previous searches
for (const property of scope._store.values()) {
property.remove();
}
}
// Retrieve new attributes eventually hidden in splits
this.controller.performSearch(scope, aToken);
// Filter already displayed attributes
if (aToken) {
scope._performSearch(aToken.toLowerCase());
}
return;
}
for (const 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 (const scope of this._store) {
const 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--) {
const scope = this._store[i];
const 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 Node 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) {
const 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() {
const focused = this.document.commandDispatcher.focusedElement;
return this.getItemForNode(focused);
},
/**
* Focuses the first visible scope, variable, or property in this container.
*/
focusFirstVisibleItem: function() {
const 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() {
const 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) {
const 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) {
const commandDispatcher = this.document.commandDispatcher;
const prevFocusedElement = commandDispatcher.focusedElement;
let currFocusedItem = null;
do {
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();
aItem._arrow.scrollIntoView({ block: "nearest" });
return true;
},
/**
* Copy current selection to clipboard.
*/
_copyItem: function() {
const item = this.getFocusedItem();
clipboardHelper.copyString(
item._nameString + item.separatorStr + item._valueString
);
},
/**
* Listener handling a key down event on the view.
*/
// eslint-disable-next-line complexity
_onViewKeyDown: function(e) {
const item = this.getFocusedItem();
// Prevent scrolling when pressing navigation keys.
ViewHelpers.preventScrolling(e);
switch (e.keyCode) {
case KeyCodes.DOM_VK_C:
if (e.ctrlKey || e.metaKey) {
this._copyItem();
}
return;
case KeyCodes.DOM_VK_UP:
// Always rewind focus.
this.focusPrevItem(true);
return;
case KeyCodes.DOM_VK_DOWN:
// Always advance focus.
this.focusNextItem(true);
return;
case KeyCodes.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 KeyCodes.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 KeyCodes.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 KeyCodes.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 KeyCodes.DOM_VK_HOME:
this.focusFirstVisibleItem();
return;
case KeyCodes.DOM_VK_END:
this.focusLastVisibleItem();
return;
case KeyCodes.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 KeyCodes.DOM_VK_DELETE:
case KeyCodes.DOM_VK_BACK_SPACE:
// Delete the Variable or Property if allowed.
if (item instanceof Variable) {
item._onDelete(e);
}
return;
case KeyCodes.DOM_VK_INSERT:
item._onAddProperty(e);
}
},
/**
* 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;
}
const label = this.document.createXULElement("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 Node
*/
get parentNode() {
return this._parent;
},
/**
* Gets the owner document holding this view.
* @return HTMLDocument
*/
get document() {
return this._document || (this._document = this._parent.ownerDocument);
},
/**
* Gets the default window holding this view.
* @return nsIDOMWindow
*/
get window() {
return 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.includes(aClassName);
};
/**
* 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 = ""
) {
const property = escapeString(aItem._nameString);
const 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 = ""
) {
const type = aItem._nameString;
const propertyObject = aItem.ownerView;
const parentObject = propertyObject.ownerView;
const property = escapeString(propertyObject._nameString);
const parent = aPrefix + parentObject.symbolicName || "this";
switch (aCurrentString) {
case "":
case "null":
case "undefined":
const mirrorType = type == "get" ? "set" : "get";
const 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")) {
const header = "function(" + (type == "set" ? "value" : "") + ")";
let body = "";
// If there's a return statement explicitly written, always use the
// standard function definition syntax
if (aCurrentString.includes("return ")) {
body = "{" + aCurrentString + "}";
} else if (aCurrentString.startsWith("{")) {
// If block syntax is used, use the whole string as the function body.
body = aCurrentString;
} else {
// Prefer an expression closure.
body = "(" + aCurrentString + ")";
}
aCurrentString = header + body;
}
// Determine if a new getter or setter should be defined.
const defineType =
type == "get" ? "__defineGetter__" : "__defineSetter__";
// Make sure all quotes are escaped in the expression's syntax,
const 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.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, 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.
* @param object aOptions
* Options of the form accepted by addItem.
* @return Variable
* The newly created child Variable.
*/
_createChild: function(aName, aDescriptor, aOptions) {
return new Variable(this, aName, aDescriptor, aOptions);
},
/**
* 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 object aOptions
* Specifies some options affecting the new variable.
* Recognized properties are
* * boolean relaxed true if name duplicates should be allowed.
* You probably shouldn't do it. Use this
* with caution.
* * boolean internalItem true if the item is internally generated.
* This is used for special variables
* like <return> or <exception> and distinguishes
* them from ordinary properties that happen
* to have the same name
* @return Variable
* The newly created Variable instance, null if it already exists.
*/
addItem: function(aName, aDescriptor = {}, aOptions = {}) {
const { relaxed } = aOptions;
if (this._store.has(aName) && !relaxed) {
return this._store.get(aName);
}
const child = this._createChild(aName, aDescriptor, aOptions);
this._store.set(aName, child);
this._variablesView._itemsByElement.set(child._target, child);
this._variablesView._currHierarchy.set(child.absoluteName, child);
child.header = aName !== undefined;
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
*/
addItems: function(aItems, aOptions = {}) {
const names = Object.keys(aItems);
// Sort all of the properties before adding them, if preferred.
if (aOptions.sorted) {
names.sort(this._naturalSort);
}
// Add the properties to the current scope.
for (const name of names) {
const descriptor = aItems[name];
const item = this.addItem(name, descriptor);
if (aOptions.callback) {
aOptions.callback(item, descriptor && descriptor.value);
}
}
},
/**
* Remove this Scope from its parent and remove all children recursively.
*/
remove: function() {
const 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 (const 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 Node aNode
* The node to search for.
* @return Variable | Property
* The matched variable or property, or null if nothing is found.
*/
find: function(aNode) {
for (const [, 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.dispatchToMainThread({ run: this._openNonEnum });
}
this._isExpanded = true;
if (this.onexpand) {
// We return onexpand as it sometimes returns a promise
// (up to the user of VariableView to do it)
// that can indicate when the view is done expanding
// and attributes are available. (Mostly used for tests)
return 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 (const [, 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;
},
/**
* Sort in ascending order
* This only needs to compare non-numbers since it is dealing with an array
* which numeric-based indices are placed in order.
*
* @param string a
* @param string b
* @return number
* -1 if a is less than b, 0 if no change in order, +1 if a is greater than 0
*/
_naturalSort: function(a, b) {
if (isNaN(parseFloat(a)) && isNaN(parseFloat(b))) {
return a < b ? -1 : 1;
}
},
/**
* 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() {
return this._isContentVisible;
},
/**
* Gets the expanded state.
* @return boolean
*/
get expanded() {
return this._isExpanded;
},
/**
* Gets the header visibility state.
* @return boolean
*/
get header() {
return this._isHeaderVisible;
},
/**
* Gets the twisty visibility state.
* @return boolean
*/
get twisty() {
return this._isArrowVisible;
},
/**
* Gets the expand lock state.
* @return boolean
*/
get locked() {
return 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() {
return this._idString;
},
/**
* Gets the name associated with this item.
* @return string
*/
get name() {
return this._nameString;
},
/**
* Gets the displayed value for this item.
* @return string
*/
get displayValue() {
return this._valueString;
},
/**
* Gets the class names used for the displayed value.
* @return string
*/
get displayValueClassName() {
return this._valueClassName;
},
/**
* Gets the element associated with this item.
* @return Node
*/
get target() {
return 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} ${aFlags.customClass}`,
"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 = "") {
const document = this.document;
const element = (this._target = document.createXULElement("vbox"));
element.id = this._idString;
element.className = aTargetClassName;
const arrow = (this._arrow = document.createXULElement("hbox"));
arrow.className = "arrow theme-twisty";
const name = (this._name = document.createXULElement("label"));
name.className = "plain name";
name.setAttribute("value", aName.trim());
name.setAttribute("crop", "end");
const title = (this._title = document.createXULElement("hbox"));
title.className = "title " + aTitleClassName;
title.setAttribute("align", "center");
const enumerable = (this._enum = document.createXULElement("vbox"));
const nonenum = (this._nonenum = document.createXULElement("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);
},
/**
* 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 (const [, 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 (const [, 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) {
const currentObject = variable;
const lowerCaseName = variable._nameString.toLowerCase();
const lowerCaseValue = variable._valueString.toLowerCase();
// Non-matched variables or properties require a corresponding attribute.
if (
!lowerCaseName.includes(aLowerCaseQuery) &&
!lowerCaseValue.includes(aLowerCaseQuery)
) {
variable._matched = false;
} else {
// Variable or property is matched.
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").