2015-10-26 20:01:06 +03:00
|
|
|
/* 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 { DOM: dom, createClass, createFactory, PropTypes } = require("devtools/client/shared/vendor/react");
|
|
|
|
const { ViewHelpers } = require("resource://devtools/client/shared/widgets/ViewHelpers.jsm");
|
|
|
|
|
|
|
|
const AUTO_EXPAND_DEPTH = 3; // depth
|
|
|
|
|
|
|
|
/**
|
|
|
|
* An arrow that displays whether its node is expanded (▼) or collapsed
|
|
|
|
* (▶). When its node has no children, it is hidden.
|
|
|
|
*/
|
|
|
|
const ArrowExpander = createFactory(createClass({
|
|
|
|
displayName: "ArrowExpander",
|
|
|
|
|
|
|
|
shouldComponentUpdate(nextProps, nextState) {
|
|
|
|
return this.props.item !== nextProps.item
|
|
|
|
|| this.props.visible != nextProps.visible
|
|
|
|
|| this.props.expanded !== nextProps.expanded;
|
|
|
|
},
|
|
|
|
|
|
|
|
render() {
|
|
|
|
const attrs = {
|
|
|
|
className: "arrow theme-twisty",
|
|
|
|
onClick: this.props.expanded
|
|
|
|
? () => this.props.onCollapse(this.props.item)
|
|
|
|
: e => this.props.onExpand(this.props.item, e.altKey)
|
|
|
|
};
|
|
|
|
|
|
|
|
if (this.props.expanded) {
|
|
|
|
attrs.className += " open";
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!this.props.visible) {
|
|
|
|
attrs.style = {
|
|
|
|
visibility: "hidden"
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
return dom.div(attrs);
|
|
|
|
}
|
|
|
|
}));
|
|
|
|
|
|
|
|
const TreeNode = createFactory(createClass({
|
|
|
|
componentDidUpdate() {
|
|
|
|
if (this.props.focused) {
|
2015-10-28 18:34:47 +03:00
|
|
|
this.refs.button.focus();
|
2015-10-26 20:01:06 +03:00
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
render() {
|
|
|
|
const arrow = ArrowExpander({
|
|
|
|
item: this.props.item,
|
|
|
|
expanded: this.props.expanded,
|
|
|
|
visible: this.props.hasChildren,
|
|
|
|
onExpand: this.props.onExpand,
|
|
|
|
onCollapse: this.props.onCollapse
|
|
|
|
});
|
|
|
|
|
|
|
|
return dom.div(
|
|
|
|
{
|
|
|
|
className: "tree-node div",
|
|
|
|
onFocus: this.props.onFocus,
|
|
|
|
onClick: this.props.onFocus,
|
|
|
|
onBlur: this.props.onBlur,
|
|
|
|
style: {
|
|
|
|
padding: 0,
|
|
|
|
margin: 0
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
this.props.renderItem(this.props.item,
|
|
|
|
this.props.depth,
|
|
|
|
this.props.focused,
|
|
|
|
arrow),
|
|
|
|
|
|
|
|
// XXX: OSX won't focus/blur regular elements even if you set tabindex
|
|
|
|
// unless there is an input/button child.
|
|
|
|
dom.button(this._buttonAttrs)
|
|
|
|
);
|
|
|
|
},
|
|
|
|
|
|
|
|
_buttonAttrs: {
|
|
|
|
ref: "button",
|
|
|
|
style: {
|
|
|
|
opacity: 0,
|
|
|
|
width: "0 !important",
|
|
|
|
height: "0 !important",
|
|
|
|
padding: "0 !important",
|
|
|
|
outline: "none",
|
|
|
|
MozAppearance: "none",
|
|
|
|
// XXX: Despite resetting all of the above properties (and margin), the
|
|
|
|
// button still ends up with ~79px width, so we set a large negative
|
|
|
|
// margin to completely hide it.
|
|
|
|
MozMarginStart: "-1000px !important",
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}));
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A generic tree component. See propTypes for the public API.
|
2015-10-28 20:20:32 +03:00
|
|
|
*
|
2015-10-26 20:01:06 +03:00
|
|
|
* @see `devtools/client/memory/components/test/mochitest/head.js` for usage
|
|
|
|
* @see `devtools/client/memory/components/heap.js` for usage
|
|
|
|
*/
|
|
|
|
const Tree = module.exports = createClass({
|
|
|
|
displayName: "Tree",
|
|
|
|
|
|
|
|
propTypes: {
|
|
|
|
// Required props
|
|
|
|
|
|
|
|
// A function to get an item's parent, or null if it is a root.
|
|
|
|
getParent: PropTypes.func.isRequired,
|
|
|
|
// A function to get an item's children.
|
|
|
|
getChildren: PropTypes.func.isRequired,
|
|
|
|
// A function which takes an item and ArrowExpander and returns a
|
|
|
|
// component.
|
|
|
|
renderItem: PropTypes.func.isRequired,
|
|
|
|
// A function which returns the roots of the tree (forest).
|
|
|
|
getRoots: PropTypes.func.isRequired,
|
|
|
|
// A function to get a unique key for the given item.
|
|
|
|
getKey: PropTypes.func.isRequired,
|
|
|
|
// The height of an item in the tree including margin and padding, in
|
|
|
|
// pixels.
|
|
|
|
itemHeight: PropTypes.number.isRequired,
|
|
|
|
|
|
|
|
// Optional props
|
|
|
|
|
|
|
|
// A predicate function to filter out unwanted items from the tree.
|
|
|
|
filter: PropTypes.func,
|
|
|
|
// The depth to which we should automatically expand new items.
|
2015-10-28 20:20:32 +03:00
|
|
|
autoExpandDepth: PropTypes.number,
|
|
|
|
// A predicate that returns true if the last DFS traversal that was cached
|
|
|
|
// can be reused, false otherwise. The predicate function is passed the
|
|
|
|
// cached traversal as an array of nodes.
|
|
|
|
reuseCachedTraversal: PropTypes.func,
|
2015-10-26 20:01:06 +03:00
|
|
|
},
|
|
|
|
|
|
|
|
getDefaultProps() {
|
|
|
|
return {
|
|
|
|
filter: item => true,
|
|
|
|
expanded: new Set(),
|
|
|
|
seen: new Set(),
|
|
|
|
focused: undefined,
|
2015-10-28 20:20:32 +03:00
|
|
|
autoExpandDepth: AUTO_EXPAND_DEPTH,
|
|
|
|
reuseCachedTraversal: null,
|
2015-10-26 20:01:06 +03:00
|
|
|
};
|
|
|
|
},
|
|
|
|
|
|
|
|
getInitialState() {
|
|
|
|
return {
|
|
|
|
scroll: 0,
|
|
|
|
height: window.innerHeight,
|
|
|
|
expanded: new Set(),
|
|
|
|
seen: new Set(),
|
2015-10-28 20:20:32 +03:00
|
|
|
focused: undefined,
|
|
|
|
cachedTraversal: undefined,
|
2015-10-26 20:01:06 +03:00
|
|
|
};
|
|
|
|
},
|
|
|
|
|
|
|
|
componentDidMount() {
|
|
|
|
window.addEventListener("resize", this._updateHeight);
|
|
|
|
this._updateHeight();
|
|
|
|
},
|
|
|
|
|
|
|
|
componentWillUnmount() {
|
|
|
|
window.removeEventListener("resize", this._updateHeight);
|
|
|
|
},
|
|
|
|
|
|
|
|
componentWillReceiveProps(nextProps) {
|
|
|
|
// Automatically expand the first autoExpandDepth levels for new items.
|
|
|
|
for (let { item } of this._dfsFromRoots(this.props.autoExpandDepth)) {
|
|
|
|
if (!this.state.seen.has(item)) {
|
|
|
|
this.state.expanded.add(item);
|
|
|
|
this.state.seen.add(item);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
render() {
|
|
|
|
const traversal = this._dfsFromRoots();
|
|
|
|
|
|
|
|
// Remove 1 from `begin` and add 2 to `end` so that the top and bottom of
|
|
|
|
// the page are filled with the previous and next items respectively,
|
|
|
|
// rather than whitespace if the item is not in full view.
|
|
|
|
const begin = Math.max(((this.state.scroll / this.props.itemHeight) | 0) - 1, 0);
|
|
|
|
const end = begin + 2 + ((this.state.height / this.props.itemHeight) | 0);
|
|
|
|
const toRender = traversal.slice(begin, end);
|
|
|
|
|
|
|
|
const nodes = [
|
|
|
|
dom.div({
|
|
|
|
key: "top-spacer",
|
|
|
|
style: {
|
|
|
|
padding: 0,
|
|
|
|
margin: 0,
|
|
|
|
height: begin * this.props.itemHeight + "px"
|
|
|
|
}
|
|
|
|
})
|
|
|
|
];
|
|
|
|
|
|
|
|
for (let i = 0; i < toRender.length; i++) {
|
|
|
|
let { item, depth } = toRender[i];
|
|
|
|
nodes.push(TreeNode({
|
|
|
|
key: this.props.getKey(item),
|
|
|
|
item: item,
|
|
|
|
depth: depth,
|
|
|
|
renderItem: this.props.renderItem,
|
|
|
|
focused: this.state.focused === item,
|
|
|
|
expanded: this.state.expanded.has(item),
|
|
|
|
hasChildren: !!this.props.getChildren(item).length,
|
|
|
|
onExpand: this._onExpand,
|
|
|
|
onCollapse: this._onCollapse,
|
|
|
|
onFocus: () => this._onFocus(item)
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
|
|
|
nodes.push(dom.div({
|
|
|
|
key: "bottom-spacer",
|
|
|
|
style: {
|
|
|
|
padding: 0,
|
|
|
|
margin: 0,
|
|
|
|
height: (traversal.length - 1 - end) * this.props.itemHeight + "px"
|
|
|
|
}
|
|
|
|
}));
|
|
|
|
|
|
|
|
return dom.div(
|
|
|
|
{
|
|
|
|
className: "tree",
|
|
|
|
ref: "tree",
|
|
|
|
onKeyDown: this._onKeyDown,
|
|
|
|
onScroll: this._onScroll,
|
|
|
|
style: {
|
|
|
|
padding: 0,
|
|
|
|
margin: 0
|
|
|
|
}
|
|
|
|
},
|
|
|
|
nodes
|
|
|
|
);
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Updates the state's height based on clientHeight.
|
|
|
|
*/
|
|
|
|
_updateHeight() {
|
|
|
|
this.setState({
|
2015-10-28 18:34:47 +03:00
|
|
|
height: this.refs.tree.clientHeight
|
2015-10-26 20:01:06 +03:00
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Perform a pre-order depth-first search from item.
|
|
|
|
*/
|
|
|
|
_dfs(item, maxDepth = Infinity, traversal = [], _depth = 0) {
|
|
|
|
if (!this.props.filter(item)) {
|
|
|
|
return traversal;
|
|
|
|
}
|
|
|
|
|
|
|
|
traversal.push({ item, depth: _depth });
|
|
|
|
|
|
|
|
if (!this.state.expanded.has(item)) {
|
|
|
|
return traversal;
|
|
|
|
}
|
|
|
|
|
|
|
|
const nextDepth = _depth + 1;
|
|
|
|
|
|
|
|
if (nextDepth > maxDepth) {
|
|
|
|
return traversal;
|
|
|
|
}
|
|
|
|
|
|
|
|
for (let child of this.props.getChildren(item)) {
|
|
|
|
this._dfs(child, maxDepth, traversal, nextDepth);
|
|
|
|
}
|
|
|
|
|
|
|
|
return traversal;
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Perform a pre-order depth-first search over the whole forest.
|
|
|
|
*/
|
|
|
|
_dfsFromRoots(maxDepth = Infinity) {
|
2015-10-28 20:20:32 +03:00
|
|
|
const cached = this.state.cachedTraversal;
|
|
|
|
if (cached
|
|
|
|
&& maxDepth === Infinity
|
|
|
|
&& this.props.reuseCachedTraversal
|
|
|
|
&& this.props.reuseCachedTraversal(cached)) {
|
|
|
|
return cached;
|
|
|
|
}
|
|
|
|
|
2015-10-26 20:01:06 +03:00
|
|
|
const traversal = [];
|
|
|
|
for (let root of this.props.getRoots()) {
|
|
|
|
this._dfs(root, maxDepth, traversal);
|
|
|
|
}
|
2015-10-28 20:20:32 +03:00
|
|
|
|
|
|
|
if (this.props.reuseCachedTraversal) {
|
|
|
|
this.state.cachedTraversal = traversal;
|
|
|
|
}
|
|
|
|
|
2015-10-26 20:01:06 +03:00
|
|
|
return traversal;
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Expands current row.
|
|
|
|
*
|
|
|
|
* @param {Object} item
|
|
|
|
* @param {Boolean} expandAllChildren
|
|
|
|
*/
|
|
|
|
_onExpand(item, expandAllChildren) {
|
|
|
|
this.state.expanded.add(item);
|
|
|
|
|
|
|
|
if (expandAllChildren) {
|
|
|
|
for (let { item: child } of this._dfs(item)) {
|
|
|
|
this.state.expanded.add(child);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.setState({
|
2015-10-28 20:20:32 +03:00
|
|
|
expanded: this.state.expanded,
|
|
|
|
cachedTraversal: null,
|
2015-10-26 20:01:06 +03:00
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Collapses current row.
|
|
|
|
*
|
|
|
|
* @param {Object} item
|
|
|
|
*/
|
|
|
|
_onCollapse(item) {
|
|
|
|
this.state.expanded.delete(item);
|
|
|
|
this.setState({
|
2015-10-28 20:20:32 +03:00
|
|
|
expanded: this.state.expanded,
|
|
|
|
cachedTraversal: null,
|
2015-10-26 20:01:06 +03:00
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets the passed in item to be the focused item.
|
|
|
|
*
|
|
|
|
* @param {Object} item
|
|
|
|
*/
|
|
|
|
_onFocus(item) {
|
|
|
|
this.setState({
|
|
|
|
focused: item
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets the state to have no focused item.
|
|
|
|
*/
|
|
|
|
_onBlur() {
|
|
|
|
this.setState({
|
|
|
|
focused: undefined
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Fired on a scroll within the tree's container, updates
|
|
|
|
* the stored position of the view port to handle virtual view rendering.
|
|
|
|
*
|
|
|
|
* @param {Event} e
|
|
|
|
*/
|
|
|
|
_onScroll(e) {
|
|
|
|
this.setState({
|
2015-10-28 18:34:47 +03:00
|
|
|
scroll: Math.max(this.refs.tree.scrollTop, 0),
|
|
|
|
height: this.refs.tree.clientHeight
|
2015-10-26 20:01:06 +03:00
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles key down events in the tree's container.
|
|
|
|
*
|
|
|
|
* @param {Event} e
|
|
|
|
*/
|
|
|
|
_onKeyDown(e) {
|
|
|
|
if (this.state.focused == null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Prevent scrolling when pressing navigation keys. Guard against mocked
|
|
|
|
// events received when testing.
|
|
|
|
if (e.nativeEvent && e.nativeEvent.preventDefault) {
|
|
|
|
ViewHelpers.preventScrolling(e.nativeEvent);
|
|
|
|
}
|
|
|
|
|
|
|
|
switch (e.key) {
|
|
|
|
case "ArrowUp":
|
|
|
|
this._focusPrevNode();
|
|
|
|
return false;
|
|
|
|
|
|
|
|
case "ArrowDown":
|
|
|
|
this._focusNextNode();
|
|
|
|
return false;
|
|
|
|
|
|
|
|
case "ArrowLeft":
|
|
|
|
if (this.state.expanded.has(this.state.focused)
|
|
|
|
&& this.props.getChildren(this.state.focused).length) {
|
|
|
|
this._onCollapse(this.state.focused);
|
|
|
|
} else {
|
|
|
|
this._focusParentNode();
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
|
|
|
|
case "ArrowRight":
|
|
|
|
if (!this.state.expanded.has(this.state.focused)) {
|
|
|
|
this._onExpand(this.state.focused);
|
|
|
|
} else {
|
|
|
|
this._focusNextNode();
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets the previous node relative to the currently focused item, to focused.
|
|
|
|
*/
|
|
|
|
_focusPrevNode() {
|
|
|
|
// Start a depth first search and keep going until we reach the currently
|
|
|
|
// focused node. Focus the previous node in the DFS, if it exists. If it
|
|
|
|
// doesn't exist, we're at the first node already.
|
|
|
|
|
|
|
|
let prev;
|
|
|
|
for (let { item } of this._dfsFromRoots()) {
|
|
|
|
if (item === this.state.focused) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
prev = item;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (prev === undefined) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.setState({
|
|
|
|
focused: prev
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles the down arrow key which will focus either the next child
|
|
|
|
* or sibling row.
|
|
|
|
*/
|
|
|
|
_focusNextNode() {
|
|
|
|
// Start a depth first search and keep going until we reach the currently
|
|
|
|
// focused node. Focus the next node in the DFS, if it exists. If it
|
|
|
|
// doesn't exist, we're at the last node already.
|
|
|
|
|
|
|
|
const traversal = this._dfsFromRoots();
|
|
|
|
|
|
|
|
let i = 0;
|
|
|
|
for (let { item } of traversal) {
|
|
|
|
if (item === this.state.focused) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
i++;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (i + 1 < traversal.length) {
|
|
|
|
this.setState({
|
|
|
|
focused: traversal[i + 1].item
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles the left arrow key, going back up to the current rows'
|
|
|
|
* parent row.
|
|
|
|
*/
|
|
|
|
_focusParentNode() {
|
|
|
|
const parent = this.props.getParent(this.state.focused);
|
|
|
|
if (parent) {
|
|
|
|
this.setState({
|
|
|
|
focused: parent
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|