diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/client/storage/VariablesView.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/storage/VariablesView.sys.mjs')
-rw-r--r-- | devtools/client/storage/VariablesView.sys.mjs | 4445 |
1 files changed, 4445 insertions, 0 deletions
diff --git a/devtools/client/storage/VariablesView.sys.mjs b/devtools/client/storage/VariablesView.sys.mjs new file mode 100644 index 0000000000..769840c022 --- /dev/null +++ b/devtools/client/storage/VariablesView.sys.mjs @@ -0,0 +1,4445 @@ +/* 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/. */ + +/* eslint-disable mozilla/no-aArgs */ + +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 + +import { require } from "resource://devtools/shared/loader/Loader.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +const { + getSourceNames, +} = require("resource://devtools/client/shared/source-utils.js"); +const { extend } = require("resource://devtools/shared/extend.js"); +const { + ViewHelpers, + setNamedTimeout, +} = require("resource://devtools/client/shared/widgets/view-helpers.js"); +const nodeConstants = require("resource://devtools/shared/dom-node-constants.js"); +const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); +const { PluralForm } = require("resource://devtools/shared/plural-form.js"); +const { + LocalizationHelper, + ELLIPSIS, +} = require("resource://devtools/shared/l10n.js"); + +const L10N = new LocalizationHelper(DBG_STRINGS_URI); +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "clipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +/** + * 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 ... } + */ +export 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 l10nId + * The scope localized string id. + * @param string aCustomClass + * An additional class name for the containing element. + * @return Scope + * The newly created Scope instance. + */ + addScope(l10nId = "", aCustomClass = "") { + this._removeEmptyNotice(); + this._toggleSearchVisibility(true); + + const scope = new Scope(this, l10nId, { customClass: aCustomClass }); + this._store.push(scope); + this._itemsByElement.set(scope._target, scope); + this._currHierarchy.set(l10nId, scope); + scope.header = !!l10nId; + + 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(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(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; + }, + + /** + * Enables variable and property searching in this view. + * Use the "searchEnabled" setter to enable searching. + */ + _enableSearch() { + // 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("data-l10n-id", "storage-variable-view-search-box"); + 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() { + // 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(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() { + this.scheduleSearch(this._searchboxNode.value); + }, + + /** + * Listener handling the searchbox keydown event. + */ + _onSearchboxKeyDown(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(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(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(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(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(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(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(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(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() { + const focused = this.document.commandDispatcher.focusedElement; + return this.getItemForNode(focused); + }, + + /** + * Focuses the first visible scope, variable, or property in this container. + */ + focusFirstVisibleItem() { + 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() { + 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() { + this.focusItemAtDelta(+1); + }, + + /** + * Focuses the previous scope, variable or property in this view. + */ + focusPrevItem() { + 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(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(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(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() { + const item = this.getFocusedItem(); + lazy.clipboardHelper.copyString( + item._nameString + item.separatorStr + item._valueString + ); + }, + + /** + * Listener handling a key down event on the view. + */ + // eslint-disable-next-line complexity + _onViewKeyDown(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() { + 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() { + 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, + _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 l10nId + * The scope localized string id. + * @param object aFlags [optional] + * Additional options or flags for this scope. + */ +function Scope(aView, l10nId, 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(l10nId, 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(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(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(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() { + 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(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(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(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(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() { + this._target.hidden = false; + this._isContentVisible = true; + + if (this.onshow) { + this.onshow(this); + } + }, + + /** + * Hides the scope. + */ + hide() { + this._target.hidden = true; + this._isContentVisible = false; + + if (this.onhide) { + this.onhide(this); + } + }, + + /** + * Expands the scope, showing all the added details. + */ + async expand() { + 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) + await this.onexpand(this); + } + }, + + /** + * Collapses the scope, hiding all the added details. + */ + collapse() { + 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(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() { + 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() { + 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(a, b) { + if (isNaN(parseFloat(a)) && isNaN(parseFloat(b))) { + return a < b ? -1 : 1; + } + return 0; + }, + + /** + * Shows the scope's expand/collapse arrow. + */ + showArrow() { + if (this._isArrowVisible) { + return; + } + this._arrow.removeAttribute("invisible"); + this._isArrowVisible = true; + }, + + /** + * Hides the scope's expand/collapse arrow. + */ + hideArrow() { + 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() { + 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(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(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 l10nId + * The scope localized string id. + * @param object aFlags [optional] + * Additional options or flags for this scope. + */ + _init(l10nId, aFlags) { + this._idString = generateId((this._nameString = l10nId)); + this._displayScope({ + l10nId, + targetClassName: `${this.targetClassName} ${aFlags.customClass}`, + titleClassName: "devtools-toolbar", + }); + this._addEventListeners(); + this.parentNode.appendChild(this._target); + }, + + /** + * Creates the necessary nodes for this scope. + * + * @param Object options + * @param string options.l10nId [optional] + * The scope localized string id. + * @param string options.value [optional] + * The scope's name. Either this or l10nId need to be passed + * @param string options.targetClassName + * A custom class name for this scope's target element. + * @param string options.titleClassName [optional] + * A custom class name for this scope's title element. + */ + _displayScope({ l10nId, value, targetClassName, titleClassName = "" }) { + const document = this.document; + + const element = (this._target = document.createXULElement("vbox")); + element.id = this._idString; + element.className = targetClassName; + + const arrow = (this._arrow = document.createXULElement("hbox")); + arrow.className = "arrow theme-twisty"; + + const name = (this._name = document.createXULElement("label")); + name.className = "plain name"; + if (l10nId) { + name.setAttribute("data-l10n-id", l10nId); + } else { + name.setAttribute("value", value); + } + name.setAttribute("crop", "end"); + + const title = (this._title = document.createXULElement("hbox")); + title.className = "title " + titleClassName; + 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() { + this._title.addEventListener("mousedown", this._onClick); + }, + + /** + * The click listener for this scope's title. + */ + _onClick(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() { + this._arrow.setAttribute("open", ""); + this._enum.setAttribute("open", ""); + }, + + /** + * Opens the non-enumerable items container. + */ + _openNonEnum() { + 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(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"). + 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(aPredicate) { + if (aPredicate(this)) { + return this; + } + + if (this._isExpanded) { + if (this._variablesView._enumVisible) { + for (const item of this._enumItems) { + const result = item._findInVisibleItems(aPredicate); + if (result) { + return result; + } + } + } + + if (this._variablesView._nonEnumVisible) { + for (const item of this._nonEnumItems) { + const 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(aPredicate) { + if (this._isExpanded) { + if (this._variablesView._nonEnumVisible) { + for (let i = this._nonEnumItems.length - 1; i >= 0; i--) { + const item = this._nonEnumItems[i]; + const result = item._findInVisibleItemsReverse(aPredicate); + if (result) { + return result; + } + } + } + + if (this._variablesView._enumVisible) { + for (let i = this._enumItems.length - 1; i >= 0; i--) { + const item = this._enumItems[i]; + const 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() { + return ( + this._topView || + (this._topView = (() => { + let parentView = this.ownerView; + let topView; + + while ((topView = parentView.ownerView)) { + parentView = topView; + } + return parentView; + })()) + ); + }, + + /** + * Gets the parent node holding this scope. + * @return Node + */ + get parentNode() { + return this.ownerView._list; + }, + + /** + * Gets the owner document holding this scope. + * @return HTMLDocument + */ + get document() { + return this._document || (this._document = this.ownerView.document); + }, + + /** + * Gets the default window holding this scope. + * @return nsIDOMWindow + */ + get window() { + return 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", + () => new Map() +); +DevToolsUtils.defineLazyPrototypeGetter(Scope.prototype, "_enumItems", Array); +DevToolsUtils.defineLazyPrototypeGetter( + Scope.prototype, + "_nonEnumItems", + Array +); + +/** + * 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. + * @param object aOptions + * Options of the form accepted by Scope.addItem + */ +function Variable(aScope, aName, aDescriptor, aOptions) { + 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); + this._internalItem = aOptions.internalItem; + + // 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 = 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. + * @param object aOptions + * Options of the form accepted by Scope.addItem + * @return Property + * The newly created child Property. + */ + _createChild(aName, aDescriptor, aOptions) { + return new Property(this, aName, aDescriptor, aOptions); + }, + + /** + * Remove this Variable from its parent and remove all children recursively. + */ + remove() { + if (this._linkedToInspector) { + this.unhighlightDomNode(); + this._valueLabel.removeEventListener("mouseover", this.highlightDomNode); + this._valueLabel.removeEventListener("mouseout", this.unhighlightDomNode); + this._openInspectorNode.removeEventListener( + "mousedown", + this.openNodeInInspector + ); + } + + this.ownerView._store.delete(this._nameString); + this._variablesView._itemsByElement.delete(this._target); + this._variablesView._currHierarchy.delete(this.absoluteName); + + this._target.remove(); + + for (const 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(aObject, aOptions = {}) { + // Retrieve the properties only once. + if (this._fetched) { + return; + } + this._fetched = true; + + const propertyNames = Object.getOwnPropertyNames(aObject); + const prototype = Object.getPrototypeOf(aObject); + + // Sort all of the properties before adding them, if preferred. + if (aOptions.sorted) { + propertyNames.sort(this._naturalSort); + } + + // Add all the variable properties. + for (const name of propertyNames) { + const descriptor = Object.getOwnPropertyDescriptor(aObject, name); + if (descriptor.get || descriptor.set) { + const prop = this._addRawNonValueProperty(name, descriptor); + if (aOptions.expanded) { + prop.expanded = true; + } + } else { + const 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(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(aName, aDescriptor, aValue) { + const descriptor = Object.create(aDescriptor); + descriptor.value = VariablesView.getGrip(aValue); + + const 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(aName, aDescriptor) { + const 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 + "[" + escapeString(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(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() { + return this._initialDescriptor.value; + }, + + /** + * Returns this variable's getter from the descriptor if available. + * @return object + */ + get getter() { + return this._initialDescriptor.get; + }, + + /** + * Returns this variable's getter from the descriptor if available. + * @return object + */ + get setter() { + return 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(aGrip) { + // Don't allow displaying grip information if there's no name available + // or the grip is malformed. + if ( + this._nameString === undefined || + aGrip === undefined || + aGrip === null + ) { + return; + } + // Getters and setters should display grip information in sub-properties. + if (this.getter || this.setter) { + return; + } + + const prevGrip = this._valueGrip; + if (prevGrip) { + this._valueLabel.classList.remove(VariablesView.getClass(prevGrip)); + } + this._valueGrip = aGrip; + + if ( + aGrip && + (aGrip.optimizedOut || aGrip.uninitialized || aGrip.missingArguments) + ) { + if (aGrip.optimizedOut) { + this._valueString = L10N.getStr("variablesViewOptimizedOut"); + } else if (aGrip.uninitialized) { + this._valueString = L10N.getStr("variablesViewUninitialized"); + } else if (aGrip.missingArguments) { + this._valueString = L10N.getStr("variablesViewMissingArgs"); + } + this.eval = null; + } else { + this._valueString = VariablesView.getString(aGrip, { + concise: true, + noEllipsis: true, + }); + this.eval = this.ownerView.eval; + } + + 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(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(aDuration = ITEM_FLASH_DURATION) { + const fadeInDelay = this._variablesView.lazyEmptyDelay + 1; + const 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(aName, aDescriptor) { + this._idString = generateId((this._nameString = aName)); + this._displayScope({ value: aName, targetClassName: this.targetClassName }); + this._displayVariable(); + this._customizeVariable(); + this._prepareTooltips(); + this._setAttributes(); + this._addEventListeners(); + + if ( + this._initialDescriptor.enumerable || + this._nameString == "this" || + this._internalItem + ) { + 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() { + const document = this.document; + const descriptor = this._initialDescriptor; + + const separatorLabel = (this._separatorLabel = + document.createXULElement("label")); + separatorLabel.className = "plain separator"; + separatorLabel.setAttribute("value", this.separatorStr + " "); + + const valueLabel = (this._valueLabel = document.createXULElement("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; + } else { + // Deleting getters and setters individually is not allowed if no + // evaluation method is provided. + this.delete = null; + this.evaluationMacro = null; + } + + const getter = this.addItem("get", { value: descriptor.get }); + const 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() { + const ownerView = this.ownerView; + const descriptor = this._initialDescriptor; + + if ((ownerView.eval && this.getter) || this.setter) { + const editNode = (this._editNode = + this.document.createXULElement("toolbarbutton")); + editNode.className = "plain variables-view-edit"; + editNode.addEventListener("mousedown", this._onEdit.bind(this)); + this._title.insertBefore(editNode, this._spacer); + } + + if (ownerView.delete) { + const deleteNode = (this._deleteNode = + this.document.createXULElement("toolbarbutton")); + deleteNode.className = "plain variables-view-delete"; + deleteNode.addEventListener("click", this._onDelete.bind(this)); + this._title.appendChild(deleteNode); + } + + if (ownerView.new) { + const addPropertyNode = (this._addPropertyNode = + this.document.createXULElement("toolbarbutton")); + addPropertyNode.className = "plain variables-view-add-property"; + addPropertyNode.addEventListener( + "mousedown", + this._onAddProperty.bind(this) + ); + 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) { + const nonWritableIcon = this.document.createXULElement("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) { + const frozenLabel = this.document.createXULElement("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) { + const sealedLabel = this.document.createXULElement("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) { + const nonExtensibleLabel = this.document.createXULElement("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() { + this._target.addEventListener("mouseover", this._setTooltips); + }, + + /** + * Sets all tooltips for this variable. + */ + _setTooltips() { + this._target.removeEventListener("mouseover", this._setTooltips); + + const ownerView = this.ownerView; + if (ownerView.preventDescriptorModifiers) { + return; + } + + const tooltip = this.document.createXULElement("tooltip"); + tooltip.id = "tooltip-" + this._idString; + tooltip.setAttribute("orient", "horizontal"); + + const labels = [ + "configurable", + "enumerable", + "writable", + "frozen", + "sealed", + "extensible", + "overridden", + "WebIDL", + ]; + + for (const type of labels) { + const labelElement = this.document.createXULElement("label"); + labelElement.className = type; + labelElement.setAttribute("value", L10N.getStr(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() { + const isDomNode = + this._valueGrip && this._valueGrip.preview.kind === "DOMNode"; + const hasBeenLinked = this._linkedToInspector; + const 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() { + if (!this._isLinkableToInspector()) { + return; + } + + // Listen to value mouseover/click events to highlight and jump + this._valueLabel.addEventListener("mouseover", this.highlightDomNode); + this._valueLabel.addEventListener("mouseout", this.unhighlightDomNode); + + // Add a button to open the node in the inspector + this._openInspectorNode = this.document.createXULElement("toolbarbutton"); + this._openInspectorNode.className = "plain variables-view-open-inspector"; + this._openInspectorNode.addEventListener( + "mousedown", + this.openNodeInInspector + ); + 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(event) { + if (!this.toolbox) { + return Promise.reject(new Error("Toolbox not available")); + } + + event && event.stopPropagation(); + + return async function () { + let nodeFront = this._nodeFront; + if (!nodeFront) { + const inspectorFront = await this.toolbox.target.getFront("inspector"); + nodeFront = await inspectorFront.getNodeFrontFromNodeGrip( + this._valueGrip + ); + } + + if (nodeFront) { + await this.toolbox.selectTool("inspector"); + + const inspectorReady = new Promise(resolve => { + this.toolbox.getPanel("inspector").once("inspector-updated", resolve); + }); + + await this.toolbox.selection.setNodeFront(nodeFront, { + reason: "variables-view", + }); + await inspectorReady; + } + }.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 + */ + async highlightDomNode() { + if (!this.toolbox) { + return; + } + + if (!this._nodeFront) { + const inspectorFront = await this.toolbox.target.getFront("inspector"); + this._nodeFront = await inspectorFront.getNodeFrontFromNodeGrip( + this._valueGrip + ); + } + + await this.toolbox.getHighlighter().highlight(this._nodeFront); + }, + + /** + * Unhighlight a previously highlit node + * @see highlightDomNode + */ + unhighlightDomNode() { + if (!this.toolbox) { + return; + } + + this.toolbox.getHighlighter().unhighlight(); + }, + + /** + * Sets a variable's configurable, enumerable and writable attributes, + * and specifies if it's a 'this', '<exception>', '<return>' or '__proto__' + * reference. + */ + // eslint-disable-next-line complexity + _setAttributes() { + const ownerView = this.ownerView; + if (ownerView.preventDescriptorModifiers) { + return; + } + + const descriptor = this._initialDescriptor; + const target = this._target; + const 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 (this._internalItem && name == "<exception>") { + target.setAttribute("exception", ""); + target.setAttribute("pseudo-item", ""); + } else if (this._internalItem && name == "<return>") { + target.setAttribute("return", ""); + target.setAttribute("pseudo-item", ""); + } else if (name == "__proto__") { + target.setAttribute("proto", ""); + target.setAttribute("pseudo-item", ""); + } + + if (!Object.keys(descriptor).length) { + target.setAttribute("pseudo-item", ""); + } + }, + + /** + * Adds the necessary event listeners for this variable. + */ + _addEventListeners() { + this._name.addEventListener("dblclick", this._activateNameInput); + this._valueLabel.addEventListener("mousedown", this._activateValueInput); + this._title.addEventListener("mousedown", this._onClick); + }, + + /** + * Makes this variable's name editable. + */ + _activateNameInput(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(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() { + // Prevent the variable from being collapsed or expanded. + this.hideArrow(); + + // Hide any nodes that may offer information about the variable. + for (const 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(e) { + if (e.button != 0) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + this._activateValueInput(); + }, + + /** + * The click listener for the delete button. + */ + _onDelete(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(e) { + if ("button" in e && e.button != 0) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + this.expanded = true; + + const item = this.addItem( + " ", + { + value: undefined, + configurable: true, + enumerable: true, + writable: true, + }, + { relaxed: 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. + * @param object aOptions + * Options of the form accepted by Scope.addItem + */ +function Property(aVar, aName, aDescriptor, aOptions) { + Variable.call(this, aVar, aName, aDescriptor, aOptions); +} + +Property.prototype = 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 + "[" + escapeString(this._nameString) + "]"; + return this._symbolicName; + }, + + /** + * @see Variable.absoluteName + * @return string + */ + get absoluteName() { + if (this._absoluteName) { + return this._absoluteName; + } + + this._absoluteName = + this.ownerView.absoluteName + "[" + escapeString(this._nameString) + "]"; + return this._absoluteName; + }, +}); + +/** + * A generator-iterator over the VariablesView, Scopes, Variables and Properties. + */ +VariablesView.prototype[Symbol.iterator] = + Scope.prototype[Symbol.iterator] = + Variable.prototype[Symbol.iterator] = + Property.prototype[Symbol.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 (const [, currItem] of this._currHierarchy) { + // Avoid performing expensive operations. + if (this.commitHierarchyIgnoredItems[currItem._nameString]) { + continue; + } + const overridden = this.isOverridden(currItem); + if (overridden) { + currItem.setOverridden(true); + } + const expanded = !currItem._committed && this.wasExpanded(currItem); + if (expanded) { + currItem.expand(); + } + const 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 = 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; + } + const 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; + } + const 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; + } + const currVariableName = aItem._nameString; + const parentScopes = this.getParentScopesForVariableOrProperty(aItem); + + for (const otherScope of parentScopes) { + for (const [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. + const getter = aDescriptor.get; + const 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. + const grip = aDescriptor.value; + if (typeof grip != "object") { + return true; + } + + // For convenience, undefined, null, Infinity, -Infinity, NaN, -0, and long + // strings are considered types. + const 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. + const getter = aDescriptor.get; + const 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. + const 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. + const grip = aDescriptor.value; + if (typeof grip != "object") { + return !grip; + } + + // For convenience, undefined, null, NaN, and -0 are all considered types. + const 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" }; + } + // fall through + case "object": + if (aValue === null) { + return { type: "null" }; + } + // fall through + case "function": + return { type: "object", class: getObjectClassName(aValue) }; + default: + console.error( + "Failed to provide a grip for value of " + typeof value + ": " + aValue + ); + return null; + } +}; + +// Match the function name from the result of toString() or toSource(). +// +// Examples: +// (function foobar(a, b) { ... +// function foobar2(a) { ... +// function() { ... +const REGEX_MATCH_FUNCTION_NAME = /^\(?function\s+([^(\s]+)\s*\(/; + +/** + * Helper function to deduce the name of the provided function. + * + * @param function function + * The function whose name will be returned. + * @return string + * Function name. + */ +function getFunctionName(func) { + let name = null; + if (func.name) { + name = func.name; + } else { + let desc; + try { + desc = func.getOwnPropertyDescriptor("displayName"); + } catch (ex) { + // Ignore. + } + if (desc && typeof desc.value == "string") { + name = desc.value; + } + } + if (!name) { + try { + const str = (func.toString() || func.toSource()) + ""; + name = (str.match(REGEX_MATCH_FUNCTION_NAME) || [])[1]; + } catch (ex) { + // Ignore. + } + } + return name; +} + +/** + * Get the object class name. For example, the |window| object has the Window + * class name (based on [object Window]). + * + * @param object object + * The object you want to get the class name for. + * @return string + * The object class name. + */ +function getObjectClassName(object) { + if (object === null) { + return "null"; + } + if (object === undefined) { + return "undefined"; + } + + const type = typeof object; + if (type != "object") { + // Grip class names should start with an uppercase letter. + return type.charAt(0).toUpperCase() + type.substr(1); + } + + let className; + + try { + className = ((object + "").match(/^\[object (\S+)\]$/) || [])[1]; + if (!className) { + className = ((object.constructor + "").match(/^\[object (\S+)\]$/) || + [])[1]; + } + if (!className && typeof object.constructor == "function") { + className = getFunctionName(object.constructor); + } + } catch (ex) { + // Ignore. + } + + return className; +} + +/** + * 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: + const stringifier = VariablesView.stringifiers.byType[aGrip.type]; + if (stringifier) { + const 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"; + } + // fall through + 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(aGrip, { noStringQuotes }) { + if (noStringQuotes) { + return aGrip; + } + return '"' + aGrip + '"'; + }, + + longString({ initial }, { noStringQuotes, noEllipsis }) { + const ellipsis = noEllipsis ? "" : ELLIPSIS; + if (noStringQuotes) { + return initial + ellipsis; + } + const result = '"' + initial + '"'; + if (!ellipsis) { + return result; + } + return result.substr(0, result.length - 1) + ellipsis + '"'; + }, + + object(aGrip, aOptions) { + const { preview } = aGrip; + let stringifier; + if (aGrip.class) { + stringifier = VariablesView.stringifiers.byObjectClass[aGrip.class]; + } + if (!stringifier && preview && preview.kind) { + stringifier = VariablesView.stringifiers.byObjectKind[preview.kind]; + } + if (stringifier) { + return stringifier(aGrip, aOptions); + } + return null; + }, + + symbol(aGrip, aOptions) { + const name = aGrip.name || ""; + return "Symbol(" + name + ")"; + }, + + mapEntry(aGrip, { concise }) { + const { + preview: { key, value }, + } = aGrip; + + const keyString = VariablesView.getString(key, { + concise: true, + noStringQuotes: true, + }); + const valueString = VariablesView.getString(value, { concise: true }); + + return keyString + " \u2192 " + valueString; + }, +}; // VariablesView.stringifiers.byType + +VariablesView.stringifiers.byObjectClass = { + 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 + const params = aGrip.parameterNames || ""; + if (!concise) { + return "function " + name + "(" + params + ")"; + } + return (name || "function ") + "(" + params + ")"; + }, + + RegExp({ displayString }) { + return VariablesView.getString(displayString, { noStringQuotes: true }); + }, + + Date({ 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(); + }, + + Number(aGrip) { + const { preview } = aGrip; + if (preview === undefined) { + return null; + } + return ( + aGrip.class + " { " + VariablesView.getString(preview.wrappedValue) + " }" + ); + }, +}; // VariablesView.stringifiers.byObjectClass + +VariablesView.stringifiers.byObjectClass.Boolean = + VariablesView.stringifiers.byObjectClass.Number; + +VariablesView.stringifiers.byObjectKind = { + ArrayLike(aGrip, { concise }) { + const { preview } = aGrip; + if (concise) { + return aGrip.class + "[" + preview.length + "]"; + } + + if (!preview.items) { + return null; + } + + let shown = 0, + lastHole = null; + const result = []; + for (const 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) { + const 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] += ","; + } + + const prefix = aGrip.class == "Array" ? "" : aGrip.class + " "; + return prefix + "[" + result.join(", ") + "]"; + }, + + MapLike(aGrip, { concise }) { + const { preview } = aGrip; + if (concise || !preview.entries) { + const size = + typeof preview.size == "number" ? "[" + preview.size + "]" : ""; + return aGrip.class + size; + } + + const entries = []; + for (const [key, value] of preview.entries) { + const keyString = VariablesView.getString(key, { + concise: true, + noStringQuotes: true, + }); + const valueString = VariablesView.getString(value, { concise: true }); + entries.push(keyString + ": " + valueString); + } + + if (typeof preview.size == "number" && preview.size > entries.length) { + const n = preview.size - entries.length; + entries.push(VariablesView.stringifiers._getNMoreString(n)); + } + + return aGrip.class + " {" + entries.join(", ") + "}"; + }, + + ObjectWithText(aGrip, { concise }) { + if (concise) { + return aGrip.class; + } + + return aGrip.class + " " + VariablesView.getString(aGrip.preview.text); + }, + + ObjectWithURL(aGrip, { concise }) { + let result = aGrip.class; + const url = aGrip.preview.url; + if (!VariablesView.isFalsy({ value: url })) { + result += ` \u2192 ${getSourceNames(url)[concise ? "short" : "long"]}`; + } + return result; + }, + + // Stringifier for any kind of object. + Object(aGrip, { concise }) { + if (concise) { + return aGrip.class; + } + + const { preview } = aGrip; + const props = []; + + if (aGrip.class == "Promise" && aGrip.promiseState) { + const { state, value, reason } = aGrip.promiseState; + props.push("<state>: " + VariablesView.getString(state)); + if (state == "fulfilled") { + props.push( + "<value>: " + VariablesView.getString(value, { concise: true }) + ); + } else if (state == "rejected") { + props.push( + "<reason>: " + VariablesView.getString(reason, { concise: true }) + ); + } + } + + for (const key of Object.keys(preview.ownProperties || {})) { + const 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 (const key of Object.keys(preview.safeGetterValues || {})) { + const value = preview.safeGetterValues[key]; + const valueString = VariablesView.getString(value.getterValue, { + concise: true, + }); + props.push(key + ": " + valueString); + } + + if (!props.length) { + return null; + } + + if (preview.ownPropertiesLength) { + const previewLength = Object.keys(preview.ownProperties).length; + const diff = preview.ownPropertiesLength - previewLength; + if (diff > 0) { + props.push(VariablesView.stringifiers._getNMoreString(diff)); + } + } + + const prefix = aGrip.class != "Object" ? aGrip.class + " " : ""; + return prefix + "{" + props.join(", ") + "}"; + }, // Object + + Error(aGrip, { concise }) { + const { preview } = aGrip; + const 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" + + L10N.getStr("variablesViewErrorStacktrace") + + "\n" + + preview.stack; + } + + return msg; + }, + + DOMException(aGrip, { concise }) { + const { 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(aGrip, { concise }) { + const { 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("-"); + } + + const props = []; + if (preview.target) { + const target = VariablesView.getString(preview.target, { concise: true }); + props.push("target: " + target); + } + + for (const prop in preview.properties) { + const value = preview.properties[prop]; + props.push( + prop + ": " + VariablesView.getString(value, { concise: true }) + ); + } + + return result + " {" + props.join(", ") + "}"; + }, // DOMEvent + + DOMNode(aGrip, { concise }) { + const { preview } = aGrip; + + switch (preview.nodeType) { + case nodeConstants.DOCUMENT_NODE: { + let result = aGrip.class; + if (preview.location) { + result += ` \u2192 ${ + getSourceNames(preview.location)[concise ? "short" : "long"] + }`; + } + + return result; + } + + case nodeConstants.ATTRIBUTE_NODE: { + const value = VariablesView.getString(preview.value, { + noStringQuotes: true, + }); + return preview.nodeName + '="' + escapeHTML(value) + '"'; + } + + case nodeConstants.TEXT_NODE: + return ( + preview.nodeName + " " + VariablesView.getString(preview.textContent) + ); + + case nodeConstants.COMMENT_NODE: { + const comment = VariablesView.getString(preview.textContent, { + noStringQuotes: true, + }); + return "<!--" + comment + "-->"; + } + + case nodeConstants.DOCUMENT_FRAGMENT_NODE: { + if (concise || !preview.childNodes) { + return aGrip.class + "[" + preview.childNodesLength + "]"; + } + const nodes = []; + for (const node of preview.childNodes) { + nodes.push(VariablesView.getString(node)); + } + if (nodes.length < preview.childNodesLength) { + const n = preview.childNodesLength - nodes.length; + nodes.push(VariablesView.stringifiers._getNMoreString(n)); + } + return aGrip.class + " [" + nodes.join(", ") + "]"; + } + + case nodeConstants.ELEMENT_NODE: { + const attrs = preview.attributes; + if (!concise) { + let n = 0, + result = "<" + preview.nodeName; + for (const name in attrs) { + const value = VariablesView.getString(attrs[name], { + noStringQuotes: true, + }); + result += " " + name + '="' + escapeHTML(value) + '"'; + n++; + } + if (preview.attributesLength > n) { + result += " " + 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) { + const str = L10N.getStr("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. + */ +var generateId = (function () { + let count = 0; + return function (aName = "") { + return aName.toLowerCase().trim().replace(/\s+/g, "-") + ++count; + }; +})(); + +/** + * Quote and escape a string. The result will be another string containing an + * ECMAScript StringLiteral which will produce the original one when evaluated + * by `eval` or similar. + * + * @param string aString + * An optional string to be escaped. If no string is passed, the function + * returns an empty string. + * @return string + */ +function escapeString(aString) { + if (typeof aString !== "string") { + return ""; + } + // U+2028 and U+2029 are allowed in JSON but not in ECMAScript string literals. + return JSON.stringify(aString) + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +/** + * 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 + */ +export function escapeHTML(aString) { + return aString + .replace(/&/g, "&") + .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) { + const 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(e) { + if (!this.shouldActivate) { + this._onCleanup && this._onCleanup(); + return; + } + + const { label } = this; + const 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. + const input = (this._input = this._variable.document.createElementNS( + HTML_NS, + "input" + )); + input.className = this.className; + input.setAttribute("value", initialString); + + // Replace the specified label with a textbox input element. + label.parentNode.replaceChild(input, label); + input.scrollIntoView({ block: "nearest" }); + 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._onKeydown = this._onKeydown.bind(this); + this._onBlur = this._onBlur.bind(this); + input.addEventListener("keydown", this._onKeydown); + 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() { + this._input.removeEventListener("keydown", this._onKeydown); + this._input.removeEventListener("blur", this.deactivate); + this._input.parentNode.replaceChild(this.label, this._input); + this._input = null; + + const scrollbox = this._variable._variablesView._list; + scrollbox.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() { + const initial = this.label.getAttribute("value"); + const 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() { + this._save(); + }, + + /** + * Called when escape is pressed, indicating a cancelling of editing without + * saving. + */ + _reset() { + this.deactivate(); + this._variable.focus(); + }, + + /** + * Event handler for when the input loses focus. + */ + _onBlur() { + this.deactivate(); + }, + + /** + * Event handler for when the input receives a key press. + */ + _onKeydown(e) { + e.stopPropagation(); + + switch (e.keyCode) { + case KeyCodes.DOM_VK_TAB: + this._next(); + break; + case KeyCodes.DOM_VK_RETURN: + this._save(); + break; + case KeyCodes.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 = 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 = 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 = extend(EditableName.prototype, { + _reset(e) { + // Hide the Variable or Property if the user presses escape. + this._variable.remove(); + this.deactivate(); + }, + + _next(e) { + // Override _next so as to set both key and value at the same time. + const key = this._input.value; + this.label.setAttribute("value", key); + + const valueEditable = EditableValue.create(this._variable, { + onSave: aValue => { + this._onSave([key, aValue]); + }, + }); + valueEditable._reset = () => { + this._variable.remove(); + valueEditable.deactivate(); + }; + }, + + _save(e) { + // Both _save and _next activate the value edit box. + this._next(e); + }, +}); |