diff options
Diffstat (limited to 'devtools/client/memory/components')
21 files changed, 3204 insertions, 0 deletions
diff --git a/devtools/client/memory/components/Census.js b/devtools/client/memory/components/Census.js new file mode 100644 index 0000000000..d33050b019 --- /dev/null +++ b/devtools/client/memory/components/Census.js @@ -0,0 +1,94 @@ +/* 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, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const Tree = createFactory( + require("resource://devtools/client/shared/components/VirtualizedTree.js") +); +const CensusTreeItem = createFactory( + require("resource://devtools/client/memory/components/CensusTreeItem.js") +); +const { + TREE_ROW_HEIGHT, +} = require("resource://devtools/client/memory/constants.js"); +const { + censusModel, + diffingModel, +} = require("resource://devtools/client/memory/models.js"); + +class Census extends Component { + static get propTypes() { + return { + census: censusModel, + onExpand: PropTypes.func.isRequired, + onCollapse: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + onViewIndividuals: PropTypes.func.isRequired, + diffing: diffingModel, + }; + } + + render() { + const { + census, + onExpand, + onCollapse, + onFocus, + diffing, + onViewSourceInDebugger, + onViewIndividuals, + } = this.props; + + const report = census.report; + const parentMap = census.parentMap; + const { totalBytes, totalCount } = report; + + const getPercentBytes = + totalBytes === 0 ? _ => 0 : bytes => (bytes / totalBytes) * 100; + + const getPercentCount = + totalCount === 0 ? _ => 0 : count => (count / totalCount) * 100; + + return Tree({ + autoExpandDepth: 0, + preventNavigationOnArrowRight: false, + focused: census.focused, + getParent: node => { + const parent = parentMap[node.id]; + return parent === report ? null : parent; + }, + getChildren: node => node.children || [], + isExpanded: node => census.expanded.has(node.id), + onExpand, + onCollapse, + onFocus, + renderItem: (item, depth, focused, arrow, expanded) => + new CensusTreeItem({ + onViewSourceInDebugger, + item, + depth, + focused, + arrow, + expanded, + getPercentBytes, + getPercentCount, + diffing, + inverted: census.display.inverted, + onViewIndividuals, + }), + getRoots: () => report.children || [], + getKey: node => node.id, + itemHeight: TREE_ROW_HEIGHT, + }); + } +} + +module.exports = Census; diff --git a/devtools/client/memory/components/CensusHeader.js b/devtools/client/memory/components/CensusHeader.js new file mode 100644 index 0000000000..bdd9d15087 --- /dev/null +++ b/devtools/client/memory/components/CensusHeader.js @@ -0,0 +1,79 @@ +/* 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, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { L10N } = require("resource://devtools/client/memory/utils.js"); +const models = require("resource://devtools/client/memory/models.js"); + +class CensusHeader extends Component { + static get propTypes() { + return { + diffing: models.diffingModel, + }; + } + + render() { + let individualsCell; + if (!this.props.diffing) { + individualsCell = dom.span({ + className: "heap-tree-item-field heap-tree-item-individuals", + }); + } + + return dom.div( + { + className: "header", + }, + + dom.span( + { + className: "heap-tree-item-bytes", + title: L10N.getStr("heapview.field.bytes.tooltip"), + }, + L10N.getStr("heapview.field.bytes") + ), + + dom.span( + { + className: "heap-tree-item-count", + title: L10N.getStr("heapview.field.count.tooltip"), + }, + L10N.getStr("heapview.field.count") + ), + + dom.span( + { + className: "heap-tree-item-total-bytes", + title: L10N.getStr("heapview.field.totalbytes.tooltip"), + }, + L10N.getStr("heapview.field.totalbytes") + ), + + dom.span( + { + className: "heap-tree-item-total-count", + title: L10N.getStr("heapview.field.totalcount.tooltip"), + }, + L10N.getStr("heapview.field.totalcount") + ), + + individualsCell, + + dom.span( + { + className: "heap-tree-item-name", + title: L10N.getStr("heapview.field.name.tooltip"), + }, + L10N.getStr("heapview.field.name") + ) + ); + } +} + +module.exports = CensusHeader; diff --git a/devtools/client/memory/components/CensusTreeItem.js b/devtools/client/memory/components/CensusTreeItem.js new file mode 100644 index 0000000000..39fdbeb052 --- /dev/null +++ b/devtools/client/memory/components/CensusTreeItem.js @@ -0,0 +1,185 @@ +/* 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 { isSavedFrame } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + L10N, + formatNumber, + formatPercent, +} = require("resource://devtools/client/memory/utils.js"); +const Frame = createFactory( + require("resource://devtools/client/shared/components/Frame.js") +); +const { + TREE_ROW_HEIGHT, +} = require("resource://devtools/client/memory/constants.js"); +const models = require("resource://devtools/client/memory/models.js"); + +class CensusTreeItem extends Component { + static get propTypes() { + return { + arrow: PropTypes.any, + depth: PropTypes.number.isRequired, + diffing: models.app.diffing, + expanded: PropTypes.bool.isRequired, + focused: PropTypes.bool.isRequired, + getPercentBytes: PropTypes.func.isRequired, + getPercentCount: PropTypes.func.isRequired, + inverted: PropTypes.bool, + item: PropTypes.object.isRequired, + onViewIndividuals: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + this.toLabel = this.toLabel.bind(this); + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.item != nextProps.item || + this.props.depth != nextProps.depth || + this.props.expanded != nextProps.expanded || + this.props.focused != nextProps.focused || + this.props.diffing != nextProps.diffing + ); + } + + toLabel(name, onViewSourceInDebugger) { + if (isSavedFrame(name)) { + return Frame({ + frame: name, + onClick: onViewSourceInDebugger, + showFunctionName: true, + showHost: true, + }); + } + + if (name === null) { + return L10N.getStr("tree-item.root"); + } + + if (name === "noStack") { + return L10N.getStr("tree-item.nostack"); + } + + if (name === "noFilename") { + return L10N.getStr("tree-item.nofilename"); + } + + return String(name); + } + + render() { + const { + item, + depth, + arrow, + focused, + getPercentBytes, + getPercentCount, + diffing, + onViewSourceInDebugger, + onViewIndividuals, + inverted, + } = this.props; + + const bytes = formatNumber(item.bytes, !!diffing); + const percentBytes = formatPercent(getPercentBytes(item.bytes), !!diffing); + + const count = formatNumber(item.count, !!diffing); + const percentCount = formatPercent(getPercentCount(item.count), !!diffing); + + const totalBytes = formatNumber(item.totalBytes, !!diffing); + const percentTotalBytes = formatPercent( + getPercentBytes(item.totalBytes), + !!diffing + ); + + const totalCount = formatNumber(item.totalCount, !!diffing); + const percentTotalCount = formatPercent( + getPercentCount(item.totalCount), + !!diffing + ); + + let pointer; + if (inverted && depth > 0) { + pointer = dom.span({ className: "children-pointer" }, "↖"); + } else if (!inverted && item.children?.length) { + pointer = dom.span({ className: "children-pointer" }, "↘"); + } + + let individualsCell; + if (!diffing) { + let individualsButton; + if (item.reportLeafIndex !== undefined) { + individualsButton = dom.button( + { + key: `individuals-button-${item.id}`, + title: L10N.getStr("tree-item.view-individuals.tooltip"), + className: "devtools-button individuals-button", + onClick: e => { + // Don't let the event bubble up to cause this item to focus after + // we have switched views, which would lead to assertion failures. + e.preventDefault(); + e.stopPropagation(); + + onViewIndividuals(item); + }, + }, + "⁂" + ); + } + individualsCell = dom.span( + { className: "heap-tree-item-field heap-tree-item-individuals" }, + individualsButton + ); + } + + return dom.div( + { className: `heap-tree-item ${focused ? "focused" : ""}` }, + dom.span( + { className: "heap-tree-item-field heap-tree-item-bytes" }, + dom.span({ className: "heap-tree-number" }, bytes), + dom.span({ className: "heap-tree-percent" }, percentBytes) + ), + dom.span( + { className: "heap-tree-item-field heap-tree-item-count" }, + dom.span({ className: "heap-tree-number" }, count), + dom.span({ className: "heap-tree-percent" }, percentCount) + ), + dom.span( + { className: "heap-tree-item-field heap-tree-item-total-bytes" }, + dom.span({ className: "heap-tree-number" }, totalBytes), + dom.span({ className: "heap-tree-percent" }, percentTotalBytes) + ), + dom.span( + { className: "heap-tree-item-field heap-tree-item-total-count" }, + dom.span({ className: "heap-tree-number" }, totalCount), + dom.span({ className: "heap-tree-percent" }, percentTotalCount) + ), + individualsCell, + dom.span( + { + className: "heap-tree-item-field heap-tree-item-name", + style: { marginInlineStart: depth * TREE_ROW_HEIGHT }, + }, + arrow, + pointer, + this.toLabel(item.name, onViewSourceInDebugger) + ) + ); + } +} + +module.exports = CensusTreeItem; diff --git a/devtools/client/memory/components/DominatorTree.js b/devtools/client/memory/components/DominatorTree.js new file mode 100644 index 0000000000..9c767cece7 --- /dev/null +++ b/devtools/client/memory/components/DominatorTree.js @@ -0,0 +1,250 @@ +/* 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, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + createParentMap, +} = require("resource://devtools/shared/heapsnapshot/CensusUtils.js"); +const Tree = createFactory( + require("resource://devtools/client/shared/components/VirtualizedTree.js") +); +const DominatorTreeItem = createFactory( + require("resource://devtools/client/memory/components/DominatorTreeItem.js") +); +const { L10N } = require("resource://devtools/client/memory/utils.js"); +const { + TREE_ROW_HEIGHT, + dominatorTreeState, +} = require("resource://devtools/client/memory/constants.js"); +const { + dominatorTreeModel, +} = require("resource://devtools/client/memory/models.js"); +const DominatorTreeLazyChildren = require("resource://devtools/client/memory/dominator-tree-lazy-children.js"); + +const DOMINATOR_TREE_AUTO_EXPAND_DEPTH = 3; + +/** + * A throbber that represents a subtree in the dominator tree that is actively + * being incrementally loaded and fetched from the `HeapAnalysesWorker`. + */ +class DominatorTreeSubtreeFetchingClass extends Component { + static get propTypes() { + return { + depth: PropTypes.number.isRequired, + focused: PropTypes.bool.isRequired, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.depth !== nextProps.depth || + this.props.focused !== nextProps.focused + ); + } + + render() { + const { depth, focused } = this.props; + + return dom.div( + { + className: `heap-tree-item subtree-fetching ${ + focused ? "focused" : "" + }`, + }, + dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }), + dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }), + dom.span({ + className: "heap-tree-item-field heap-tree-item-name devtools-throbber", + style: { marginInlineStart: depth * TREE_ROW_HEIGHT }, + }) + ); + } +} + +/** + * A link to fetch and load more siblings in the dominator tree, when there are + * already many loaded above. + */ +class DominatorTreeSiblingLinkClass extends Component { + static get propTypes() { + return { + depth: PropTypes.number.isRequired, + focused: PropTypes.bool.isRequired, + item: PropTypes.instanceOf(DominatorTreeLazyChildren).isRequired, + onLoadMoreSiblings: PropTypes.func.isRequired, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.depth !== nextProps.depth || + this.props.focused !== nextProps.focused + ); + } + + render() { + const { depth, focused, item, onLoadMoreSiblings } = this.props; + + return dom.div( + { + className: `heap-tree-item more-children ${focused ? "focused" : ""}`, + }, + dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }), + dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }), + dom.span( + { + className: "heap-tree-item-field heap-tree-item-name", + style: { marginInlineStart: depth * TREE_ROW_HEIGHT }, + }, + dom.a( + { + className: "load-more-link", + onClick: () => onLoadMoreSiblings(item), + }, + L10N.getStr("tree-item.load-more") + ) + ) + ); + } +} + +class DominatorTree extends Component { + static get propTypes() { + return { + dominatorTree: dominatorTreeModel.isRequired, + onLoadMoreSiblings: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + onExpand: PropTypes.func.isRequired, + onCollapse: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + // Safe to use referential equality here because all of our mutations on + // dominator tree models use immutableUpdate in a persistent manner. The + // exception to the rule are mutations of the expanded set, however we take + // care that the dominatorTree model itself is still re-allocated when + // mutations to the expanded set occur. Because of the re-allocations, we + // can continue using referential equality here. + return this.props.dominatorTree !== nextProps.dominatorTree; + } + + render() { + const { dominatorTree, onViewSourceInDebugger, onLoadMoreSiblings } = + this.props; + + const parentMap = createParentMap(dominatorTree.root, node => node.nodeId); + + return Tree({ + key: "dominator-tree-tree", + autoExpandDepth: DOMINATOR_TREE_AUTO_EXPAND_DEPTH, + preventNavigationOnArrowRight: false, + focused: dominatorTree.focused, + getParent: node => + node instanceof DominatorTreeLazyChildren + ? parentMap[node.parentNodeId()] + : parentMap[node.nodeId], + getChildren: node => { + const children = node.children ? node.children.slice() : []; + if (node.moreChildrenAvailable) { + children.push( + new DominatorTreeLazyChildren(node.nodeId, children.length) + ); + } + return children; + }, + isExpanded: node => { + return node instanceof DominatorTreeLazyChildren + ? false + : dominatorTree.expanded.has(node.nodeId); + }, + onExpand: item => { + if (item instanceof DominatorTreeLazyChildren) { + return; + } + + if ( + item.moreChildrenAvailable && + (!item.children || !item.children.length) + ) { + const startIndex = item.children ? item.children.length : 0; + onLoadMoreSiblings( + new DominatorTreeLazyChildren(item.nodeId, startIndex) + ); + } + + this.props.onExpand(item); + }, + onCollapse: item => { + if (item instanceof DominatorTreeLazyChildren) { + return; + } + + this.props.onCollapse(item); + }, + onFocus: item => { + if (item instanceof DominatorTreeLazyChildren) { + return; + } + + this.props.onFocus(item); + }, + renderItem: (item, depth, focused, arrow, expanded) => { + if (item instanceof DominatorTreeLazyChildren) { + if (item.isFirstChild()) { + assert( + dominatorTree.state === dominatorTreeState.INCREMENTAL_FETCHING, + "If we are displaying a throbber for loading a subtree, " + + "then we should be INCREMENTAL_FETCHING those children right now" + ); + return DominatorTreeSubtreeFetching({ + key: item.key(), + depth, + focused, + }); + } + + return DominatorTreeSiblingLink({ + key: item.key(), + item, + depth, + focused, + onLoadMoreSiblings, + }); + } + + return DominatorTreeItem({ + item, + depth, + focused, + arrow, + expanded, + getPercentSize: size => + (size / dominatorTree.root.retainedSize) * 100, + onViewSourceInDebugger, + }); + }, + getRoots: () => [dominatorTree.root], + getKey: node => + node instanceof DominatorTreeLazyChildren ? node.key() : node.nodeId, + itemHeight: TREE_ROW_HEIGHT, + }); + } +} + +const DominatorTreeSubtreeFetching = createFactory( + DominatorTreeSubtreeFetchingClass +); +const DominatorTreeSiblingLink = createFactory(DominatorTreeSiblingLinkClass); + +module.exports = DominatorTree; diff --git a/devtools/client/memory/components/DominatorTreeHeader.js b/devtools/client/memory/components/DominatorTreeHeader.js new file mode 100644 index 0000000000..292b74ea60 --- /dev/null +++ b/devtools/client/memory/components/DominatorTreeHeader.js @@ -0,0 +1,51 @@ +/* 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, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { L10N } = require("resource://devtools/client/memory/utils.js"); + +class DominatorTreeHeader extends Component { + static get propTypes() { + return {}; + } + + render() { + return dom.div( + { + className: "header", + }, + + dom.span( + { + className: "heap-tree-item-bytes", + title: L10N.getStr("heapview.field.retainedSize.tooltip"), + }, + L10N.getStr("heapview.field.retainedSize") + ), + + dom.span( + { + className: "heap-tree-item-bytes", + title: L10N.getStr("heapview.field.shallowSize.tooltip"), + }, + L10N.getStr("heapview.field.shallowSize") + ), + + dom.span( + { + className: "heap-tree-item-name", + title: L10N.getStr("dominatortree.field.label.tooltip"), + }, + L10N.getStr("dominatortree.field.label") + ) + ); + } +} + +module.exports = DominatorTreeHeader; diff --git a/devtools/client/memory/components/DominatorTreeItem.js b/devtools/client/memory/components/DominatorTreeItem.js new file mode 100644 index 0000000000..59cb542a3a --- /dev/null +++ b/devtools/client/memory/components/DominatorTreeItem.js @@ -0,0 +1,174 @@ +/* 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 { + assert, + isSavedFrame, +} = require("resource://devtools/shared/DevToolsUtils.js"); +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + L10N, + formatNumber, + formatPercent, +} = require("resource://devtools/client/memory/utils.js"); +const Frame = createFactory( + require("resource://devtools/client/shared/components/Frame.js") +); +const { + TREE_ROW_HEIGHT, +} = require("resource://devtools/client/memory/constants.js"); + +class SeparatorClass extends Component { + render() { + return dom.span({ className: "separator" }, "›"); + } +} + +const Separator = createFactory(SeparatorClass); + +class DominatorTreeItem extends Component { + static get propTypes() { + return { + item: PropTypes.object.isRequired, + depth: PropTypes.number.isRequired, + arrow: PropTypes.object, + expanded: PropTypes.bool.isRequired, + focused: PropTypes.bool.isRequired, + getPercentSize: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.item != nextProps.item || + this.props.depth != nextProps.depth || + this.props.expanded != nextProps.expanded || + this.props.focused != nextProps.focused + ); + } + + render() { + const { + item, + depth, + arrow, + focused, + getPercentSize, + onViewSourceInDebugger, + } = this.props; + + const retainedSize = formatNumber(item.retainedSize); + const percentRetainedSize = formatPercent( + getPercentSize(item.retainedSize) + ); + + const shallowSize = formatNumber(item.shallowSize); + const percentShallowSize = formatPercent(getPercentSize(item.shallowSize)); + + // Build up our label UI as an array of each label piece, which is either a + // string or a frame, and separators in between them. + + assert(!!item.label.length, "Our label should not be empty"); + const label = Array(item.label.length * 2 - 1); + label.fill(undefined); + + for (let i = 0, length = item.label.length; i < length; i++) { + const piece = item.label[i]; + const key = `${item.nodeId}-label-${i}`; + + // `i` is the index of the label piece we are rendering, `label[i*2]` is + // where the rendered label piece belngs, and `label[i*2+1]` (if it isn't + // out of bounds) is where the separator belongs. + + if (isSavedFrame(piece)) { + label[i * 2] = Frame({ + key, + onClick: onViewSourceInDebugger, + frame: piece, + showFunctionName: true, + }); + } else if (piece === "noStack") { + label[i * 2] = dom.span( + { key, className: "not-available" }, + L10N.getStr("tree-item.nostack") + ); + } else if (piece === "noFilename") { + label[i * 2] = dom.span( + { key, className: "not-available" }, + L10N.getStr("tree-item.nofilename") + ); + } else if (piece === "JS::ubi::RootList") { + // Don't use the usual labeling machinery for root lists: replace it + // with the "GC Roots" string. + label.splice(0, label.length); + label.push(L10N.getStr("tree-item.rootlist")); + break; + } else { + label[i * 2] = piece; + } + + // If this is not the last piece of the label, add a separator. + if (i < length - 1) { + label[i * 2 + 1] = Separator({ key: `${item.nodeId}-separator-${i}` }); + } + } + + return dom.div( + { + className: `heap-tree-item ${focused ? "focused" : ""} node-${ + item.nodeId + }`, + }, + + dom.span( + { + className: "heap-tree-item-field heap-tree-item-bytes", + }, + dom.span( + { + className: "heap-tree-number", + }, + retainedSize + ), + dom.span({ className: "heap-tree-percent" }, percentRetainedSize) + ), + + dom.span( + { + className: "heap-tree-item-field heap-tree-item-bytes", + }, + dom.span( + { + className: "heap-tree-number", + }, + shallowSize + ), + dom.span({ className: "heap-tree-percent" }, percentShallowSize) + ), + + dom.span( + { + className: "heap-tree-item-field heap-tree-item-name", + style: { marginInlineStart: depth * TREE_ROW_HEIGHT }, + }, + arrow, + label, + dom.span( + { className: "heap-tree-item-address" }, + `@ 0x${item.nodeId.toString(16)}` + ) + ) + ); + } +} + +module.exports = DominatorTreeItem; diff --git a/devtools/client/memory/components/Heap.js b/devtools/client/memory/components/Heap.js new file mode 100644 index 0000000000..2c78a63749 --- /dev/null +++ b/devtools/client/memory/components/Heap.js @@ -0,0 +1,547 @@ +/* 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, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + assert, + safeErrorString, +} = require("resource://devtools/shared/DevToolsUtils.js"); +const Census = createFactory( + require("resource://devtools/client/memory/components/Census.js") +); +const CensusHeader = createFactory( + require("resource://devtools/client/memory/components/CensusHeader.js") +); +const DominatorTree = createFactory( + require("resource://devtools/client/memory/components/DominatorTree.js") +); +const DominatorTreeHeader = createFactory( + require("resource://devtools/client/memory/components/DominatorTreeHeader.js") +); +const TreeMap = createFactory( + require("resource://devtools/client/memory/components/TreeMap.js") +); +const HSplitBox = createFactory( + require("resource://devtools/client/shared/components/HSplitBox.js") +); +const Individuals = createFactory( + require("resource://devtools/client/memory/components/Individuals.js") +); +const IndividualsHeader = createFactory( + require("resource://devtools/client/memory/components/IndividualsHeader.js") +); +const ShortestPaths = createFactory( + require("resource://devtools/client/memory/components/ShortestPaths.js") +); +const { + getStatusTextFull, + L10N, +} = require("resource://devtools/client/memory/utils.js"); +const { + snapshotState: states, + diffingState, + viewState, + censusState, + treeMapState, + dominatorTreeState, + individualsState, +} = require("resource://devtools/client/memory/constants.js"); +const models = require("resource://devtools/client/memory/models.js"); +const { snapshot: snapshotModel, diffingModel } = models; + +/** + * Get the app state's current state atom. + * + * @see the relevant state string constants in `../constants.js`. + * + * @param {models.view} view + * @param {snapshotModel} snapshot + * @param {diffingModel} diffing + * @param {individualsModel} individuals + * + * @return {snapshotState|diffingState|dominatorTreeState} + */ +function getState(view, snapshot, diffing, individuals) { + switch (view.state) { + case viewState.CENSUS: + return snapshot.census ? snapshot.census.state : snapshot.state; + + case viewState.DIFFING: + return diffing.state; + + case viewState.TREE_MAP: + return snapshot.treeMap ? snapshot.treeMap.state : snapshot.state; + + case viewState.DOMINATOR_TREE: + return snapshot.dominatorTree + ? snapshot.dominatorTree.state + : snapshot.state; + + case viewState.INDIVIDUALS: + return individuals.state; + } + + assert(false, `Unexpected view state: ${view.state}`); + return null; +} + +/** + * Return true if we should display a status message when we are in the given + * state. Return false otherwise. + * + * @param {snapshotState|diffingState|dominatorTreeState} state + * @param {models.view} view + * @param {snapshotModel} snapshot + * + * @returns {Boolean} + */ +function shouldDisplayStatus(state, view, snapshot) { + switch (state) { + case states.IMPORTING: + case states.SAVING: + case states.SAVED: + case states.READING: + case censusState.SAVING: + case treeMapState.SAVING: + case diffingState.SELECTING: + case diffingState.TAKING_DIFF: + case dominatorTreeState.COMPUTING: + case dominatorTreeState.COMPUTED: + case dominatorTreeState.FETCHING: + case individualsState.COMPUTING_DOMINATOR_TREE: + case individualsState.FETCHING: + return true; + } + return view.state === viewState.DOMINATOR_TREE && !snapshot.dominatorTree; +} + +/** + * Get the status text to display for the given state. + * + * @param {snapshotState|diffingState|dominatorTreeState} state + * @param {diffingModel} diffing + * + * @returns {String} + */ +function getStateStatusText(state, diffing) { + if (state === diffingState.SELECTING) { + return L10N.getStr( + diffing.firstSnapshotId === null + ? "diffing.prompt.selectBaseline" + : "diffing.prompt.selectComparison" + ); + } + + return getStatusTextFull(state); +} + +/** + * Given that we should display a status message, return true if we should also + * display a throbber along with the status message. Return false otherwise. + * + * @param {diffingModel} diffing + * + * @returns {Boolean} + */ +function shouldDisplayThrobber(diffing) { + return !diffing || diffing.state !== diffingState.SELECTING; +} + +/** + * Get the current state's error, or return null if there is none. + * + * @param {snapshotModel} snapshot + * @param {diffingModel} diffing + * @param {individualsModel} individuals + * + * @returns {Error|null} + */ +function getError(snapshot, diffing, individuals) { + if (diffing) { + if (diffing.state === diffingState.ERROR) { + return diffing.error; + } + if (diffing.census === censusState.ERROR) { + return diffing.census.error; + } + } + + if (snapshot) { + if (snapshot.state === states.ERROR) { + return snapshot.error; + } + + if (snapshot.census === censusState.ERROR) { + return snapshot.census.error; + } + + if (snapshot.treeMap === treeMapState.ERROR) { + return snapshot.treeMap.error; + } + + if ( + snapshot.dominatorTree && + snapshot.dominatorTree.state === dominatorTreeState.ERROR + ) { + return snapshot.dominatorTree.error; + } + } + + if (individuals && individuals.state === individualsState.ERROR) { + return individuals.error; + } + + return null; +} + +/** + * Main view for the memory tool. + * + * The Heap component contains several panels for different states; an initial + * state of only a button to take a snapshot, loading states, the census view + * tree, the dominator tree, etc. + */ +class Heap extends Component { + static get propTypes() { + return { + onSnapshotClick: PropTypes.func.isRequired, + onLoadMoreSiblings: PropTypes.func.isRequired, + onCensusExpand: PropTypes.func.isRequired, + onCensusCollapse: PropTypes.func.isRequired, + onDominatorTreeExpand: PropTypes.func.isRequired, + onDominatorTreeCollapse: PropTypes.func.isRequired, + onCensusFocus: PropTypes.func.isRequired, + onDominatorTreeFocus: PropTypes.func.isRequired, + onShortestPathsResize: PropTypes.func.isRequired, + snapshot: snapshotModel, + onViewSourceInDebugger: PropTypes.func.isRequired, + onPopView: PropTypes.func.isRequired, + individuals: models.individuals, + onViewIndividuals: PropTypes.func.isRequired, + onFocusIndividual: PropTypes.func.isRequired, + diffing: diffingModel, + view: models.view.isRequired, + sizes: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + this._renderHeapView = this._renderHeapView.bind(this); + this._renderInitial = this._renderInitial.bind(this); + this._renderStatus = this._renderStatus.bind(this); + this._renderError = this._renderError.bind(this); + this._renderCensus = this._renderCensus.bind(this); + this._renderTreeMap = this._renderTreeMap.bind(this); + this._renderIndividuals = this._renderIndividuals.bind(this); + this._renderDominatorTree = this._renderDominatorTree.bind(this); + } + + /** + * Render the heap view's container panel with the given contents inside of + * it. + * + * @param {snapshotState|diffingState|dominatorTreeState} state + * @param {...Any} contents + */ + _renderHeapView(state, ...contents) { + return dom.div( + { + id: "heap-view", + "data-state": state, + }, + dom.div( + { + className: "heap-view-panel", + "data-state": state, + }, + ...contents + ) + ); + } + + _renderInitial(onSnapshotClick) { + return this._renderHeapView( + "initial", + dom.button( + { + className: "devtools-button take-snapshot", + onClick: onSnapshotClick, + "data-standalone": true, + }, + L10N.getStr("take-snapshot") + ) + ); + } + + _renderStatus(state, statusText, diffing) { + let throbber = ""; + if (shouldDisplayThrobber(diffing)) { + throbber = "devtools-throbber"; + } + + return this._renderHeapView( + state, + dom.span( + { + className: `snapshot-status ${throbber}`, + }, + statusText + ) + ); + } + + _renderError(state, statusText, error) { + return this._renderHeapView( + state, + dom.span({ className: "snapshot-status error" }, statusText), + dom.pre({}, safeErrorString(error)) + ); + } + + _renderCensus( + state, + census, + diffing, + onViewSourceInDebugger, + onViewIndividuals + ) { + assert( + census.report, + "Should not render census that does not have a report" + ); + + if (!census.report.children) { + const censusFilterMsg = census.filter + ? L10N.getStr("heapview.none-match") + : L10N.getStr("heapview.empty"); + const msg = diffing + ? L10N.getStr("heapview.no-difference") + : censusFilterMsg; + return this._renderHeapView(state, dom.div({ className: "empty" }, msg)); + } + + const contents = []; + + if ( + census.display.breakdown.by === "allocationStack" && + census.report.children && + census.report.children.length === 1 && + census.report.children[0].name === "noStack" + ) { + contents.push( + dom.div( + { className: "error no-allocation-stacks" }, + L10N.getStr("heapview.noAllocationStacks") + ) + ); + } + + contents.push(CensusHeader({ diffing })); + contents.push( + Census({ + onViewSourceInDebugger, + onViewIndividuals, + diffing, + census, + onExpand: node => this.props.onCensusExpand(census, node), + onCollapse: node => this.props.onCensusCollapse(census, node), + onFocus: node => this.props.onCensusFocus(census, node), + }) + ); + + return this._renderHeapView(state, ...contents); + } + + _renderTreeMap(state, treeMap) { + return this._renderHeapView(state, TreeMap({ treeMap })); + } + + _renderIndividuals( + state, + individuals, + dominatorTree, + onViewSourceInDebugger + ) { + assert( + individuals.state === individualsState.FETCHED, + "Should have fetched individuals" + ); + assert(dominatorTree?.root, "Should have a dominator tree and its root"); + + const tree = dom.div( + { + className: "vbox", + style: { + overflowY: "auto", + }, + }, + IndividualsHeader(), + Individuals({ + individuals, + dominatorTree, + onViewSourceInDebugger, + onFocus: this.props.onFocusIndividual, + }) + ); + + const shortestPaths = ShortestPaths({ + graph: individuals.focused ? individuals.focused.shortestPaths : null, + }); + + return this._renderHeapView( + state, + dom.div( + { className: "hbox devtools-toolbar" }, + dom.label( + { id: "pop-view-button-label" }, + dom.button( + { + id: "pop-view-button", + className: "devtools-button", + onClick: this.props.onPopView, + }, + L10N.getStr("toolbar.pop-view") + ), + L10N.getStr("toolbar.pop-view.label") + ), + dom.span( + { className: "toolbar-text" }, + L10N.getStr("toolbar.viewing-individuals") + ) + ), + HSplitBox({ + start: tree, + end: shortestPaths, + startWidth: this.props.sizes.shortestPathsSize, + onResize: this.props.onShortestPathsResize, + }) + ); + } + + _renderDominatorTree( + state, + onViewSourceInDebugger, + dominatorTree, + onLoadMoreSiblings + ) { + const tree = dom.div( + { + className: "vbox", + style: { + overflowY: "auto", + }, + }, + DominatorTreeHeader(), + DominatorTree({ + onViewSourceInDebugger, + dominatorTree, + onLoadMoreSiblings, + onExpand: this.props.onDominatorTreeExpand, + onCollapse: this.props.onDominatorTreeCollapse, + onFocus: this.props.onDominatorTreeFocus, + }) + ); + + const shortestPaths = ShortestPaths({ + graph: dominatorTree.focused ? dominatorTree.focused.shortestPaths : null, + }); + + return this._renderHeapView( + state, + HSplitBox({ + start: tree, + end: shortestPaths, + startWidth: this.props.sizes.shortestPathsSize, + onResize: this.props.onShortestPathsResize, + }) + ); + } + + render() { + const { + snapshot, + diffing, + onSnapshotClick, + onLoadMoreSiblings, + onViewSourceInDebugger, + onViewIndividuals, + individuals, + view, + } = this.props; + + if (!diffing && !snapshot && !individuals) { + return this._renderInitial(onSnapshotClick); + } + + const state = getState(view, snapshot, diffing, individuals); + const statusText = getStateStatusText(state, diffing); + + if (shouldDisplayStatus(state, view, snapshot)) { + return this._renderStatus(state, statusText, diffing); + } + + const error = getError(snapshot, diffing, individuals); + if (error) { + return this._renderError(state, statusText, error); + } + + if (view.state === viewState.CENSUS || view.state === viewState.DIFFING) { + const census = + view.state === viewState.CENSUS ? snapshot.census : diffing.census; + if (!census) { + return this._renderStatus(state, statusText, diffing); + } + return this._renderCensus( + state, + census, + diffing, + onViewSourceInDebugger, + onViewIndividuals + ); + } + + if (view.state === viewState.TREE_MAP) { + return this._renderTreeMap(state, snapshot.treeMap); + } + + if (view.state === viewState.INDIVIDUALS) { + assert( + individuals.state === individualsState.FETCHED, + "Should have fetched the individuals -- " + + "other states are rendered as statuses" + ); + return this._renderIndividuals( + state, + individuals, + individuals.dominatorTree, + onViewSourceInDebugger + ); + } + + assert( + view.state === viewState.DOMINATOR_TREE, + "If we aren't in progress, looking at a census, or diffing, then we " + + "must be looking at a dominator tree" + ); + assert(!diffing, "Should not have diffing"); + assert(snapshot.dominatorTree, "Should have a dominator tree"); + + return this._renderDominatorTree( + state, + onViewSourceInDebugger, + snapshot.dominatorTree, + onLoadMoreSiblings + ); + } +} + +module.exports = Heap; diff --git a/devtools/client/memory/components/Individuals.js b/devtools/client/memory/components/Individuals.js new file mode 100644 index 0000000000..dd0e9acc81 --- /dev/null +++ b/devtools/client/memory/components/Individuals.js @@ -0,0 +1,70 @@ +/* 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, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const Tree = createFactory( + require("resource://devtools/client/shared/components/VirtualizedTree.js") +); +const DominatorTreeItem = createFactory( + require("resource://devtools/client/memory/components/DominatorTreeItem.js") +); +const { + TREE_ROW_HEIGHT, +} = require("resource://devtools/client/memory/constants.js"); +const models = require("resource://devtools/client/memory/models.js"); + +/** + * The list of individuals in a census group. + */ +class Individuals extends Component { + static get propTypes() { + return { + onViewSourceInDebugger: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired, + individuals: models.individuals, + dominatorTree: models.dominatorTreeModel, + }; + } + + render() { + const { individuals, dominatorTree, onViewSourceInDebugger, onFocus } = + this.props; + + return Tree({ + key: "individuals-tree", + autoExpandDepth: 0, + preventNavigationOnArrowRight: false, + focused: individuals.focused, + getParent: node => null, + getChildren: node => [], + isExpanded: node => false, + onExpand: () => {}, + onCollapse: () => {}, + onFocus, + renderItem: (item, depth, focused, _, expanded) => { + return DominatorTreeItem({ + item, + depth, + focused, + arrow: undefined, + expanded, + getPercentSize: size => + (size / dominatorTree.root.retainedSize) * 100, + onViewSourceInDebugger, + }); + }, + getRoots: () => individuals.nodes, + getKey: node => node.nodeId, + itemHeight: TREE_ROW_HEIGHT, + }); + } +} + +module.exports = Individuals; diff --git a/devtools/client/memory/components/IndividualsHeader.js b/devtools/client/memory/components/IndividualsHeader.js new file mode 100644 index 0000000000..abd5a81a36 --- /dev/null +++ b/devtools/client/memory/components/IndividualsHeader.js @@ -0,0 +1,51 @@ +/* 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, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { L10N } = require("resource://devtools/client/memory/utils.js"); + +class IndividualsHeader extends Component { + static get propTypes() { + return {}; + } + + render() { + return dom.div( + { + className: "header", + }, + + dom.span( + { + className: "heap-tree-item-bytes", + title: L10N.getStr("heapview.field.retainedSize.tooltip"), + }, + L10N.getStr("heapview.field.retainedSize") + ), + + dom.span( + { + className: "heap-tree-item-bytes", + title: L10N.getStr("heapview.field.shallowSize.tooltip"), + }, + L10N.getStr("heapview.field.shallowSize") + ), + + dom.span( + { + className: "heap-tree-item-name", + title: L10N.getStr("individuals.field.node.tooltip"), + }, + L10N.getStr("individuals.field.node") + ) + ); + } +} + +module.exports = IndividualsHeader; diff --git a/devtools/client/memory/components/List.js b/devtools/client/memory/components/List.js new file mode 100644 index 0000000000..290b178b7e --- /dev/null +++ b/devtools/client/memory/components/List.js @@ -0,0 +1,46 @@ +/* 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, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +/** + * Generic list component that takes another react component to represent + * the children nodes as `itemComponent`, and a list of items to render + * as that component with a click handler. + */ +class List extends Component { + static get propTypes() { + return { + itemComponent: PropTypes.any.isRequired, + onClick: PropTypes.func, + items: PropTypes.array.isRequired, + }; + } + + render() { + const { items, onClick, itemComponent: Item } = this.props; + + return dom.ul( + { className: "list" }, + ...items.map((item, index) => { + return Item( + Object.assign({}, this.props, { + key: index, + item, + index, + onClick: () => onClick(item), + }) + ); + }) + ); + } +} + +module.exports = List; diff --git a/devtools/client/memory/components/ShortestPaths.js b/devtools/client/memory/components/ShortestPaths.js new file mode 100644 index 0000000000..0dd6e22c33 --- /dev/null +++ b/devtools/client/memory/components/ShortestPaths.js @@ -0,0 +1,196 @@ +/* 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, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { isSavedFrame } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + getSourceNames, +} = require("resource://devtools/client/shared/source-utils.js"); +const { L10N } = require("resource://devtools/client/memory/utils.js"); + +const GRAPH_DEFAULTS = { + translate: [20, 20], + scale: 1, +}; + +const NO_STACK = "noStack"; +const NO_FILENAME = "noFilename"; +const ROOT_LIST = "JS::ubi::RootList"; + +function stringifyLabel(label, id) { + const sanitized = []; + + for (let i = 0, length = label.length; i < length; i++) { + const piece = label[i]; + + if (isSavedFrame(piece)) { + const { short } = getSourceNames(piece.source); + sanitized[i] = + `${piece.functionDisplayName} @ ` + + `${short}:${piece.line}:${piece.column}`; + } else if (piece === NO_STACK) { + sanitized[i] = L10N.getStr("tree-item.nostack"); + } else if (piece === NO_FILENAME) { + sanitized[i] = L10N.getStr("tree-item.nofilename"); + } else if (piece === ROOT_LIST) { + // Don't use the usual labeling machinery for root lists: replace it + // with the "GC Roots" string. + sanitized.splice(0, label.length); + sanitized.push(L10N.getStr("tree-item.rootlist")); + break; + } else { + sanitized[i] = "" + piece; + } + } + + return `${sanitized.join(" › ")} @ 0x${id.toString(16)}`; +} + +class ShortestPaths extends Component { + static get propTypes() { + return { + graph: PropTypes.shape({ + nodes: PropTypes.arrayOf(PropTypes.object), + edges: PropTypes.arrayOf(PropTypes.object), + }), + }; + } + + constructor(props) { + super(props); + this.state = { zoom: null }; + this._renderGraph = this._renderGraph.bind(this); + } + + componentDidMount() { + if (this.props.graph) { + this._renderGraph(this.refs.container, this.props.graph); + } + } + + shouldComponentUpdate(nextProps) { + return this.props.graph != nextProps.graph; + } + + componentDidUpdate() { + if (this.props.graph) { + this._renderGraph(this.refs.container, this.props.graph); + } + } + + componentWillUnmount() { + if (this.state.zoom) { + this.state.zoom.on("zoom", null); + } + } + + _renderGraph(container, { nodes, edges }) { + if (!container.firstChild) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("id", "graph-svg"); + svg.setAttribute("xlink", "http://www.w3.org/1999/xlink"); + svg.style.width = "100%"; + svg.style.height = "100%"; + + const target = document.createElementNS( + "http://www.w3.org/2000/svg", + "g" + ); + target.setAttribute("id", "graph-target"); + target.style.width = "100%"; + target.style.height = "100%"; + + svg.appendChild(target); + container.appendChild(svg); + } + + const graph = new dagreD3.Digraph(); + + for (let i = 0; i < nodes.length; i++) { + graph.addNode(nodes[i].id, { + id: nodes[i].id, + label: stringifyLabel(nodes[i].label, nodes[i].id), + }); + } + + for (let i = 0; i < edges.length; i++) { + graph.addEdge(null, edges[i].from, edges[i].to, { + label: edges[i].name, + }); + } + + const renderer = new dagreD3.Renderer(); + renderer.drawNodes(); + renderer.drawEdgePaths(); + + const svg = d3.select("#graph-svg"); + const target = d3.select("#graph-target"); + + let zoom = this.state.zoom; + if (!zoom) { + zoom = d3.behavior.zoom().on("zoom", function () { + target.attr( + "transform", + `translate(${d3.event.translate}) scale(${d3.event.scale})` + ); + }); + svg.call(zoom); + this.setState({ zoom }); + } + + const { translate, scale } = GRAPH_DEFAULTS; + zoom.scale(scale); + zoom.translate(translate); + target.attr("transform", `translate(${translate}) scale(${scale})`); + + const layout = dagreD3.layout(); + renderer.layout(layout).run(graph, target); + } + + render() { + let contents; + if (this.props.graph) { + // Let the componentDidMount or componentDidUpdate method draw the graph + // with DagreD3. We just provide the container for the graph here. + contents = dom.div({ + ref: "container", + style: { + flex: 1, + height: "100%", + width: "100%", + }, + }); + } else { + contents = dom.div( + { + id: "shortest-paths-select-node-msg", + }, + L10N.getStr("shortest-paths.select-node") + ); + } + + return dom.div( + { + id: "shortest-paths", + className: "vbox", + }, + dom.label( + { + id: "shortest-paths-header", + className: "header", + }, + L10N.getStr("shortest-paths.header") + ), + contents + ); + } +} + +module.exports = ShortestPaths; diff --git a/devtools/client/memory/components/SnapshotListItem.js b/devtools/client/memory/components/SnapshotListItem.js new file mode 100644 index 0000000000..5a2c24b4e4 --- /dev/null +++ b/devtools/client/memory/components/SnapshotListItem.js @@ -0,0 +1,142 @@ +/* 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, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + L10N, + getSnapshotTitle, + getSnapshotTotals, + getStatusText, + snapshotIsDiffable, + getSavedCensus, +} = require("resource://devtools/client/memory/utils.js"); +const { + diffingState, +} = require("resource://devtools/client/memory/constants.js"); +const { + snapshot: snapshotModel, + app: appModel, +} = require("resource://devtools/client/memory/models.js"); + +class SnapshotListItem extends Component { + static get propTypes() { + return { + onClick: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + item: snapshotModel.isRequired, + index: PropTypes.number.isRequired, + diffing: appModel.diffing, + }; + } + + render() { + const { item: snapshot, onClick, onSave, onDelete, diffing } = this.props; + let className = `snapshot-list-item ${ + snapshot.selected ? " selected" : "" + }`; + let statusText = getStatusText(snapshot.state); + let wantThrobber = !!statusText; + const title = getSnapshotTitle(snapshot); + + const selectedForDiffing = + diffing && + (diffing.firstSnapshotId === snapshot.id || + diffing.secondSnapshotId === snapshot.id); + + let checkbox; + if (diffing && snapshotIsDiffable(snapshot)) { + if (diffing.state === diffingState.SELECTING) { + wantThrobber = false; + } + + const checkboxAttrs = { + type: "checkbox", + checked: false, + }; + + if (selectedForDiffing) { + checkboxAttrs.checked = true; + checkboxAttrs.disabled = true; + className += " selected"; + statusText = L10N.getStr( + diffing.firstSnapshotId === snapshot.id + ? "diffing.baseline" + : "diffing.comparison" + ); + } + + if (selectedForDiffing || diffing.state == diffingState.SELECTING) { + checkbox = dom.input(checkboxAttrs); + } + } + + let details; + if (!selectedForDiffing) { + // See if a tree map or census is in the read state. + const census = getSavedCensus(snapshot); + + // If there is census data, fill in the total bytes. + if (census) { + const { bytes } = getSnapshotTotals(census); + const formatBytes = L10N.getFormatStr( + "aggregate.mb", + L10N.numberWithDecimals(bytes / 1000000, 2) + ); + + details = dom.span( + { className: "snapshot-totals" }, + dom.span({ className: "total-bytes" }, formatBytes) + ); + } + } + if (!details) { + details = dom.span({ className: "snapshot-state" }, statusText); + } + + const saveLink = !snapshot.path + ? void 0 + : dom.a( + { + onClick: () => onSave(snapshot), + className: "save", + }, + L10N.getStr("snapshot.io.save") + ); + + const deleteButton = !snapshot.path + ? void 0 + : dom.button({ + onClick: event => { + event.stopPropagation(); + onDelete(snapshot); + }, + className: "delete", + title: L10N.getStr("snapshot.io.delete"), + }); + + return dom.li( + { className, onClick }, + dom.span( + { + className: `snapshot-title ${ + wantThrobber ? " devtools-throbber" : "" + }`, + }, + checkbox, + title, + deleteButton + ), + dom.span({ className: "snapshot-info" }, details, saveLink) + ); + } +} + +module.exports = SnapshotListItem; diff --git a/devtools/client/memory/components/Toolbar.js b/devtools/client/memory/components/Toolbar.js new file mode 100644 index 0000000000..712a04abfe --- /dev/null +++ b/devtools/client/memory/components/Toolbar.js @@ -0,0 +1,309 @@ +/* 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 { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { L10N } = require("resource://devtools/client/memory/utils.js"); +const models = require("resource://devtools/client/memory/models.js"); +const { viewState } = require("resource://devtools/client/memory/constants.js"); + +class Toolbar extends Component { + static get propTypes() { + return { + censusDisplays: PropTypes.arrayOf(models.censusDisplay).isRequired, + censusDisplay: models.censusDisplay.isRequired, + onTakeSnapshotClick: PropTypes.func.isRequired, + onImportClick: PropTypes.func.isRequired, + onClearSnapshotsClick: PropTypes.func.isRequired, + onCensusDisplayChange: PropTypes.func.isRequired, + onToggleRecordAllocationStacks: PropTypes.func.isRequired, + allocations: models.allocations, + filterString: PropTypes.string, + setFilterString: PropTypes.func.isRequired, + diffing: models.diffingModel, + onToggleDiffing: PropTypes.func.isRequired, + view: models.view.isRequired, + onViewChange: PropTypes.func.isRequired, + labelDisplays: PropTypes.arrayOf(models.labelDisplay).isRequired, + labelDisplay: models.labelDisplay.isRequired, + onLabelDisplayChange: PropTypes.func.isRequired, + treeMapDisplays: PropTypes.arrayOf(models.treeMapDisplay).isRequired, + onTreeMapDisplayChange: PropTypes.func.isRequired, + snapshots: PropTypes.arrayOf(models.snapshot).isRequired, + }; + } + + render() { + const { + onTakeSnapshotClick, + onImportClick, + onClearSnapshotsClick, + onCensusDisplayChange, + censusDisplays, + censusDisplay, + labelDisplays, + labelDisplay, + onLabelDisplayChange, + treeMapDisplays, + onTreeMapDisplayChange, + onToggleRecordAllocationStacks, + allocations, + filterString, + setFilterString, + snapshots, + diffing, + onToggleDiffing, + view, + onViewChange, + } = this.props; + + let viewToolbarOptions; + if (view.state == viewState.CENSUS || view.state === viewState.DIFFING) { + viewToolbarOptions = dom.div( + { + className: "toolbar-group", + }, + + dom.label( + { + className: "display-by", + title: L10N.getStr("toolbar.displayBy.tooltip"), + }, + L10N.getStr("toolbar.displayBy"), + dom.select( + { + id: "select-display", + className: "devtools-toolbar-select select-display", + onChange: e => { + const newDisplay = censusDisplays.find( + b => b.displayName === e.target.value + ); + onCensusDisplayChange(newDisplay); + }, + value: censusDisplay.displayName, + }, + censusDisplays.map(({ tooltip, displayName }) => + dom.option( + { + key: `display-${displayName}`, + value: displayName, + title: tooltip, + }, + displayName + ) + ) + ) + ), + + dom.span({ className: "devtools-separator" }), + + dom.input({ + id: "filter", + type: "search", + className: "devtools-filterinput", + placeholder: L10N.getStr("filter.placeholder"), + title: L10N.getStr("filter.tooltip"), + onChange: event => setFilterString(event.target.value), + value: filterString || undefined, + }) + ); + } else if (view.state == viewState.TREE_MAP) { + assert( + treeMapDisplays.length >= 1, + "Should always have at least one tree map display" + ); + + // Only show the dropdown if there are multiple display options + viewToolbarOptions = + treeMapDisplays.length > 1 + ? dom.div( + { + className: "toolbar-group", + }, + + dom.label( + { + className: "display-by", + title: L10N.getStr("toolbar.displayBy.tooltip"), + }, + L10N.getStr("toolbar.displayBy"), + dom.select( + { + id: "select-tree-map-display", + onChange: e => { + const newDisplay = treeMapDisplays.find( + b => b.displayName === e.target.value + ); + onTreeMapDisplayChange(newDisplay); + }, + }, + treeMapDisplays.map(({ tooltip, displayName }) => + dom.option( + { + key: `tree-map-display-${displayName}`, + value: displayName, + title: tooltip, + }, + displayName + ) + ) + ) + ) + ) + : null; + } else { + assert( + view.state === viewState.DOMINATOR_TREE || + view.state === viewState.INDIVIDUALS + ); + + viewToolbarOptions = dom.div( + { + className: "toolbar-group", + }, + + dom.label( + { + className: "label-by", + title: L10N.getStr("toolbar.labelBy.tooltip"), + }, + L10N.getStr("toolbar.labelBy"), + dom.select( + { + id: "select-label-display", + className: "devtools-toolbar-select select-label-display", + onChange: e => { + const newDisplay = labelDisplays.find( + b => b.displayName === e.target.value + ); + onLabelDisplayChange(newDisplay); + }, + value: labelDisplay.displayName, + }, + labelDisplays.map(({ tooltip, displayName }) => + dom.option( + { + key: `label-display-${displayName}`, + value: displayName, + title: tooltip, + }, + displayName + ) + ) + ) + ) + ); + } + + let viewSelect; + if ( + view.state !== viewState.DIFFING && + view.state !== viewState.INDIVIDUALS + ) { + viewSelect = dom.label( + { + title: L10N.getStr("toolbar.view.tooltip"), + }, + L10N.getStr("toolbar.view"), + dom.select( + { + id: "select-view", + className: "devtools-toolbar-select select-view", + onChange: e => onViewChange(e.target.value), + value: view.state, + }, + dom.option( + { + value: viewState.TREE_MAP, + title: L10N.getStr("toolbar.view.treemap.tooltip"), + }, + L10N.getStr("toolbar.view.treemap") + ), + dom.option( + { + value: viewState.CENSUS, + title: L10N.getStr("toolbar.view.census.tooltip"), + }, + L10N.getStr("toolbar.view.census") + ), + dom.option( + { + value: viewState.DOMINATOR_TREE, + title: L10N.getStr("toolbar.view.dominators.tooltip"), + }, + L10N.getStr("toolbar.view.dominators") + ) + ) + ); + } + + return dom.div( + { + className: "devtools-toolbar", + }, + + dom.div( + { + className: "toolbar-group", + }, + + dom.button({ + id: "clear-snapshots", + className: "clear-snapshots devtools-button", + disabled: !snapshots.length, + onClick: onClearSnapshotsClick, + title: L10N.getStr("clear-snapshots.tooltip"), + }), + + dom.button({ + id: "take-snapshot", + className: "take-snapshot devtools-button", + onClick: onTakeSnapshotClick, + title: L10N.getStr("take-snapshot"), + }), + + dom.button({ + id: "diff-snapshots", + className: + "devtools-button devtools-monospace" + (diffing ? " checked" : ""), + disabled: snapshots.length < 2, + onClick: onToggleDiffing, + title: L10N.getStr("diff-snapshots.tooltip"), + }), + + dom.button({ + id: "import-snapshot", + className: "import-snapshot devtools-button", + onClick: onImportClick, + title: L10N.getStr("import-snapshot"), + }) + ), + + dom.label( + { + id: "record-allocation-stacks-label", + title: L10N.getStr("checkbox.recordAllocationStacks.tooltip"), + }, + dom.input({ + id: "record-allocation-stacks-checkbox", + type: "checkbox", + checked: allocations.recording, + disabled: allocations.togglingInProgress, + onChange: onToggleRecordAllocationStacks, + }), + L10N.getStr("checkbox.recordAllocationStacks") + ), + + viewSelect, + viewToolbarOptions + ); + } +} + +module.exports = Toolbar; diff --git a/devtools/client/memory/components/TreeMap.js b/devtools/client/memory/components/TreeMap.js new file mode 100644 index 0000000000..b9a3c39495 --- /dev/null +++ b/devtools/client/memory/components/TreeMap.js @@ -0,0 +1,77 @@ +/* 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, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { treeMapModel } = require("resource://devtools/client/memory/models.js"); +const startVisualization = require("resource://devtools/client/memory/components/tree-map/start.js"); + +class TreeMap extends Component { + static get propTypes() { + return { + treeMap: treeMapModel, + }; + } + + constructor(props) { + super(props); + this.state = {}; + this._stopVisualization = this._stopVisualization.bind(this); + this._startVisualization = this._startVisualization.bind(this); + } + + componentDidMount() { + const { treeMap } = this.props; + if (treeMap?.report) { + this._startVisualization(); + } + } + + shouldComponentUpdate(nextProps) { + const oldTreeMap = this.props.treeMap; + const newTreeMap = nextProps.treeMap; + return oldTreeMap !== newTreeMap; + } + + componentDidUpdate(prevProps) { + this._stopVisualization(); + + if (this.props.treeMap && this.props.treeMap.report) { + this._startVisualization(); + } + } + + componentWillUnmount() { + if (this.state.stopVisualization) { + this.state.stopVisualization(); + } + } + + _stopVisualization() { + if (this.state.stopVisualization) { + this.state.stopVisualization(); + this.setState({ stopVisualization: null }); + } + } + + _startVisualization() { + const { container } = this.refs; + const { report } = this.props.treeMap; + const stopVisualization = startVisualization(container, report); + this.setState({ stopVisualization }); + } + + render() { + return dom.div({ + ref: "container", + className: "tree-map-container", + }); + } +} + +module.exports = TreeMap; diff --git a/devtools/client/memory/components/moz.build b/devtools/client/memory/components/moz.build new file mode 100644 index 0000000000..82739bf97f --- /dev/null +++ b/devtools/client/memory/components/moz.build @@ -0,0 +1,25 @@ +# 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 += [ + "tree-map", +] + +DevToolsModules( + "Census.js", + "CensusHeader.js", + "CensusTreeItem.js", + "DominatorTree.js", + "DominatorTreeHeader.js", + "DominatorTreeItem.js", + "Heap.js", + "Individuals.js", + "IndividualsHeader.js", + "List.js", + "ShortestPaths.js", + "SnapshotListItem.js", + "Toolbar.js", + "TreeMap.js", +) diff --git a/devtools/client/memory/components/tree-map/canvas-utils.js b/devtools/client/memory/components/tree-map/canvas-utils.js new file mode 100644 index 0000000000..e1bf252057 --- /dev/null +++ b/devtools/client/memory/components/tree-map/canvas-utils.js @@ -0,0 +1,132 @@ +/* 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-env browser */ + +"use strict"; + +/** + * Create 2 canvases and contexts for drawing onto, 1 main canvas, and 1 zoom + * canvas. The main canvas dimensions match the parent div, but the CSS can be + * transformed to be zoomed and dragged around (potentially creating a blurry + * canvas once zoomed in). The zoom canvas is a zoomed in section that matches + * the parent div's dimensions and is kept in place through CSS. A zoomed in + * view of the visualization is drawn onto this canvas, providing a crisp zoomed + * in view of the tree map. + */ +const { debounce } = require("resource://devtools/shared/debounce.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const FULLSCREEN_STYLE = { + width: "100%", + height: "100%", + position: "absolute", +}; + +/** + * Create the canvases, resize handlers, and return references to them all + * + * @param {HTMLDivElement} parentEl + * @param {Number} debounceRate + * @return {Object} + */ +function Canvases(parentEl, debounceRate) { + EventEmitter.decorate(this); + this.container = createContainingDiv(parentEl); + + // This canvas contains all of the treemap + this.main = createCanvas(this.container, "main"); + // This canvas contains only the zoomed in portion, overlaying the main canvas + this.zoom = createCanvas(this.container, "zoom"); + + this.removeHandlers = handleResizes(this, debounceRate); +} + +Canvases.prototype = { + /** + * Remove the handlers and elements + * + * @return {type} description + */ + destroy() { + this.removeHandlers(); + this.container.removeChild(this.main.canvas); + this.container.removeChild(this.zoom.canvas); + }, +}; + +module.exports = Canvases; + +/** + * Create the containing div + * + * @param {HTMLDivElement} parentEl + * @return {HTMLDivElement} + */ +function createContainingDiv(parentEl) { + const div = parentEl.ownerDocument.createElementNS(HTML_NS, "div"); + Object.assign(div.style, FULLSCREEN_STYLE); + parentEl.appendChild(div); + return div; +} + +/** + * Create a canvas and context + * + * @param {HTMLDivElement} container + * @param {String} className + * @return {Object} { canvas, ctx } + */ +function createCanvas(container, className) { + const window = container.ownerDocument.defaultView; + const canvas = container.ownerDocument.createElementNS(HTML_NS, "canvas"); + container.appendChild(canvas); + canvas.width = container.offsetWidth * window.devicePixelRatio; + canvas.height = container.offsetHeight * window.devicePixelRatio; + canvas.className = className; + + Object.assign(canvas.style, FULLSCREEN_STYLE, { + pointerEvents: "none", + }); + + const ctx = canvas.getContext("2d"); + + return { canvas, ctx }; +} + +/** + * Resize the canvases' resolutions, and fires out the onResize callback + * + * @param {HTMLDivElement} container + * @param {Object} canvases + * @param {Number} debounceRate + */ +function handleResizes(canvases, debounceRate) { + const { container, main, zoom } = canvases; + const window = container.ownerDocument.defaultView; + + function resize() { + const width = container.offsetWidth * window.devicePixelRatio; + const height = container.offsetHeight * window.devicePixelRatio; + + main.canvas.width = width; + main.canvas.height = height; + zoom.canvas.width = width; + zoom.canvas.height = height; + + canvases.emit("resize"); + } + + // Tests may not need debouncing + const debouncedResize = + debounceRate > 0 ? debounce(resize, debounceRate) : resize; + + window.addEventListener("resize", debouncedResize); + resize(); + + return function removeResizeHandlers() { + window.removeEventListener("resize", debouncedResize); + }; +} diff --git a/devtools/client/memory/components/tree-map/color-coarse-type.js b/devtools/client/memory/components/tree-map/color-coarse-type.js new file mode 100644 index 0000000000..b511f9f50e --- /dev/null +++ b/devtools/client/memory/components/tree-map/color-coarse-type.js @@ -0,0 +1,70 @@ +/* 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"; + +/** + * Color the boxes in the treemap + */ + +const TYPES = ["objects", "other", "strings", "scripts", "domNode"]; + +// The factors determine how much the hue shifts +const TYPE_FACTOR = TYPES.length * 3; +const DEPTH_FACTOR = -10; +const H = 0.5; +const S = 0.6; +const L = 0.9; + +/** + * Recursively find the index of the coarse type of a node + * + * @param {Object} node + * d3 treemap + * @return {Integer} + * index + */ +function findCoarseTypeIndex(node) { + const index = TYPES.indexOf(node.name); + + if (node.parent) { + return index === -1 ? findCoarseTypeIndex(node.parent) : index; + } + + return TYPES.indexOf("other"); +} + +/** + * Decide a color value for depth to be used in the HSL computation + * + * @param {Object} node + * @return {Number} + */ +function depthColorFactor(node) { + return Math.min(1, node.depth / DEPTH_FACTOR); +} + +/** + * Decide a color value for type to be used in the HSL computation + * + * @param {Object} node + * @return {Number} + */ +function typeColorFactor(node) { + return findCoarseTypeIndex(node) / TYPE_FACTOR; +} + +/** + * Color a node + * + * @param {Object} node + * @return {Array} HSL values ranged 0-1 + */ +module.exports = function colorCoarseType(node) { + const h = Math.min(1, H + typeColorFactor(node)); + const s = Math.min(1, S); + const l = Math.min(1, L + depthColorFactor(node)); + + return [h, s, l]; +}; diff --git a/devtools/client/memory/components/tree-map/drag-zoom.js b/devtools/client/memory/components/tree-map/drag-zoom.js new file mode 100644 index 0000000000..034017e086 --- /dev/null +++ b/devtools/client/memory/components/tree-map/drag-zoom.js @@ -0,0 +1,337 @@ +/* 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 { debounce } = require("resource://devtools/shared/debounce.js"); +const { lerp } = require("resource://devtools/client/memory/utils.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +const LERP_SPEED = 0.5; +const ZOOM_SPEED = 0.01; +const TRANSLATE_EPSILON = 1; +const ZOOM_EPSILON = 0.001; +const LINE_SCROLL_MODE = 1; +const SCROLL_LINE_SIZE = 15; + +/** + * DragZoom is a constructor that contains the state of the current dragging and + * zooming behavior. It sets the scrolling and zooming behaviors. + * + * @param {HTMLElement} container description + * The container for the canvases + */ +function DragZoom(container, debounceRate, requestAnimationFrame) { + EventEmitter.decorate(this); + + this.isDragging = false; + + // The current mouse position + this.mouseX = container.offsetWidth / 2; + this.mouseY = container.offsetHeight / 2; + + // The total size of the visualization after being zoomed, in pixels + this.zoomedWidth = container.offsetWidth; + this.zoomedHeight = container.offsetHeight; + + // How much the visualization has been zoomed in + this.zoom = 0; + + // The offset of visualization from the container. This is applied after + // the zoom, and the visualization by default is centered + this.translateX = 0; + this.translateY = 0; + + // The size of the offset between the top/left of the container, and the + // top/left of the containing element. This value takes into account + // the device pixel ratio for canvas draws. + this.offsetX = 0; + this.offsetY = 0; + + // The smoothed values that are animated and eventually match the target + // values. The values are updated by the update loop + this.smoothZoom = 0; + this.smoothTranslateX = 0; + this.smoothTranslateY = 0; + + // Add the constant values for testing purposes + this.ZOOM_SPEED = ZOOM_SPEED; + this.ZOOM_EPSILON = ZOOM_EPSILON; + + const update = createUpdateLoop(container, this, requestAnimationFrame); + + this.destroy = setHandlers(this, container, update, debounceRate); +} + +module.exports = DragZoom; + +/** + * Returns an update loop. This loop smoothly updates the visualization when + * actions are performed. Once the animations have reached their target values + * the animation loop is stopped. + * + * Any value in the `dragZoom` object that starts with "smooth" is the + * smoothed version of a value that is interpolating toward the target value. + * For instance `dragZoom.smoothZoom` approaches `dragZoom.zoom` on each + * iteration of the update loop until it's sufficiently close as defined by + * the epsilon values. + * + * Only these smoothed values and the container CSS are updated by the loop. + * + * @param {HTMLDivElement} container + * @param {Object} dragZoom + * The values that represent the current dragZoom state + * @param {Function} requestAnimationFrame + */ +function createUpdateLoop(container, dragZoom, requestAnimationFrame) { + let isLooping = false; + + function update() { + const isScrollChanging = + Math.abs(dragZoom.smoothZoom - dragZoom.zoom) > ZOOM_EPSILON; + const isTranslateChanging = + Math.abs(dragZoom.smoothTranslateX - dragZoom.translateX) > + TRANSLATE_EPSILON || + Math.abs(dragZoom.smoothTranslateY - dragZoom.translateY) > + TRANSLATE_EPSILON; + + isLooping = isScrollChanging || isTranslateChanging; + + if (isScrollChanging) { + dragZoom.smoothZoom = lerp( + dragZoom.smoothZoom, + dragZoom.zoom, + LERP_SPEED + ); + } else { + dragZoom.smoothZoom = dragZoom.zoom; + } + + if (isTranslateChanging) { + dragZoom.smoothTranslateX = lerp( + dragZoom.smoothTranslateX, + dragZoom.translateX, + LERP_SPEED + ); + dragZoom.smoothTranslateY = lerp( + dragZoom.smoothTranslateY, + dragZoom.translateY, + LERP_SPEED + ); + } else { + dragZoom.smoothTranslateX = dragZoom.translateX; + dragZoom.smoothTranslateY = dragZoom.translateY; + } + + const zoom = 1 + dragZoom.smoothZoom; + const x = dragZoom.smoothTranslateX; + const y = dragZoom.smoothTranslateY; + container.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`; + + if (isLooping) { + requestAnimationFrame(update); + } + } + + // Go ahead and start the update loop + update(); + + return function restartLoopingIfStopped() { + if (!isLooping) { + update(); + } + }; +} + +/** + * Set the various event listeners and return a function to remove them + * + * @param {Object} dragZoom + * @param {HTMLElement} container + * @param {Function} update + * @return {Function} The function to remove the handlers + */ +function setHandlers(dragZoom, container, update, debounceRate) { + const emitChanged = debounce(() => dragZoom.emit("change"), debounceRate); + + const removeDragHandlers = setDragHandlers( + container, + dragZoom, + emitChanged, + update + ); + const removeScrollHandlers = setScrollHandlers( + container, + dragZoom, + emitChanged, + update + ); + + return function removeHandlers() { + removeDragHandlers(); + removeScrollHandlers(); + }; +} + +/** + * Sets handlers for when the user drags on the canvas. It will update dragZoom + * object with new translate and offset values. + * + * @param {HTMLElement} container + * @param {Object} dragZoom + * @param {Function} changed + * @param {Function} update + */ +function setDragHandlers(container, dragZoom, emitChanged, update) { + const parentEl = container.parentElement; + + function startDrag() { + dragZoom.isDragging = true; + container.style.cursor = "grabbing"; + } + + function stopDrag() { + dragZoom.isDragging = false; + container.style.cursor = "grab"; + } + + function drag(event) { + const prevMouseX = dragZoom.mouseX; + const prevMouseY = dragZoom.mouseY; + + dragZoom.mouseX = event.clientX - parentEl.offsetLeft; + dragZoom.mouseY = event.clientY - parentEl.offsetTop; + + if (!dragZoom.isDragging) { + return; + } + + dragZoom.translateX += dragZoom.mouseX - prevMouseX; + dragZoom.translateY += dragZoom.mouseY - prevMouseY; + + keepInView(container, dragZoom); + + emitChanged(); + update(); + } + + parentEl.addEventListener("mousedown", startDrag); + parentEl.addEventListener("mouseup", stopDrag); + parentEl.addEventListener("mouseout", stopDrag); + parentEl.addEventListener("mousemove", drag); + + return function removeListeners() { + parentEl.removeEventListener("mousedown", startDrag); + parentEl.removeEventListener("mouseup", stopDrag); + parentEl.removeEventListener("mouseout", stopDrag); + parentEl.removeEventListener("mousemove", drag); + }; +} + +/** + * Sets the handlers for when the user scrolls. It updates the dragZoom object + * and keeps the canvases all within the view. After changing values update + * loop is called, and the changed event is emitted. + * + * @param {HTMLDivElement} container + * @param {Object} dragZoom + * @param {Function} changed + * @param {Function} update + */ +function setScrollHandlers(container, dragZoom, emitChanged, update) { + const window = container.ownerDocument.defaultView; + + function handleWheel(event) { + event.preventDefault(); + + if (dragZoom.isDragging) { + return; + } + + // Update the zoom level + const scrollDelta = getScrollDelta(event, window); + const prevZoom = dragZoom.zoom; + dragZoom.zoom = Math.max(0, dragZoom.zoom - scrollDelta * ZOOM_SPEED); + + // Calculate the updated width and height + const prevZoomedWidth = container.offsetWidth * (1 + prevZoom); + const prevZoomedHeight = container.offsetHeight * (1 + prevZoom); + dragZoom.zoomedWidth = container.offsetWidth * (1 + dragZoom.zoom); + dragZoom.zoomedHeight = container.offsetHeight * (1 + dragZoom.zoom); + const deltaWidth = dragZoom.zoomedWidth - prevZoomedWidth; + const deltaHeight = dragZoom.zoomedHeight - prevZoomedHeight; + + const mouseOffsetX = dragZoom.mouseX - container.offsetWidth / 2; + const mouseOffsetY = dragZoom.mouseY - container.offsetHeight / 2; + + // The ratio of where the center of the mouse is in regards to the total + // zoomed width/height + const ratioZoomX = + (prevZoomedWidth / 2 + mouseOffsetX - dragZoom.translateX) / + prevZoomedWidth; + const ratioZoomY = + (prevZoomedHeight / 2 + mouseOffsetY - dragZoom.translateY) / + prevZoomedHeight; + + // Distribute the change in width and height based on the above ratio + dragZoom.translateX -= lerp(-deltaWidth / 2, deltaWidth / 2, ratioZoomX); + dragZoom.translateY -= lerp(-deltaHeight / 2, deltaHeight / 2, ratioZoomY); + + // Keep the canvas in range of the container + keepInView(container, dragZoom); + emitChanged(); + update(); + } + + container.addEventListener("wheel", handleWheel); + + return function removeListener() { + container.removeEventListener("wheel", handleWheel); + }; +} + +/** + * Account for the various mouse wheel event types, per pixel or per line + * + * @param {WheelEvent} event + * @param {Window} window + * @return {Number} The scroll size in pixels + */ +function getScrollDelta(event, window) { + if (event.deltaMode === LINE_SCROLL_MODE) { + // Update by a fixed arbitrary value to normalize scroll types + return event.deltaY * SCROLL_LINE_SIZE; + } + return event.deltaY; +} + +/** + * Keep the dragging and zooming within the view by updating the values in the + * `dragZoom` object. + * + * @param {HTMLDivElement} container + * @param {Object} dragZoom + */ +function keepInView(container, dragZoom) { + const { devicePixelRatio } = container.ownerDocument.defaultView; + const overdrawX = (dragZoom.zoomedWidth - container.offsetWidth) / 2; + const overdrawY = (dragZoom.zoomedHeight - container.offsetHeight) / 2; + + dragZoom.translateX = Math.max( + -overdrawX, + Math.min(overdrawX, dragZoom.translateX) + ); + dragZoom.translateY = Math.max( + -overdrawY, + Math.min(overdrawY, dragZoom.translateY) + ); + + dragZoom.offsetX = + devicePixelRatio * + ((dragZoom.zoomedWidth - container.offsetWidth) / 2 - dragZoom.translateX); + dragZoom.offsetY = + devicePixelRatio * + ((dragZoom.zoomedHeight - container.offsetHeight) / 2 - + dragZoom.translateY); +} diff --git a/devtools/client/memory/components/tree-map/draw.js b/devtools/client/memory/components/tree-map/draw.js new file mode 100644 index 0000000000..12a5901332 --- /dev/null +++ b/devtools/client/memory/components/tree-map/draw.js @@ -0,0 +1,317 @@ +/* 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"; +/** + * Draw the treemap into the provided canvases using the 2d context. The treemap + * layout is computed with d3. There are 2 canvases provided, each matching + * the resolution of the window. The main canvas is a fully drawn version of + * the treemap that is positioned and zoomed using css. It gets blurry the more + * you zoom in as it doesn't get redrawn when zooming. The zoom canvas is + * repositioned absolutely after every change in the dragZoom object, and then + * redrawn to provide a full-resolution (non-blurry) view of zoomed in segment + * of the treemap. + */ + +const colorCoarseType = require("resource://devtools/client/memory/components/tree-map/color-coarse-type.js"); +const { + hslToStyle, + formatAbbreviatedBytes, + L10N, +} = require("resource://devtools/client/memory/utils.js"); + +// A constant fully zoomed out dragZoom object for the main canvas +const NO_SCROLL = { + translateX: 0, + translateY: 0, + zoom: 0, + offsetX: 0, + offsetY: 0, +}; + +// Drawing constants +const ELLIPSIS = "..."; +const TEXT_MARGIN = 2; +const TEXT_COLOR = "#000000"; +const TEXT_LIGHT_COLOR = "rgba(0,0,0,0.5)"; +const LINE_WIDTH = 1; +const FONT_SIZE = 10; +const FONT_LINE_HEIGHT = 2; +const PADDING = [5 + FONT_SIZE, 5, 5, 5]; +const COUNT_LABEL = L10N.getStr("tree-map.node-count"); + +/** + * Setup and start drawing the treemap visualization + * + * @param {Object} report + * @param {Object} canvases + * A CanvasUtils object that contains references to the main and zoom + * canvases and contexts + * @param {Object} dragZoom + * A DragZoom object representing the current state of the dragging + * and zooming behavior + */ +exports.setupDraw = function (report, canvases, dragZoom) { + const getTreemap = configureD3Treemap.bind(null, canvases.main.canvas); + + let treemap, nodes; + + function drawFullTreemap() { + treemap = getTreemap(); + nodes = treemap(report); + drawTreemap(canvases.main, nodes, NO_SCROLL); + drawTreemap(canvases.zoom, nodes, dragZoom); + } + + function drawZoomedTreemap() { + drawTreemap(canvases.zoom, nodes, dragZoom); + positionZoomedCanvas(canvases.zoom.canvas, dragZoom); + } + + drawFullTreemap(); + canvases.on("resize", drawFullTreemap); + dragZoom.on("change", drawZoomedTreemap); +}; + +/** + * Returns a configured d3 treemap function + * + * @param {HTMLCanvasElement} canvas + * @return {Function} + */ +const configureD3Treemap = (exports.configureD3Treemap = function (canvas) { + const window = canvas.ownerDocument.defaultView; + const ratio = window.devicePixelRatio; + const treemap = window.d3.layout + .treemap() + .size([ + // The d3 layout includes the padding around everything, add some + // extra padding to the size to compensate for thi + canvas.width + (PADDING[1] + PADDING[3]) * ratio, + canvas.height + (PADDING[0] + PADDING[2]) * ratio, + ]) + .sticky(true) + .padding([ + PADDING[0] * ratio, + PADDING[1] * ratio, + PADDING[2] * ratio, + PADDING[3] * ratio, + ]) + .value(d => d.bytes); + + /** + * Create treemap nodes from a census report that are sorted by depth + * + * @param {Object} report + * @return {Array} An array of d3 treemap nodes + * // https://github.com/mbostock/d3/wiki/Treemap-Layout + * parent - the parent node, or null for the root. + * children - the array of child nodes, or null for leaf nodes. + * value - the node value, as returned by the value accessor. + * depth - the depth of the node, starting at 0 for the root. + * area - the computed pixel area of this node. + * x - the minimum x-coordinate of the node position. + * y - the minimum y-coordinate of the node position. + * z - the orientation of this cell’s subdivision, if any. + * dx - the x-extent of the node position. + * dy - the y-extent of the node position. + */ + return function depthSortedNodes(report) { + const nodes = treemap(report); + nodes.sort((a, b) => a.depth - b.depth); + return nodes; + }; +}); + +/** + * Draw the text, cut it in half every time it doesn't fit until it fits or + * it's smaller than the "..." text. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Number} x + * the position of the text + * @param {Number} y + * the position of the text + * @param {Number} innerWidth + * the inner width of the containing treemap cell + * @param {Text} name + */ +const drawTruncatedName = (exports.drawTruncatedName = function ( + ctx, + x, + y, + innerWidth, + name +) { + const truncated = name.substr(0, Math.floor(name.length / 2)); + const formatted = truncated + ELLIPSIS; + + if (ctx.measureText(formatted).width > innerWidth) { + drawTruncatedName(ctx, x, y, innerWidth, truncated); + } else { + ctx.fillText(formatted, x, y); + } +}); + +/** + * Fit and draw the text in a node with the following strategies to shrink + * down the text size: + * + * Function 608KB 9083 count + * Function + * Func... + * Fu... + * ... + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} node + * @param {Number} borderWidth + * @param {Object} dragZoom + * @param {Array} padding + */ +const drawText = (exports.drawText = function ( + ctx, + node, + borderWidth, + ratio, + dragZoom, + padding +) { + let { dx, dy, name, totalBytes, totalCount } = node; + const scale = dragZoom.zoom + 1; + dx *= scale; + dy *= scale; + + // Start checking to see how much text we can fit in, optimizing for the + // common case of lots of small leaf nodes + if (FONT_SIZE * FONT_LINE_HEIGHT < dy) { + const margin = borderWidth(node) * 1.5 + ratio * TEXT_MARGIN; + const x = margin + (node.x - padding[0]) * scale - dragZoom.offsetX; + const y = margin + (node.y - padding[1]) * scale - dragZoom.offsetY; + const innerWidth = dx - margin * 2; + const nameSize = ctx.measureText(name).width; + + if (ctx.measureText(ELLIPSIS).width > innerWidth) { + return; + } + + ctx.fillStyle = TEXT_COLOR; + + if (nameSize > innerWidth) { + // The name is too long - halve the name as an expediant way to shorten it + drawTruncatedName(ctx, x, y, innerWidth, name); + } else { + const bytesFormatted = formatAbbreviatedBytes(totalBytes); + const countFormatted = `${totalCount} ${COUNT_LABEL}`; + const byteSize = ctx.measureText(bytesFormatted).width; + const countSize = ctx.measureText(countFormatted).width; + const spaceSize = ctx.measureText(" ").width; + + if (nameSize + byteSize + countSize + spaceSize * 3 > innerWidth) { + // The full name will fit + ctx.fillText(`${name}`, x, y); + } else { + // The full name plus the byte information will fit + ctx.fillText(name, x, y); + ctx.fillStyle = TEXT_LIGHT_COLOR; + ctx.fillText( + `${bytesFormatted} ${countFormatted}`, + x + nameSize + spaceSize, + y + ); + } + } + } +}); + +/** + * Draw a box given a node + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} node + * @param {Number} borderWidth + * @param {Number} ratio + * @param {Object} dragZoom + * @param {Array} padding + */ +const drawBox = (exports.drawBox = function ( + ctx, + node, + borderWidth, + dragZoom, + padding +) { + const border = borderWidth(node); + const fillHSL = colorCoarseType(node); + const strokeHSL = [fillHSL[0], fillHSL[1], fillHSL[2] * 0.5]; + const scale = 1 + dragZoom.zoom; + + // Offset the draw so that box strokes don't overlap + const x = scale * (node.x - padding[0]) - dragZoom.offsetX + border / 2; + const y = scale * (node.y - padding[1]) - dragZoom.offsetY + border / 2; + const dx = scale * node.dx - border; + const dy = scale * node.dy - border; + + ctx.fillStyle = hslToStyle(...fillHSL); + ctx.fillRect(x, y, dx, dy); + + ctx.strokeStyle = hslToStyle(...strokeHSL); + ctx.lineWidth = border; + ctx.strokeRect(x, y, dx, dy); +}); + +/** + * Draw the overall treemap + * + * @param {HTMLCanvasElement} canvas + * @param {CanvasRenderingContext2D} ctx + * @param {Array} nodes + * @param {Objbect} dragZoom + */ +const drawTreemap = (exports.drawTreemap = function ( + { canvas, ctx }, + nodes, + dragZoom +) { + const window = canvas.ownerDocument.defaultView; + const ratio = window.devicePixelRatio; + const canvasArea = canvas.width * canvas.height; + // Subtract the outer padding from the tree map layout. + const padding = [PADDING[3] * ratio, PADDING[0] * ratio]; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.font = `${FONT_SIZE * ratio}px sans-serif`; + ctx.textBaseline = "top"; + + function borderWidth(node) { + const areaRatio = Math.sqrt(node.area / canvasArea); + return ratio * Math.max(1, LINE_WIDTH * areaRatio); + } + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.parent === undefined) { + continue; + } + + drawBox(ctx, node, borderWidth, dragZoom, padding); + drawText(ctx, node, borderWidth, ratio, dragZoom, padding); + } +}); + +/** + * Set the position of the zoomed in canvas. It always take up 100% of the view + * window, but is transformed relative to the zoomed in containing element, + * essentially reversing the transform of the containing element. + * + * @param {HTMLCanvasElement} canvas + * @param {Object} dragZoom + */ +const positionZoomedCanvas = function (canvas, dragZoom) { + const scale = 1 / (1 + dragZoom.zoom); + const x = -dragZoom.translateX; + const y = -dragZoom.translateY; + canvas.style.transform = `scale(${scale}) translate(${x}px, ${y}px)`; +}; + +exports.positionZoomedCanvas = positionZoomedCanvas; diff --git a/devtools/client/memory/components/tree-map/moz.build b/devtools/client/memory/components/tree-map/moz.build new file mode 100644 index 0000000000..a9e5900339 --- /dev/null +++ b/devtools/client/memory/components/tree-map/moz.build @@ -0,0 +1,12 @@ +# 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( + "canvas-utils.js", + "color-coarse-type.js", + "drag-zoom.js", + "draw.js", + "start.js", +) diff --git a/devtools/client/memory/components/tree-map/start.js b/devtools/client/memory/components/tree-map/start.js new file mode 100644 index 0000000000..80ae483903 --- /dev/null +++ b/devtools/client/memory/components/tree-map/start.js @@ -0,0 +1,40 @@ +/* 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 { + setupDraw, +} = require("resource://devtools/client/memory/components/tree-map/draw.js"); +const DragZoom = require("resource://devtools/client/memory/components/tree-map/drag-zoom.js"); +const CanvasUtils = require("resource://devtools/client/memory/components/tree-map/canvas-utils.js"); + +/** + * Start the tree map visualization + * + * @param {HTMLDivElement} container + * @param {Object} report + * the report from a census + * @param {Number} debounceRate + */ +module.exports = function startVisualization( + parentEl, + report, + debounceRate = 60 +) { + const window = parentEl.ownerDocument.defaultView; + const canvases = new CanvasUtils(parentEl, debounceRate); + const dragZoom = new DragZoom( + canvases.container, + debounceRate, + window.requestAnimationFrame + ); + + setupDraw(report, canvases, dragZoom); + + return function stopVisualization() { + canvases.destroy(); + dragZoom.destroy(); + }; +}; |