diff options
Diffstat (limited to 'devtools/client/shared/components/object-inspector')
14 files changed, 2667 insertions, 0 deletions
diff --git a/devtools/client/shared/components/object-inspector/actions.js b/devtools/client/shared/components/object-inspector/actions.js new file mode 100644 index 0000000000..370f1b161a --- /dev/null +++ b/devtools/client/shared/components/object-inspector/actions.js @@ -0,0 +1,225 @@ +/* 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/>. */ + +const { loadItemProperties } = require("resource://devtools/client/shared/components/object-inspector/utils/load-properties.js"); +const { + getPathExpression, + getParentFront, + getParentGripValue, + getValue, + nodeIsBucket, + getFront, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); +const { getLoadedProperties, getWatchpoints } = require("resource://devtools/client/shared/components/object-inspector/reducer.js"); + +/** + * This action is responsible for expanding a given node, which also means that + * it will call the action responsible to fetch properties. + */ +function nodeExpand(node, actor) { + return async ({ dispatch }) => { + dispatch({ type: "NODE_EXPAND", data: { node } }); + dispatch(nodeLoadProperties(node, actor)); + }; +} + +function nodeCollapse(node) { + return { + type: "NODE_COLLAPSE", + data: { node }, + }; +} + +/* + * This action checks if we need to fetch properties, entries, prototype and + * symbols for a given node. If we do, it will call the appropriate ObjectFront + * functions. + */ +function nodeLoadProperties(node, actor) { + return async ({ dispatch, client, getState }) => { + const state = getState(); + const loadedProperties = getLoadedProperties(state); + if (loadedProperties.has(node.path)) { + return; + } + + try { + const properties = await loadItemProperties( + node, + client, + loadedProperties + ); + + // If the client does not have a releaseActor function, it means the actors are + // handled directly by the consumer, so we don't need to track them. + if (!client || !client.releaseActor) { + actor = null; + } + + dispatch(nodePropertiesLoaded(node, actor, properties)); + } catch (e) { + console.error(e); + } + }; +} + +function nodePropertiesLoaded(node, actor, properties) { + return { + type: "NODE_PROPERTIES_LOADED", + data: { node, actor, properties }, + }; +} + +/* + * This action adds a property watchpoint to an object + */ +function addWatchpoint(item, watchpoint) { + return async function({ dispatch, client }) { + const { parent, name } = item; + let object = getValue(parent); + + if (nodeIsBucket(parent)) { + object = getValue(parent.parent); + } + + if (!object) { + return; + } + + const path = parent.path; + const property = name; + const label = getPathExpression(item); + const actor = object.actor; + + await client.addWatchpoint(object, property, label, watchpoint); + + dispatch({ + type: "SET_WATCHPOINT", + data: { path, watchpoint, property, actor }, + }); + }; +} + +/* + * This action removes a property watchpoint from an object + */ +function removeWatchpoint(item) { + return async function({ dispatch, client }) { + const { parent, name } = item; + let object = getValue(parent); + + if (nodeIsBucket(parent)) { + object = getValue(parent.parent); + } + + const property = name; + const path = parent.path; + const actor = object.actor; + + await client.removeWatchpoint(object, property); + + dispatch({ + type: "REMOVE_WATCHPOINT", + data: { path, property, actor }, + }); + }; +} + +function getActorIDs(roots) { + if (!roots) { + return [] + } + + const actorIds = []; + for (const root of roots) { + const front = getFront(root); + if (front?.actorID) { + actorIds.push(front.actorID); + } + } + + return actorIds; +} + +function closeObjectInspector(roots) { + return ({ client }) => { + releaseActors(client, roots); + }; +} + +/* + * This action is dispatched when the `roots` prop, provided by a consumer of + * the ObjectInspector (inspector, console, …), is modified. It will clean the + * internal state properties (expandedPaths, loadedProperties, …) and release + * the actors consumed with the previous roots. + * It takes a props argument which reflects what is passed by the upper-level + * consumer. + */ +function rootsChanged(roots, oldRoots) { + return ({ dispatch, client }) => { + releaseActors(client, oldRoots, roots); + dispatch({ + type: "ROOTS_CHANGED", + data: roots, + }); + }; +} + +/** + * Release any actors we don't need anymore + * + * @param {Object} client: Object with a `releaseActor` method + * @param {Array} oldRoots: The roots in which we want to cleanup now-unused actors + * @param {Array} newRoots: The current roots (might have item that are also in oldRoots) + */ +async function releaseActors(client, oldRoots, newRoots = []) { + if (!client?.releaseActor ) { + return; + } + + let actorIdsToRelease = getActorIDs(oldRoots); + if (newRoots.length) { + const newActorIds = getActorIDs(newRoots); + actorIdsToRelease = actorIdsToRelease.filter(id => !newActorIds.includes(id)); + } + + if (!actorIdsToRelease.length) { + return; + } + await Promise.all(actorIdsToRelease.map(client.releaseActor)); +} + +function invokeGetter(node, receiverId) { + return async ({ dispatch, client, getState }) => { + try { + const objectFront = + getParentFront(node) || + client.createObjectFront(getParentGripValue(node)); + const getterName = node.propertyName || node.name; + + const result = await objectFront.getPropertyValue(getterName, receiverId); + dispatch({ + type: "GETTER_INVOKED", + data: { + node, + result, + }, + }); + } catch (e) { + console.error(e); + } + }; +} + +module.exports = { + closeObjectInspector, + invokeGetter, + nodeExpand, + nodeCollapse, + nodeLoadProperties, + nodePropertiesLoaded, + rootsChanged, + addWatchpoint, + removeWatchpoint, +}; diff --git a/devtools/client/shared/components/object-inspector/components/ObjectInspector.css b/devtools/client/shared/components/object-inspector/components/ObjectInspector.css new file mode 100644 index 0000000000..d7b9b3ffa7 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/components/ObjectInspector.css @@ -0,0 +1,99 @@ +/* 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/. */ + +.tree.object-inspector .node.object-node { + display: inline-block; +} + +.tree.object-inspector .object-label, +.tree.object-inspector .object-label * { + color: var(--theme-highlight-blue); +} + +.tree.object-inspector .node .unavailable { + color: var(--theme-comment); +} + +.tree.object-inspector .lessen, +.tree.object-inspector .lessen *, +.tree.object-inspector .lessen .object-label, +.tree.object-inspector .lessen .object-label * { + color: var(--theme-comment); +} + +.tree.object-inspector .block .object-label, +.tree.object-inspector .block .object-label * { + color: var(--theme-body-color); +} + +.tree.object-inspector .block .object-label::before { + content: "☲"; + font-size: 1.1em; + display: inline; + padding-inline-end: 2px; + line-height: 14px; +} + +.object-inspector .object-delimiter { + color: var(--theme-comment); + white-space: pre-wrap; +} + +.object-inspector .tree-node .arrow { + display: inline-block; + vertical-align: middle; + margin-inline-start: -1px; +} + +/* Focused styles */ +.tree.object-inspector .tree-node.focused * { + color: inherit; +} + +.tree-node.focused button.open-inspector { + fill: currentColor; +} + +.tree-node.focused button.invoke-getter { + background-color: currentColor; +} + +button[class*="remove-watchpoint-"] { + background: url("chrome://devtools/content/debugger/images/webconsole-logpoint.svg") + no-repeat; + display: inline-block; + vertical-align: top; + height: 13px; + width: 15px; + margin: 1px 4px 0px 20px; + padding: 0; + border: none; + -moz-context-properties: fill, stroke; + cursor: pointer; +} + +button.remove-watchpoint-set { + fill: var(--breakpoint-fill); + stroke: var(--breakpoint-fill); +} + +button.remove-watchpoint-get { + fill: var(--purple-60); + stroke: var(--purple-60); +} + +button.remove-watchpoint-getorset { + fill: var(--yellow-60); + stroke: var(--yellow-60); +} + +.tree-node.focused button[class*="remove-watchpoint-"] { + stroke: white; +} + +/* Don't display the light grey background we have on button hover */ +.theme-dark button[class*="remove-watchpoint-"]:hover, +.theme-light button[class*="remove-watchpoint-"]:hover { + background-color: transparent; +} diff --git a/devtools/client/shared/components/object-inspector/components/ObjectInspector.js b/devtools/client/shared/components/object-inspector/components/ObjectInspector.js new file mode 100644 index 0000000000..a8af00d32a --- /dev/null +++ b/devtools/client/shared/components/object-inspector/components/ObjectInspector.js @@ -0,0 +1,371 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +const { + Component, + createFactory, + createElement, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + connect, + Provider, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +loader.lazyRequireGetter( + this, + "createStore", + "resource://devtools/client/shared/redux/create-store.js" +); + +const actions = require("resource://devtools/client/shared/components/object-inspector/actions.js"); +const { + getExpandedPaths, + getLoadedProperties, + getEvaluations, + default: reducer, +} = require("resource://devtools/client/shared/components/object-inspector/reducer.js"); + +const Tree = createFactory(require("resource://devtools/client/shared/components/Tree.js")); + +const ObjectInspectorItem = createFactory( + require("resource://devtools/client/shared/components/object-inspector/components/ObjectInspectorItem.js") +); + +const Utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); +const { renderRep, shouldRenderRootsInReps } = Utils; +const { + getChildrenWithEvaluations, + getActor, + getEvaluatedItem, + getParent, + getValue, + nodeIsPrimitive, + nodeHasGetter, + nodeHasSetter, +} = Utils.node; + +// This implements a component that renders an interactive inspector +// for looking at JavaScript objects. It expects descriptions of +// objects from the protocol, and will dynamically fetch children +// properties as objects are expanded. +// +// If you want to inspect a single object, pass the name and the +// protocol descriptor of it: +// +// ObjectInspector({ +// name: "foo", +// desc: { writable: true, ..., { value: { actor: "1", ... }}}, +// ... +// }) +// +// If you want multiple top-level objects (like scopes), you can pass +// an array of manually constructed nodes as `roots`: +// +// ObjectInspector({ +// roots: [{ name: ... }, ...], +// ... +// }); + +// There are 3 types of nodes: a simple node with a children array, an +// object that has properties that should be children when they are +// fetched, and a primitive value that should be displayed with no +// children. + +class ObjectInspector extends Component { + static defaultProps = { + autoReleaseObjectActors: true + }; + constructor(props) { + super(); + this.cachedNodes = new Map(); + + const self = this; + + self.getItemChildren = this.getItemChildren.bind(this); + self.isNodeExpandable = this.isNodeExpandable.bind(this); + self.setExpanded = this.setExpanded.bind(this); + self.focusItem = this.focusItem.bind(this); + self.activateItem = this.activateItem.bind(this); + self.getRoots = this.getRoots.bind(this); + self.getNodeKey = this.getNodeKey.bind(this); + self.shouldItemUpdate = this.shouldItemUpdate.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillMount() { + this.roots = this.props.roots; + this.focusedItem = this.props.focusedItem; + this.activeItem = this.props.activeItem; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate(nextProps) { + this.removeOutdatedNodesFromCache(nextProps); + + if (this.roots !== nextProps.roots) { + // Since the roots changed, we assume the properties did as well, + // so we need to cleanup the component internal state. + const oldRoots = this.roots; + this.roots = nextProps.roots; + this.focusedItem = nextProps.focusedItem; + this.activeItem = nextProps.activeItem; + if (this.props.rootsChanged) { + this.props.rootsChanged(this.roots, oldRoots); + } + } + } + + removeOutdatedNodesFromCache(nextProps) { + // When the roots changes, we can wipe out everything. + if (this.roots !== nextProps.roots) { + this.cachedNodes.clear(); + return; + } + + for (const [path, properties] of nextProps.loadedProperties) { + if (properties !== this.props.loadedProperties.get(path)) { + this.cachedNodes.delete(path); + } + } + + // If there are new evaluations, we want to remove the existing cached + // nodes from the cache. + if (nextProps.evaluations > this.props.evaluations) { + for (const key of nextProps.evaluations.keys()) { + if (!this.props.evaluations.has(key)) { + this.cachedNodes.delete(key); + } + } + } + } + + shouldComponentUpdate(nextProps) { + const { expandedPaths, loadedProperties, evaluations } = this.props; + + // We should update if: + // - there are new loaded properties + // - OR there are new evaluations + // - OR the expanded paths number changed, and all of them have properties + // loaded + // - OR the expanded paths number did not changed, but old and new sets + // differ + // - OR the focused node changed. + // - OR the active node changed. + return ( + loadedProperties !== nextProps.loadedProperties || + loadedProperties.size !== nextProps.loadedProperties.size || + evaluations.size !== nextProps.evaluations.size || + (expandedPaths.size !== nextProps.expandedPaths.size && + [...nextProps.expandedPaths].every(path => + nextProps.loadedProperties.has(path) + )) || + (expandedPaths.size === nextProps.expandedPaths.size && + [...nextProps.expandedPaths].some(key => !expandedPaths.has(key))) || + this.focusedItem !== nextProps.focusedItem || + this.activeItem !== nextProps.activeItem || + this.roots !== nextProps.roots + ); + } + + componentWillUnmount() { + if (this.props.autoReleaseObjectActors){ + this.props.closeObjectInspector(this.props.roots); + } + } + + getItemChildren(item) { + const { loadedProperties, evaluations } = this.props; + const { cachedNodes } = this; + + return getChildrenWithEvaluations({ + evaluations, + loadedProperties, + cachedNodes, + item, + }); + } + + getRoots() { + const { evaluations, roots } = this.props; + const length = roots.length; + + for (let i = 0; i < length; i++) { + let rootItem = roots[i]; + + if (evaluations.has(rootItem.path)) { + roots[i] = getEvaluatedItem(rootItem, evaluations); + } + } + + return roots; + } + + getNodeKey(item) { + return item.path && typeof item.path.toString === "function" + ? item.path.toString() + : JSON.stringify(item); + } + + isNodeExpandable(item) { + if ( + nodeIsPrimitive(item) || + item.contents?.value?.useCustomFormatter + ) { + return false; + } + + if (nodeHasSetter(item) || nodeHasGetter(item)) { + return false; + } + + return true; + } + + setExpanded(item, expand) { + if (!this.isNodeExpandable(item)) { + return; + } + + const { + nodeExpand, + nodeCollapse, + recordTelemetryEvent, + setExpanded, + roots, + } = this.props; + + if (expand === true) { + const actor = getActor(item, roots); + nodeExpand(item, actor); + if (recordTelemetryEvent) { + recordTelemetryEvent("object_expanded"); + } + } else { + nodeCollapse(item); + } + + if (setExpanded) { + setExpanded(item, expand); + } + } + + focusItem(item) { + const { focusable = true, onFocus } = this.props; + + if (focusable && this.focusedItem !== item) { + this.focusedItem = item; + this.forceUpdate(); + + if (onFocus) { + onFocus(item); + } + } + } + + activateItem(item) { + const { focusable = true, onActivate } = this.props; + + if (focusable && this.activeItem !== item) { + this.activeItem = item; + this.forceUpdate(); + + if (onActivate) { + onActivate(item); + } + } + } + + shouldItemUpdate(prevItem, nextItem) { + const value = getValue(nextItem); + // Long string should always update because fullText loading will not + // trigger item re-render. + return value && value.type === "longString"; + } + + render() { + const { + autoExpandAll = true, + autoExpandDepth = 1, + initiallyExpanded, + focusable = true, + disableWrap = false, + expandedPaths, + inline, + } = this.props; + + const classNames = ["object-inspector"]; + if (inline) { + classNames.push("inline"); + } + if (disableWrap) { + classNames.push("nowrap"); + } + + return Tree({ + className: classNames.join(" "), + + autoExpandAll, + autoExpandDepth, + initiallyExpanded, + isExpanded: item => expandedPaths && expandedPaths.has(item.path), + isExpandable: this.isNodeExpandable, + focused: this.focusedItem, + active: this.activeItem, + + getRoots: this.getRoots, + getParent, + getChildren: this.getItemChildren, + getKey: this.getNodeKey, + + onExpand: item => this.setExpanded(item, true), + onCollapse: item => this.setExpanded(item, false), + onFocus: focusable ? this.focusItem : null, + onActivate: focusable ? this.activateItem : null, + + shouldItemUpdate: this.shouldItemUpdate, + renderItem: (item, depth, focused, arrow, expanded) => + ObjectInspectorItem({ + ...this.props, + item, + depth, + focused, + arrow, + expanded, + setExpanded: this.setExpanded, + }), + }); + } +} + +function mapStateToProps(state, props) { + return { + expandedPaths: getExpandedPaths(state), + loadedProperties: getLoadedProperties(state), + evaluations: getEvaluations(state), + }; +} + +const OI = connect(mapStateToProps, actions)(ObjectInspector); + +module.exports = props => { + const { roots, standalone = false } = props; + + if (roots.length == 0) { + return null; + } + + if (shouldRenderRootsInReps(roots, props)) { + return renderRep(roots[0], props); + } + + const oiElement = createElement(OI, props); + + if (!standalone) { + return oiElement; + } + + const store = createStore(reducer); + return createElement(Provider, { store }, oiElement); +}; diff --git a/devtools/client/shared/components/object-inspector/components/ObjectInspectorItem.js b/devtools/client/shared/components/object-inspector/components/ObjectInspectorItem.js new file mode 100644 index 0000000000..534ac0e13b --- /dev/null +++ b/devtools/client/shared/components/object-inspector/components/ObjectInspectorItem.js @@ -0,0 +1,285 @@ +/* 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/>. */ + +const { Component } = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const isMacOS = Services.appinfo.OS === "Darwin"; + +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); + +const Utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); + +const { + getValue, + nodeHasAccessors, + nodeHasProperties, + nodeIsBlock, + nodeIsDefaultProperties, + nodeIsFunction, + nodeIsGetter, + nodeIsMapEntry, + nodeIsMissingArguments, + nodeIsOptimizedOut, + nodeIsPrimitive, + nodeIsPrototype, + nodeIsSetter, + nodeIsUninitializedBinding, + nodeIsUnmappedBinding, + nodeIsUnscopedBinding, + nodeIsWindow, + nodeIsLongString, + nodeHasFullText, + nodeHasGetter, + getNonPrototypeParentGripValue, +} = Utils.node; + +class ObjectInspectorItem extends Component { + static get defaultProps() { + return { + onContextMenu: () => {}, + renderItemActions: () => null, + }; + } + + // eslint-disable-next-line complexity + getLabelAndValue() { + const { item, depth, expanded, mode } = this.props; + + const label = item.name; + const isPrimitive = nodeIsPrimitive(item); + + if (nodeIsOptimizedOut(item)) { + return { + label, + value: dom.span({ className: "unavailable" }, "(optimized away)"), + }; + } + + if (nodeIsUninitializedBinding(item)) { + return { + label, + value: dom.span({ className: "unavailable" }, "(uninitialized)"), + }; + } + + if (nodeIsUnmappedBinding(item)) { + return { + label, + value: dom.span({ className: "unavailable" }, "(unmapped)"), + }; + } + + if (nodeIsUnscopedBinding(item)) { + return { + label, + value: dom.span({ className: "unavailable" }, "(unscoped)"), + }; + } + + const itemValue = getValue(item); + const unavailable = + isPrimitive && + itemValue && + itemValue.hasOwnProperty && + itemValue.hasOwnProperty("unavailable"); + + if (nodeIsMissingArguments(item) || unavailable) { + return { + label, + value: dom.span({ className: "unavailable" }, "(unavailable)"), + }; + } + + if ( + nodeIsFunction(item) && + !nodeIsGetter(item) && + !nodeIsSetter(item) && + (mode === MODE.TINY || !mode) + ) { + return { + label: Utils.renderRep(item, { + ...this.props, + functionName: label, + }), + }; + } + + if ( + nodeHasProperties(item) || + nodeHasAccessors(item) || + nodeIsMapEntry(item) || + nodeIsLongString(item) || + isPrimitive + ) { + const repProps = { ...this.props }; + if (depth > 0) { + repProps.mode = mode === MODE.LONG ? MODE.SHORT : MODE.TINY; + } + + + if (nodeIsLongString(item)) { + repProps.member = { + open: nodeHasFullText(item) && expanded, + }; + } + + if (nodeHasGetter(item)) { + const receiverGrip = getNonPrototypeParentGripValue(item); + if (receiverGrip) { + Object.assign(repProps, { + onInvokeGetterButtonClick: () => + this.props.invokeGetter(item, receiverGrip.actor), + }); + } + } + + return { + label, + value: Utils.renderRep(item, repProps), + }; + } + + return { + label, + }; + } + + getTreeItemProps() { + const { + item, + depth, + focused, + expanded, + onCmdCtrlClick, + onDoubleClick, + dimTopLevelWindow, + onContextMenu, + } = this.props; + + const classNames = ["node", "object-node"]; + if (focused) { + classNames.push("focused"); + } + + if (nodeIsBlock(item)) { + classNames.push("block"); + } + + if ( + !expanded && + (nodeIsDefaultProperties(item) || + nodeIsPrototype(item) || + nodeIsGetter(item) || + nodeIsSetter(item) || + (dimTopLevelWindow === true && nodeIsWindow(item) && depth === 0)) + ) { + classNames.push("lessen"); + } + + const parentElementProps = { + className: classNames.join(" "), + onClick: e => { + if ( + onCmdCtrlClick && + ((isMacOS && e.metaKey) || (!isMacOS && e.ctrlKey)) + ) { + onCmdCtrlClick(item, { + depth, + event: e, + focused, + expanded, + }); + e.stopPropagation(); + return; + } + + // If this click happened because the user selected some text, bail out. + // Note that if the user selected some text before and then clicks here, + // the previously selected text will be first unselected, unless the + // user clicked on the arrow itself. Indeed because the arrow is an + // image, clicking on it does not remove any existing text selection. + // So we need to also check if the arrow was clicked. + if ( + e.target && + Utils.selection.documentHasSelection(e.target.ownerDocument) && + !(e.target.matches && e.target.matches(".arrow")) + ) { + e.stopPropagation(); + } + }, + onContextMenu: e => onContextMenu(e, item), + }; + + if (onDoubleClick) { + parentElementProps.onDoubleClick = e => { + e.stopPropagation(); + onDoubleClick(item, { + depth, + focused, + expanded, + }); + }; + } + + return parentElementProps; + } + + renderLabel(label) { + if (label === null || typeof label === "undefined") { + return null; + } + + const { item, depth, focused, expanded, onLabelClick } = this.props; + return dom.span( + { + className: "object-label", + onClick: onLabelClick + ? event => { + event.stopPropagation(); + + // If the user selected text, bail out. + if ( + Utils.selection.documentHasSelection(event.target.ownerDocument) + ) { + return; + } + + onLabelClick(item, { + depth, + focused, + expanded, + setExpanded: this.props.setExpanded, + }); + } + : undefined, + }, + label + ); + } + + render() { + const { arrow, renderItemActions, item } = this.props; + + const { label, value } = this.getLabelAndValue(); + const labelElement = this.renderLabel(label); + const delimiter = + value && labelElement + ? dom.span({ className: "object-delimiter" }, ": ") + : null; + + return dom.div( + this.getTreeItemProps(), + arrow, + labelElement, + delimiter, + value, + renderItemActions(item) + ); + } +} + +module.exports = ObjectInspectorItem; diff --git a/devtools/client/shared/components/object-inspector/components/moz.build b/devtools/client/shared/components/object-inspector/components/moz.build new file mode 100644 index 0000000000..a1744891f2 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/components/moz.build @@ -0,0 +1,10 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "ObjectInspector.js", + "ObjectInspectorItem.js", +) diff --git a/devtools/client/shared/components/object-inspector/index.js b/devtools/client/shared/components/object-inspector/index.js new file mode 100644 index 0000000000..34e4d30086 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/index.js @@ -0,0 +1,10 @@ +/* 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/>. */ + +const ObjectInspector = require("resource://devtools/client/shared/components/object-inspector/components/ObjectInspector.js"); +const utils = require("resource://devtools/client/shared/components/object-inspector/utils/index.js"); +const reducer = require("resource://devtools/client/shared/components/object-inspector/reducer.js"); +const actions = require("resource://devtools/client/shared/components/object-inspector/actions.js"); + +module.exports = { ObjectInspector, utils, actions, reducer }; diff --git a/devtools/client/shared/components/object-inspector/moz.build b/devtools/client/shared/components/object-inspector/moz.build new file mode 100644 index 0000000000..14f9c285ba --- /dev/null +++ b/devtools/client/shared/components/object-inspector/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DIRS += [ + "components", + "utils", +] + +DevToolsModules( + "actions.js", + "index.js", + "reducer.js", +) diff --git a/devtools/client/shared/components/object-inspector/reducer.js b/devtools/client/shared/components/object-inspector/reducer.js new file mode 100644 index 0000000000..aa8af2b529 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/reducer.js @@ -0,0 +1,147 @@ +/* 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/>. */ + +function initialOIState(overrides) { + return { + expandedPaths: new Set(), + loadedProperties: new Map(), + evaluations: new Map(), + watchpoints: new Map(), + ...overrides, + }; +} + +function reducer(state = initialOIState(), action = {}) { + const { type, data } = action; + + const cloneState = overrides => ({ ...state, ...overrides }); + + if (type === "NODE_EXPAND") { + return cloneState({ + expandedPaths: new Set(state.expandedPaths).add(data.node.path), + }); + } + + if (type === "NODE_COLLAPSE") { + const expandedPaths = new Set(state.expandedPaths); + expandedPaths.delete(data.node.path); + return cloneState({ expandedPaths }); + } + + if (type == "SET_WATCHPOINT") { + const { watchpoint, property, path } = data; + const obj = state.loadedProperties.get(path); + + return cloneState({ + loadedProperties: new Map(state.loadedProperties).set( + path, + updateObject(obj, property, watchpoint) + ), + watchpoints: new Map(state.watchpoints).set(data.actor, data.watchpoint), + }); + } + + if (type === "REMOVE_WATCHPOINT") { + const { path, property, actor } = data; + const obj = state.loadedProperties.get(path); + const watchpoints = new Map(state.watchpoints); + watchpoints.delete(actor); + + return cloneState({ + loadedProperties: new Map(state.loadedProperties).set( + path, + updateObject(obj, property, null) + ), + watchpoints: watchpoints, + }); + } + + if (type === "NODE_PROPERTIES_LOADED") { + return cloneState({ + loadedProperties: new Map(state.loadedProperties).set( + data.node.path, + action.data.properties + ), + }); + } + + if (type === "ROOTS_CHANGED") { + return cloneState(); + } + + if (type === "GETTER_INVOKED") { + return cloneState({ + evaluations: new Map(state.evaluations).set(data.node.path, { + getterValue: + data.result && + data.result.value && + (data.result.value.throw || data.result.value.return), + }), + }); + } + + // NOTE: we clear the state on resume because otherwise the scopes pane + // would be out of date. Bug 1514760 + if (type === "RESUME" || type == "NAVIGATE") { + return initialOIState({ watchpoints: state.watchpoints }); + } + + return state; +} + +function updateObject(obj, property, watchpoint) { + return { + ...obj, + ownProperties: { + ...obj.ownProperties, + [property]: { + ...obj.ownProperties[property], + watchpoint, + }, + }, + }; +} + +function getObjectInspectorState(state) { + return state.objectInspector || state; +} + +function getExpandedPaths(state) { + return getObjectInspectorState(state).expandedPaths; +} + +function getExpandedPathKeys(state) { + return [...getExpandedPaths(state).keys()]; +} + +function getWatchpoints(state) { + return getObjectInspectorState(state).watchpoints; +} + +function getLoadedProperties(state) { + return getObjectInspectorState(state).loadedProperties; +} + +function getLoadedPropertyKeys(state) { + return [...getLoadedProperties(state).keys()]; +} + +function getEvaluations(state) { + return getObjectInspectorState(state).evaluations; +} + +const selectors = { + getWatchpoints, + getEvaluations, + getExpandedPathKeys, + getExpandedPaths, + getLoadedProperties, + getLoadedPropertyKeys, +}; + +Object.defineProperty(module.exports, "__esModule", { + value: true, +}); +module.exports = { ...selectors, initialOIState }; +module.exports.default = reducer; diff --git a/devtools/client/shared/components/object-inspector/utils/client.js b/devtools/client/shared/components/object-inspector/utils/client.js new file mode 100644 index 0000000000..eaa42be05a --- /dev/null +++ b/devtools/client/shared/components/object-inspector/utils/client.js @@ -0,0 +1,124 @@ +/* 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/>. */ + +const { + getValue, + nodeHasFullText, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); + +async function enumIndexedProperties(objectFront, start, end) { + try { + const iterator = await objectFront.enumProperties({ + ignoreNonIndexedProperties: true, + }); + const response = await iteratorSlice(iterator, start, end); + return response; + } catch (e) { + console.error("Error in enumIndexedProperties", e); + return {}; + } +} + +async function enumNonIndexedProperties(objectFront, start, end) { + try { + const iterator = await objectFront.enumProperties({ + ignoreIndexedProperties: true, + }); + const response = await iteratorSlice(iterator, start, end); + return response; + } catch (e) { + console.error("Error in enumNonIndexedProperties", e); + return {}; + } +} + +async function enumEntries(objectFront, start, end) { + try { + const iterator = await objectFront.enumEntries(); + const response = await iteratorSlice(iterator, start, end); + return response; + } catch (e) { + console.error("Error in enumEntries", e); + return {}; + } +} + +async function enumSymbols(objectFront, start, end) { + try { + const iterator = await objectFront.enumSymbols(); + const response = await iteratorSlice(iterator, start, end); + return response; + } catch (e) { + console.error("Error in enumSymbols", e); + return {}; + } +} + +async function enumPrivateProperties(objectFront, start, end) { + try { + const iterator = await objectFront.enumPrivateProperties(); + const response = await iteratorSlice(iterator, start, end); + return response; + } catch (e) { + console.error("Error in enumPrivateProperties", e); + return {}; + } +} + +async function getPrototype(objectFront) { + if (typeof objectFront.getPrototype !== "function") { + console.error("objectFront.getPrototype is not a function"); + return Promise.resolve({}); + } + return objectFront.getPrototype(); +} + +async function getFullText(longStringFront, item) { + const { initial, fullText, length } = getValue(item); + // Return fullText property if it exists so that it can be added to the + // loadedProperties map. + if (nodeHasFullText(item)) { + return { fullText }; + } + + try { + const substring = await longStringFront.substring(initial.length, length); + return { + fullText: initial + substring, + }; + } catch (e) { + console.error("LongStringFront.substring", e); + throw e; + } +} + +async function getPromiseState(objectFront) { + return objectFront.getPromiseState(); +} + +async function getProxySlots(objectFront) { + return objectFront.getProxySlots(); +} + +function iteratorSlice(iterator, start, end) { + start = start || 0; + const count = end ? end - start + 1 : iterator.count; + + if (count === 0) { + return Promise.resolve({}); + } + return iterator.slice(start, count); +} + +module.exports = { + enumEntries, + enumIndexedProperties, + enumNonIndexedProperties, + enumPrivateProperties, + enumSymbols, + getPrototype, + getFullText, + getPromiseState, + getProxySlots, +}; diff --git a/devtools/client/shared/components/object-inspector/utils/index.js b/devtools/client/shared/components/object-inspector/utils/index.js new file mode 100644 index 0000000000..13b3fd0049 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/utils/index.js @@ -0,0 +1,52 @@ +/* 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/>. */ + +const client = require("resource://devtools/client/shared/components/object-inspector/utils/client.js"); +const loadProperties = require("resource://devtools/client/shared/components/object-inspector/utils/load-properties.js"); +const node = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); +const { nodeIsError, nodeIsPrimitive } = node; +const selection = require("resource://devtools/client/shared/components/object-inspector/utils/selection.js"); + +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/reps/constants.js"); +const { + REPS: { Rep, Grip }, +} = require("resource://devtools/client/shared/components/reps/reps/rep.js"); + +function shouldRenderRootsInReps(roots, props = {}) { + if (roots.length !== 1) { + return false; + } + + const root = roots[0]; + const name = root && root.name; + + return ( + (name === null || typeof name === "undefined") && + (nodeIsPrimitive(root) || + (root?.contents?.value?.useCustomFormatter === true && + Array.isArray(root?.contents?.value?.header)) || + (nodeIsError(root) && props?.customFormat === true)) + ); +} + +function renderRep(item, props) { + return Rep({ + ...props, + front: item.contents.front, + object: node.getValue(item), + mode: props.mode || MODE.TINY, + defaultRep: Grip, + }); +} + +module.exports = { + client, + loadProperties, + node, + renderRep, + selection, + shouldRenderRootsInReps, +}; diff --git a/devtools/client/shared/components/object-inspector/utils/load-properties.js b/devtools/client/shared/components/object-inspector/utils/load-properties.js new file mode 100644 index 0000000000..42525e54f1 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/utils/load-properties.js @@ -0,0 +1,260 @@ +/* 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/>. */ + +const { + enumEntries, + enumIndexedProperties, + enumNonIndexedProperties, + enumPrivateProperties, + enumSymbols, + getPrototype, + getFullText, + getPromiseState, + getProxySlots, +} = require("resource://devtools/client/shared/components/object-inspector/utils/client.js"); + +const { + getClosestGripNode, + getClosestNonBucketNode, + getFront, + getValue, + nodeHasAccessors, + nodeHasProperties, + nodeIsBucket, + nodeIsDefaultProperties, + nodeIsEntries, + nodeIsMapEntry, + nodeIsPrimitive, + nodeIsPromise, + nodeIsProxy, + nodeNeedsNumericalBuckets, + nodeIsLongString, +} = require("resource://devtools/client/shared/components/object-inspector/utils/node.js"); + +function loadItemProperties(item, client, loadedProperties, threadActorID) { + const gripItem = getClosestGripNode(item); + const value = getValue(gripItem); + let front = getFront(gripItem); + + if (!front && value && client && client.getFrontByID) { + front = client.getFrontByID(value.actor); + } + + const getObjectFront = function() { + if (!front) { + front = client.createObjectFront( + value, + client.getFrontByID(threadActorID) + ); + } + + return front; + }; + + const [start, end] = item.meta + ? [item.meta.startIndex, item.meta.endIndex] + : []; + + const promises = []; + + if (shouldLoadItemIndexedProperties(item, loadedProperties)) { + promises.push(enumIndexedProperties(getObjectFront(), start, end)); + } + + if (shouldLoadItemNonIndexedProperties(item, loadedProperties)) { + promises.push(enumNonIndexedProperties(getObjectFront(), start, end)); + } + + if (shouldLoadItemEntries(item, loadedProperties)) { + promises.push(enumEntries(getObjectFront(), start, end)); + } + + if (shouldLoadItemPrototype(item, loadedProperties)) { + promises.push(getPrototype(getObjectFront())); + } + + if (shouldLoadItemPrivateProperties(item, loadedProperties)) { + promises.push(enumPrivateProperties(getObjectFront(), start, end)); + } + + if (shouldLoadItemSymbols(item, loadedProperties)) { + promises.push(enumSymbols(getObjectFront(), start, end)); + } + + if (shouldLoadItemFullText(item, loadedProperties)) { + const longStringFront = front || client.createLongStringFront(value); + promises.push(getFullText(longStringFront, item)); + } + + if (shouldLoadItemPromiseState(item, loadedProperties)) { + promises.push(getPromiseState(getObjectFront())); + } + + if (shouldLoadItemProxySlots(item, loadedProperties)) { + promises.push(getProxySlots(getObjectFront())); + } + + return Promise.all(promises).then(mergeResponses); +} + +function mergeResponses(responses) { + const data = {}; + + for (const response of responses) { + if (response.hasOwnProperty("ownProperties")) { + data.ownProperties = { ...data.ownProperties, ...response.ownProperties }; + } + + if (response.privateProperties && response.privateProperties.length > 0) { + data.privateProperties = response.privateProperties; + } + + if (response.ownSymbols && response.ownSymbols.length > 0) { + data.ownSymbols = response.ownSymbols; + } + + if (response.prototype) { + data.prototype = response.prototype; + } + + if (response.fullText) { + data.fullText = response.fullText; + } + + if (response.promiseState) { + data.promiseState = response.promiseState; + } + + if (response.proxyTarget && response.proxyHandler) { + data.proxyTarget = response.proxyTarget; + data.proxyHandler = response.proxyHandler; + } + } + + return data; +} + +function shouldLoadItemIndexedProperties(item, loadedProperties = new Map()) { + const gripItem = getClosestGripNode(item); + const value = getValue(gripItem); + + return ( + value && + nodeHasProperties(gripItem) && + !loadedProperties.has(item.path) && + !nodeIsProxy(item) && + !nodeNeedsNumericalBuckets(item) && + !nodeIsEntries(getClosestNonBucketNode(item)) && + // The data is loaded when expanding the window node. + !nodeIsDefaultProperties(item) + ); +} + +function shouldLoadItemNonIndexedProperties( + item, + loadedProperties = new Map() +) { + const gripItem = getClosestGripNode(item); + const value = getValue(gripItem); + + return ( + value && + nodeHasProperties(gripItem) && + !loadedProperties.has(item.path) && + !nodeIsProxy(item) && + !nodeIsEntries(getClosestNonBucketNode(item)) && + !nodeIsBucket(item) && + // The data is loaded when expanding the window node. + !nodeIsDefaultProperties(item) + ); +} + +function shouldLoadItemEntries(item, loadedProperties = new Map()) { + const gripItem = getClosestGripNode(item); + const value = getValue(gripItem); + + return ( + value && + nodeIsEntries(getClosestNonBucketNode(item)) && + !loadedProperties.has(item.path) && + !nodeNeedsNumericalBuckets(item) + ); +} + +function shouldLoadItemPrototype(item, loadedProperties = new Map()) { + const value = getValue(item); + + return ( + value && + !loadedProperties.has(item.path) && + !nodeIsBucket(item) && + !nodeIsMapEntry(item) && + !nodeIsEntries(item) && + !nodeIsDefaultProperties(item) && + !nodeHasAccessors(item) && + !nodeIsPrimitive(item) && + !nodeIsLongString(item) && + !nodeIsProxy(item) + ); +} + +function shouldLoadItemSymbols(item, loadedProperties = new Map()) { + const value = getValue(item); + + return ( + value && + !loadedProperties.has(item.path) && + !nodeIsBucket(item) && + !nodeIsMapEntry(item) && + !nodeIsEntries(item) && + !nodeIsDefaultProperties(item) && + !nodeHasAccessors(item) && + !nodeIsPrimitive(item) && + !nodeIsLongString(item) && + !nodeIsProxy(item) + ); +} + +function shouldLoadItemPrivateProperties(item, loadedProperties = new Map()) { + const value = getValue(item); + + return ( + value && + value?.preview?.privatePropertiesLength && + !loadedProperties.has(item.path) && + !nodeIsBucket(item) && + !nodeIsMapEntry(item) && + !nodeIsEntries(item) && + !nodeIsDefaultProperties(item) && + !nodeHasAccessors(item) && + !nodeIsPrimitive(item) && + !nodeIsLongString(item) && + !nodeIsProxy(item) + ); +} + +function shouldLoadItemFullText(item, loadedProperties = new Map()) { + return !loadedProperties.has(item.path) && nodeIsLongString(item); +} + +function shouldLoadItemPromiseState(item, loadedProperties = new Map()) { + return !loadedProperties.has(item.path) && nodeIsPromise(item); +} + +function shouldLoadItemProxySlots(item, loadedProperties = new Map()) { + return !loadedProperties.has(item.path) && nodeIsProxy(item); +} + +module.exports = { + loadItemProperties, + mergeResponses, + shouldLoadItemEntries, + shouldLoadItemIndexedProperties, + shouldLoadItemNonIndexedProperties, + shouldLoadItemPrototype, + shouldLoadItemSymbols, + shouldLoadItemFullText, + shouldLoadItemPromiseState, + shouldLoadItemProxySlots, +}; diff --git a/devtools/client/shared/components/object-inspector/utils/moz.build b/devtools/client/shared/components/object-inspector/utils/moz.build new file mode 100644 index 0000000000..1301b2aca6 --- /dev/null +++ b/devtools/client/shared/components/object-inspector/utils/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "client.js", + "index.js", + "load-properties.js", + "node.js", + "selection.js", +) diff --git a/devtools/client/shared/components/object-inspector/utils/node.js b/devtools/client/shared/components/object-inspector/utils/node.js new file mode 100644 index 0000000000..7b4d1fb0ce --- /dev/null +++ b/devtools/client/shared/components/object-inspector/utils/node.js @@ -0,0 +1,1039 @@ +/* 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/>. */ + +const { + maybeEscapePropertyName, +} = require("resource://devtools/client/shared/components/reps/reps/rep-utils.js"); +const ArrayRep = require("resource://devtools/client/shared/components/reps/reps/array.js"); +const GripArrayRep = require("resource://devtools/client/shared/components/reps/reps/grip-array.js"); +const GripMap = require("resource://devtools/client/shared/components/reps/reps/grip-map.js"); +const GripEntryRep = require("resource://devtools/client/shared/components/reps/reps/grip-entry.js"); +const ErrorRep = require("resource://devtools/client/shared/components/reps/reps/error.js"); +const BigIntRep = require("resource://devtools/client/shared/components/reps/reps/big-int.js"); +const { + isLongString, +} = require("resource://devtools/client/shared/components/reps/reps/string.js"); + +const MAX_NUMERICAL_PROPERTIES = 100; + +const NODE_TYPES = { + BUCKET: Symbol("[n…m]"), + DEFAULT_PROPERTIES: Symbol("<default properties>"), + ENTRIES: Symbol("<entries>"), + GET: Symbol("<get>"), + GRIP: Symbol("GRIP"), + MAP_ENTRY_KEY: Symbol("<key>"), + MAP_ENTRY_VALUE: Symbol("<value>"), + PROMISE_REASON: Symbol("<reason>"), + PROMISE_STATE: Symbol("<state>"), + PROMISE_VALUE: Symbol("<value>"), + PROXY_HANDLER: Symbol("<handler>"), + PROXY_TARGET: Symbol("<target>"), + SET: Symbol("<set>"), + PROTOTYPE: Symbol("<prototype>"), + BLOCK: Symbol("☲"), +}; + +let WINDOW_PROPERTIES = {}; + +if (typeof window === "object") { + WINDOW_PROPERTIES = Object.getOwnPropertyNames(window); +} + +function getType(item) { + return item.type; +} + +function getValue(item) { + if (nodeHasValue(item)) { + return item.contents.value; + } + + if (nodeHasGetterValue(item)) { + return item.contents.getterValue; + } + + if (nodeHasAccessors(item)) { + return item.contents; + } + + return undefined; +} + +function getFront(item) { + return item && item.contents && item.contents.front; +} + +function getActor(item, roots) { + const isRoot = isNodeRoot(item, roots); + const value = getValue(item); + return isRoot || !value ? null : value.actor; +} + +function isNodeRoot(item, roots) { + const gripItem = getClosestGripNode(item); + const value = getValue(gripItem); + + return ( + value && + roots.some(root => { + const rootValue = getValue(root); + return rootValue && rootValue.actor === value.actor; + }) + ); +} + +function nodeIsBucket(item) { + return getType(item) === NODE_TYPES.BUCKET; +} + +function nodeIsEntries(item) { + return getType(item) === NODE_TYPES.ENTRIES; +} + +function nodeIsMapEntry(item) { + return GripEntryRep.supportsObject(getValue(item)); +} + +function nodeHasChildren(item) { + return Array.isArray(item.contents); +} + +function nodeHasValue(item) { + return item && item.contents && item.contents.hasOwnProperty("value"); +} + +function nodeHasGetterValue(item) { + return item && item.contents && item.contents.hasOwnProperty("getterValue"); +} + +function nodeIsObject(item) { + const value = getValue(item); + return value && value.type === "object"; +} + +function nodeIsArrayLike(item) { + const value = getValue(item); + return GripArrayRep.supportsObject(value) || ArrayRep.supportsObject(value); +} + +function nodeIsFunction(item) { + const value = getValue(item); + return value && value.class === "Function"; +} + +function nodeIsOptimizedOut(item) { + const value = getValue(item); + return !nodeHasChildren(item) && value && value.optimizedOut; +} + +function nodeIsUninitializedBinding(item) { + const value = getValue(item); + return value && value.uninitialized; +} + +// Used to check if an item represents a binding that exists in a sourcemap's +// original file content, but does not match up with a binding found in the +// generated code. +function nodeIsUnmappedBinding(item) { + const value = getValue(item); + return value && value.unmapped; +} + +// Used to check if an item represents a binding that exists in the debugger's +// parser result, but does not match up with a binding returned by the +// devtools server. +function nodeIsUnscopedBinding(item) { + const value = getValue(item); + return value && value.unscoped; +} + +function nodeIsMissingArguments(item) { + const value = getValue(item); + return !nodeHasChildren(item) && value && value.missingArguments; +} + +function nodeHasProperties(item) { + return !nodeHasChildren(item) && nodeIsObject(item); +} + +function nodeIsPrimitive(item) { + return ( + nodeIsBigInt(item) || + (!nodeHasChildren(item) && + !nodeHasProperties(item) && + !nodeIsEntries(item) && + !nodeIsMapEntry(item) && + !nodeHasAccessors(item) && + !nodeIsBucket(item) && + !nodeIsLongString(item)) + ); +} + +function nodeIsDefaultProperties(item) { + return getType(item) === NODE_TYPES.DEFAULT_PROPERTIES; +} + +function isDefaultWindowProperty(name) { + return WINDOW_PROPERTIES.includes(name); +} + +function nodeIsPromise(item) { + const value = getValue(item); + if (!value) { + return false; + } + + return value.class == "Promise"; +} + +function nodeIsProxy(item) { + const value = getValue(item); + if (!value) { + return false; + } + + return value.class == "Proxy"; +} + +function nodeIsPrototype(item) { + return getType(item) === NODE_TYPES.PROTOTYPE; +} + +function nodeIsWindow(item) { + const value = getValue(item); + if (!value) { + return false; + } + + return value.class == "Window"; +} + +function nodeIsGetter(item) { + return getType(item) === NODE_TYPES.GET; +} + +function nodeIsSetter(item) { + return getType(item) === NODE_TYPES.SET; +} + +function nodeIsBlock(item) { + return getType(item) === NODE_TYPES.BLOCK; +} + +function nodeIsError(item) { + return ErrorRep.supportsObject(getValue(item)); +} + +function nodeIsLongString(item) { + return isLongString(getValue(item)); +} + +function nodeIsBigInt(item) { + return BigIntRep.supportsObject(getValue(item)); +} + +function nodeHasFullText(item) { + const value = getValue(item); + return nodeIsLongString(item) && value.hasOwnProperty("fullText"); +} + +function nodeHasGetter(item) { + const getter = getNodeGetter(item); + return getter && getter.type !== "undefined"; +} + +function nodeHasSetter(item) { + const setter = getNodeSetter(item); + return setter && setter.type !== "undefined"; +} + +function nodeHasAccessors(item) { + return nodeHasGetter(item) || nodeHasSetter(item); +} + +function nodeSupportsNumericalBucketing(item) { + // We exclude elements with entries since it's the <entries> node + // itself that can have buckets. + return ( + (nodeIsArrayLike(item) && !nodeHasEntries(item)) || + nodeIsEntries(item) || + nodeIsBucket(item) + ); +} + +function nodeHasEntries(item) { + const value = getValue(item); + if (!value) { + return false; + } + + const className = value.class; + return ( + className === "Map" || + className === "Set" || + className === "WeakMap" || + className === "WeakSet" || + className === "Storage" || + className === "URLSearchParams" || + className === "Headers" || + className === "FormData" || + className === "MIDIInputMap" || + className === "MIDIOutputMap" + ); +} + +function nodeNeedsNumericalBuckets(item) { + return ( + nodeSupportsNumericalBucketing(item) && + getNumericalPropertiesCount(item) > MAX_NUMERICAL_PROPERTIES + ); +} + +function makeNodesForPromiseProperties(loadedProps, item) { + const { reason, value, state } = loadedProps.promiseState; + const properties = []; + + if (state) { + properties.push( + createNode({ + parent: item, + name: "<state>", + contents: { value: state }, + type: NODE_TYPES.PROMISE_STATE, + }) + ); + } + + if (reason) { + properties.push( + createNode({ + parent: item, + name: "<reason>", + contents: { + value: reason.getGrip ? reason.getGrip() : reason, + front: reason.getGrip ? reason : null, + }, + type: NODE_TYPES.PROMISE_REASON, + }) + ); + } + + if (value) { + properties.push( + createNode({ + parent: item, + name: "<value>", + contents: { + value: value.getGrip ? value.getGrip() : value, + front: value.getGrip ? value : null, + }, + type: NODE_TYPES.PROMISE_VALUE, + }) + ); + } + + return properties; +} + +function makeNodesForProxyProperties(loadedProps, item) { + const { proxyHandler, proxyTarget } = loadedProps; + + const isProxyHandlerFront = proxyHandler && proxyHandler.getGrip; + const proxyHandlerGrip = isProxyHandlerFront + ? proxyHandler.getGrip() + : proxyHandler; + const proxyHandlerFront = isProxyHandlerFront ? proxyHandler : null; + + const isProxyTargetFront = proxyTarget && proxyTarget.getGrip; + const proxyTargetGrip = isProxyTargetFront + ? proxyTarget.getGrip() + : proxyTarget; + const proxyTargetFront = isProxyTargetFront ? proxyTarget : null; + + return [ + createNode({ + parent: item, + name: "<target>", + contents: { value: proxyTargetGrip, front: proxyTargetFront }, + type: NODE_TYPES.PROXY_TARGET, + }), + createNode({ + parent: item, + name: "<handler>", + contents: { value: proxyHandlerGrip, front: proxyHandlerFront }, + type: NODE_TYPES.PROXY_HANDLER, + }), + ]; +} + +function makeNodesForEntries(item) { + const nodeName = "<entries>"; + + return createNode({ + parent: item, + name: nodeName, + contents: null, + type: NODE_TYPES.ENTRIES, + }); +} + +function makeNodesForMapEntry(item) { + const nodeValue = getValue(item); + if (!nodeValue || !nodeValue.preview) { + return []; + } + + const { key, value } = nodeValue.preview; + const isKeyFront = key && key.getGrip; + const keyGrip = isKeyFront ? key.getGrip() : key; + const keyFront = isKeyFront ? key : null; + + const isValueFront = value && value.getGrip; + const valueGrip = isValueFront ? value.getGrip() : value; + const valueFront = isValueFront ? value : null; + + return [ + createNode({ + parent: item, + name: "<key>", + contents: { value: keyGrip, front: keyFront }, + type: NODE_TYPES.MAP_ENTRY_KEY, + }), + createNode({ + parent: item, + name: "<value>", + contents: { value: valueGrip, front: valueFront }, + type: NODE_TYPES.MAP_ENTRY_VALUE, + }), + ]; +} + +function getNodeGetter(item) { + return item && item.contents ? item.contents.get : undefined; +} + +function getNodeSetter(item) { + return item && item.contents ? item.contents.set : undefined; +} + +function sortProperties(properties) { + return properties.sort((a, b) => { + // Sort numbers in ascending order and sort strings lexicographically + const aInt = parseInt(a, 10); + const bInt = parseInt(b, 10); + + if (isNaN(aInt) || isNaN(bInt)) { + return a > b ? 1 : -1; + } + + return aInt - bInt; + }); +} + +function makeNumericalBuckets(parent) { + const numProperties = getNumericalPropertiesCount(parent); + + // We want to have at most a hundred slices. + const bucketSize = + 10 ** Math.max(2, Math.ceil(Math.log10(numProperties)) - 2); + const numBuckets = Math.ceil(numProperties / bucketSize); + + const buckets = []; + for (let i = 1; i <= numBuckets; i++) { + const minKey = (i - 1) * bucketSize; + const maxKey = Math.min(i * bucketSize - 1, numProperties - 1); + const startIndex = nodeIsBucket(parent) ? parent.meta.startIndex : 0; + const minIndex = startIndex + minKey; + const maxIndex = startIndex + maxKey; + const bucketName = `[${minIndex}…${maxIndex}]`; + + buckets.push( + createNode({ + parent, + name: bucketName, + contents: null, + type: NODE_TYPES.BUCKET, + meta: { + startIndex: minIndex, + endIndex: maxIndex, + }, + }) + ); + } + return buckets; +} + +function makeDefaultPropsBucket(propertiesNames, parent, ownProperties) { + const userPropertiesNames = []; + const defaultProperties = []; + + propertiesNames.forEach(name => { + if (isDefaultWindowProperty(name)) { + defaultProperties.push(name); + } else { + userPropertiesNames.push(name); + } + }); + + const nodes = makeNodesForOwnProps( + userPropertiesNames, + parent, + ownProperties + ); + + if (defaultProperties.length > 0) { + const defaultPropertiesNode = createNode({ + parent, + name: "<default properties>", + contents: null, + type: NODE_TYPES.DEFAULT_PROPERTIES, + }); + + const defaultNodes = makeNodesForOwnProps( + defaultProperties, + defaultPropertiesNode, + ownProperties + ); + nodes.push(setNodeChildren(defaultPropertiesNode, defaultNodes)); + } + return nodes; +} + +function makeNodesForOwnProps(propertiesNames, parent, ownProperties) { + return propertiesNames.map(name => { + const property = ownProperties[name]; + + let propertyValue = property; + if (property && property.hasOwnProperty("getterValue")) { + propertyValue = property.getterValue; + } else if (property && property.hasOwnProperty("value")) { + propertyValue = property.value; + } + + // propertyValue can be a front (LongString or Object) or a primitive grip. + const isFront = propertyValue && propertyValue.getGrip; + const front = isFront ? propertyValue : null; + const grip = isFront ? front.getGrip() : propertyValue; + + return createNode({ + parent, + name: maybeEscapePropertyName(name), + propertyName: name, + contents: { + ...(property || {}), + value: grip, + front, + }, + }); + }); +} + +function makeNodesForProperties(objProps, parent) { + const { + ownProperties = {}, + ownSymbols, + privateProperties, + prototype, + safeGetterValues, + } = objProps; + + const parentValue = getValue(parent); + const allProperties = { ...ownProperties, ...safeGetterValues }; + + // Ignore properties that are neither non-concrete nor getters/setters. + const propertiesNames = sortProperties(Object.keys(allProperties)).filter( + name => { + if (!allProperties[name]) { + return false; + } + + const properties = Object.getOwnPropertyNames(allProperties[name]); + return properties.some(property => + ["value", "getterValue", "get", "set"].includes(property) + ); + } + ); + + const isParentNodeWindow = parentValue && parentValue.class == "Window"; + const nodes = isParentNodeWindow + ? makeDefaultPropsBucket(propertiesNames, parent, allProperties) + : makeNodesForOwnProps(propertiesNames, parent, allProperties); + + if (Array.isArray(ownSymbols)) { + ownSymbols.forEach((ownSymbol, index) => { + const descriptorValue = ownSymbol?.descriptor?.value; + const hasGrip = descriptorValue?.getGrip; + const symbolGrip = hasGrip ? descriptorValue.getGrip() : descriptorValue; + const symbolFront = hasGrip ? ownSymbol.descriptor.value : null; + + nodes.push( + createNode({ + parent, + name: ownSymbol.name, + path: `symbol-${index}`, + contents: { + value: symbolGrip, + front: symbolFront, + }, + }) + ); + }, this); + } + + if (Array.isArray(privateProperties)) { + privateProperties.forEach((privateProperty, index) => { + const descriptorValue = privateProperty?.descriptor?.value; + const hasGrip = descriptorValue?.getGrip; + const privatePropertyGrip = hasGrip + ? descriptorValue.getGrip() + : descriptorValue; + const privatePropertyFront = hasGrip + ? privateProperty.descriptor.value + : null; + + nodes.push( + createNode({ + parent, + name: privateProperty.name, + path: `private-${index}`, + contents: { + value: privatePropertyGrip, + front: privatePropertyFront, + }, + }) + ); + }, this); + } + + if (nodeIsPromise(parent)) { + nodes.push(...makeNodesForPromiseProperties(objProps, parent)); + } + + if (nodeHasEntries(parent)) { + nodes.push(makeNodesForEntries(parent)); + } + + // Add accessor nodes if needed + const defaultPropertiesNode = isParentNodeWindow + ? nodes.find(node => nodeIsDefaultProperties(node)) + : null; + + for (const name of propertiesNames) { + const property = allProperties[name]; + const isDefaultProperty = + isParentNodeWindow && + defaultPropertiesNode && + isDefaultWindowProperty(name); + const parentNode = isDefaultProperty ? defaultPropertiesNode : parent; + const parentContentsArray = + isDefaultProperty && defaultPropertiesNode + ? defaultPropertiesNode.contents + : nodes; + + if (property.get && property.get.type !== "undefined") { + parentContentsArray.push( + createGetterNode({ + parent: parentNode, + property, + name, + }) + ); + } + + if (property.set && property.set.type !== "undefined") { + parentContentsArray.push( + createSetterNode({ + parent: parentNode, + property, + name, + }) + ); + } + } + + // Add the prototype if it exists and is not null + if (prototype && prototype.type !== "null") { + nodes.push(makeNodeForPrototype(objProps, parent)); + } + + return nodes; +} + +function setNodeFullText(loadedProps, node) { + if (nodeHasFullText(node) || !nodeIsLongString(node)) { + return node; + } + + const { fullText } = loadedProps; + if (nodeHasValue(node)) { + node.contents.value.fullText = fullText; + } else if (nodeHasGetterValue(node)) { + node.contents.getterValue.fullText = fullText; + } + + return node; +} + +function makeNodeForPrototype(objProps, parent) { + const { prototype } = objProps || {}; + + // Add the prototype if it exists and is not null + if (prototype && prototype.type !== "null") { + return createNode({ + parent, + name: "<prototype>", + contents: { + value: prototype.getGrip ? prototype.getGrip() : prototype, + front: prototype.getGrip ? prototype : null, + }, + type: NODE_TYPES.PROTOTYPE, + }); + } + + return null; +} + +function createNode(options) { + const { + parent, + name, + propertyName, + path, + contents, + type = NODE_TYPES.GRIP, + meta, + } = options; + + if (contents === undefined) { + return null; + } + + // The path is important to uniquely identify the item in the entire + // tree. This helps debugging & optimizes React's rendering of large + // lists. The path will be separated by property name. + + return { + parent, + name, + // `name` can be escaped; propertyName contains the original property name. + propertyName, + path: createPath(parent && parent.path, path || name), + contents, + type, + meta, + }; +} + +function createGetterNode({ parent, property, name }) { + const isFront = property.get && property.get.getGrip; + const grip = isFront ? property.get.getGrip() : property.get; + const front = isFront ? property.get : null; + + return createNode({ + parent, + name: `<get ${name}()>`, + contents: { value: grip, front }, + type: NODE_TYPES.GET, + }); +} + +function createSetterNode({ parent, property, name }) { + const isFront = property.set && property.set.getGrip; + const grip = isFront ? property.set.getGrip() : property.set; + const front = isFront ? property.set : null; + + return createNode({ + parent, + name: `<set ${name}()>`, + contents: { value: grip, front }, + type: NODE_TYPES.SET, + }); +} + +function setNodeChildren(node, children) { + node.contents = children; + return node; +} + +function getEvaluatedItem(item, evaluations) { + if (!evaluations.has(item.path)) { + return item; + } + + const evaluation = evaluations.get(item.path); + const isFront = + evaluation && evaluation.getterValue && evaluation.getterValue.getGrip; + + const contents = isFront + ? { + getterValue: evaluation.getterValue.getGrip(), + front: evaluation.getterValue, + } + : evaluations.get(item.path); + + return { + ...item, + contents, + }; +} + +function getChildrenWithEvaluations(options) { + const { item, loadedProperties, cachedNodes, evaluations } = options; + + const children = getChildren({ + loadedProperties, + cachedNodes, + item, + }); + + if (Array.isArray(children)) { + return children.map(i => getEvaluatedItem(i, evaluations)); + } + + if (children) { + return getEvaluatedItem(children, evaluations); + } + + return []; +} + +function getChildren(options) { + const { cachedNodes, item, loadedProperties = new Map() } = options; + + const key = item.path; + if (cachedNodes && cachedNodes.has(key)) { + return cachedNodes.get(key); + } + + const loadedProps = loadedProperties.get(key); + const hasLoadedProps = loadedProperties.has(key); + + // Because we are dynamically creating the tree as the user + // expands it (not precalculated tree structure), we cache child + // arrays. This not only helps performance, but is necessary + // because the expanded state depends on instances of nodes + // being the same across renders. If we didn't do this, each + // node would be a new instance every render. + // If the node needs properties, we only add children to + // the cache if the properties are loaded. + const addToCache = children => { + if (cachedNodes) { + cachedNodes.set(item.path, children); + } + return children; + }; + + // Nodes can either have children already, or be an object with + // properties that we need to go and fetch. + if (nodeHasChildren(item)) { + return addToCache(item.contents); + } + + if (nodeIsMapEntry(item)) { + return addToCache(makeNodesForMapEntry(item)); + } + + if (nodeIsProxy(item) && hasLoadedProps) { + return addToCache(makeNodesForProxyProperties(loadedProps, item)); + } + + if (nodeIsLongString(item) && hasLoadedProps) { + // Set longString object's fullText to fetched one. + return addToCache(setNodeFullText(loadedProps, item)); + } + + if (nodeNeedsNumericalBuckets(item) && hasLoadedProps) { + // Even if we have numerical buckets, we should have loaded non indexed + // properties. + const bucketNodes = makeNumericalBuckets(item); + return addToCache( + bucketNodes.concat(makeNodesForProperties(loadedProps, item)) + ); + } + + if (!nodeIsEntries(item) && !nodeIsBucket(item) && !nodeHasProperties(item)) { + return []; + } + + if (!hasLoadedProps) { + return []; + } + + return addToCache(makeNodesForProperties(loadedProps, item)); +} + +// Builds an expression that resolves to the value of the item in question +// e.g. `b` in { a: { b: 2 } } resolves to `a.b` +function getPathExpression(item) { + if (item && item.parent) { + const parent = nodeIsBucket(item.parent) ? item.parent.parent : item.parent; + return `${getPathExpression(parent)}.${item.name}`; + } + + return item.name; +} + +function getParent(item) { + return item.parent; +} + +function getNumericalPropertiesCount(item) { + if (nodeIsBucket(item)) { + return item.meta.endIndex - item.meta.startIndex + 1; + } + + const value = getValue(getClosestGripNode(item)); + if (!value) { + return 0; + } + + if (GripArrayRep.supportsObject(value)) { + return GripArrayRep.getLength(value); + } + + if (GripMap.supportsObject(value)) { + return GripMap.getLength(value); + } + + // TODO: We can also have numerical properties on Objects, but at the + // moment we don't have a way to distinguish them from non-indexed properties, + // as they are all computed in a ownPropertiesLength property. + + return 0; +} + +function getClosestGripNode(item) { + const type = getType(item); + if ( + type !== NODE_TYPES.BUCKET && + type !== NODE_TYPES.DEFAULT_PROPERTIES && + type !== NODE_TYPES.ENTRIES + ) { + return item; + } + + const parent = getParent(item); + if (!parent) { + return null; + } + + return getClosestGripNode(parent); +} + +function getClosestNonBucketNode(item) { + const type = getType(item); + + if (type !== NODE_TYPES.BUCKET) { + return item; + } + + const parent = getParent(item); + if (!parent) { + return null; + } + + return getClosestNonBucketNode(parent); +} + +function getParentGripNode(item) { + const parentNode = getParent(item); + if (!parentNode) { + return null; + } + + return getClosestGripNode(parentNode); +} + +function getParentGripValue(item) { + const parentGripNode = getParentGripNode(item); + if (!parentGripNode) { + return null; + } + + return getValue(parentGripNode); +} + +function getParentFront(item) { + const parentGripNode = getParentGripNode(item); + if (!parentGripNode) { + return null; + } + + return getFront(parentGripNode); +} + +function getNonPrototypeParentGripValue(item) { + const parentGripNode = getParentGripNode(item); + if (!parentGripNode) { + return null; + } + + if (getType(parentGripNode) === NODE_TYPES.PROTOTYPE) { + return getNonPrototypeParentGripValue(parentGripNode); + } + + return getValue(parentGripNode); +} + +function createPath(parentPath, path) { + return parentPath ? `${parentPath}◦${path}` : path; +} + +module.exports = { + createNode, + createGetterNode, + createSetterNode, + getActor, + getChildren, + getChildrenWithEvaluations, + getClosestGripNode, + getClosestNonBucketNode, + getEvaluatedItem, + getFront, + getPathExpression, + getParent, + getParentFront, + getParentGripValue, + getNonPrototypeParentGripValue, + getNumericalPropertiesCount, + getValue, + makeNodesForEntries, + makeNodesForPromiseProperties, + makeNodesForProperties, + makeNumericalBuckets, + nodeHasAccessors, + nodeHasChildren, + nodeHasEntries, + nodeHasProperties, + nodeHasGetter, + nodeHasSetter, + nodeIsBlock, + nodeIsBucket, + nodeIsDefaultProperties, + nodeIsEntries, + nodeIsError, + nodeIsLongString, + nodeHasFullText, + nodeIsFunction, + nodeIsGetter, + nodeIsMapEntry, + nodeIsMissingArguments, + nodeIsObject, + nodeIsOptimizedOut, + nodeIsPrimitive, + nodeIsPromise, + nodeIsPrototype, + nodeIsProxy, + nodeIsSetter, + nodeIsUninitializedBinding, + nodeIsUnmappedBinding, + nodeIsUnscopedBinding, + nodeIsWindow, + nodeNeedsNumericalBuckets, + nodeSupportsNumericalBucketing, + setNodeChildren, + sortProperties, + NODE_TYPES, +}; diff --git a/devtools/client/shared/components/object-inspector/utils/selection.js b/devtools/client/shared/components/object-inspector/utils/selection.js new file mode 100644 index 0000000000..fdcca7ff6b --- /dev/null +++ b/devtools/client/shared/components/object-inspector/utils/selection.js @@ -0,0 +1,16 @@ +/* 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/>. */ + +function documentHasSelection(doc = document) { + const selection = doc.defaultView.getSelection(); + if (!selection) { + return false; + } + + return selection.type === "Range"; +} + +module.exports = { + documentHasSelection, +}; |