diff options
Diffstat (limited to 'devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js')
-rw-r--r-- | devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js | 510 |
1 files changed, 510 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js new file mode 100644 index 0000000000..c570bdd5a0 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js @@ -0,0 +1,510 @@ +/* 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/>. */ + +// Dependencies +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; + +// Selectors +import { + getSelectedLocation, + getMainThreadHost, + getExpandedState, + getProjectDirectoryRoot, + getProjectDirectoryRootName, + getSourcesTreeSources, + getFocusedSourceItem, + getContext, + getGeneratedSourceByURL, + getBlackBoxRanges, + getHideIgnoredSources, +} from "../../selectors"; + +// Actions +import actions from "../../actions"; + +// Components +import SourcesTreeItem from "./SourcesTreeItem"; +import AccessibleImage from "../shared/AccessibleImage"; + +// Utils +import { getRawSourceURL } from "../../utils/source"; +import { createLocation } from "../../utils/location"; + +const classnames = require("devtools/client/shared/classnames.js"); +const Tree = require("devtools/client/shared/components/Tree"); + +function shouldAutoExpand(item, mainThreadHost) { + // There is only one case where we want to force auto expand, + // when we are on the group of the page's domain. + return item.type == "group" && item.groupName === mainThreadHost; +} + +/** + * Get the SourceItem displayed in the SourceTree for a given "tree location". + * + * @param {Object} treeLocation + * An object containing the Source coming from the sources.js reducer and the source actor + * See getTreeLocation(). + * @param {object} rootItems + * Result of getSourcesTreeSources selector, containing all sources sorted in a tree structure. + * items to be displayed in the source tree. + * @return {SourceItem} + * The directory source item where the given source is displayed. + */ +function getSourceItemForTreeLocation(treeLocation, rootItems) { + // Sources without URLs are not visible in the SourceTree + const { source, sourceActor } = treeLocation; + + if (!source.url) { + return null; + } + const { displayURL } = source; + function findSourceInItem(item, path) { + if (item.type == "source") { + if (item.source.url == source.url) { + return item; + } + return null; + } + // Bail out if we the current item doesn't match the source + if (item.type == "thread" && item.threadActorID != sourceActor?.thread) { + return null; + } + if (item.type == "group" && displayURL.group != item.groupName) { + return null; + } + if (item.type == "directory" && !path.startsWith(item.path)) { + return null; + } + // Otherwise, walk down the tree if this ancestor item seems to match + for (const child of item.children) { + const match = findSourceInItem(child, path); + if (match) { + return match; + } + } + + return null; + } + for (const rootItem of rootItems) { + // Note that when we are setting a project root, rootItem + // may no longer be only Thread Item, but also be Group, Directory or Source Items. + const item = findSourceInItem(rootItem, displayURL.path); + if (item) { + return item; + } + } + return null; +} + +class SourcesTree extends Component { + constructor(props) { + super(props); + + this.state = {}; + } + + static get propTypes() { + return { + cx: PropTypes.object.isRequired, + mainThreadHost: PropTypes.string.isRequired, + expanded: PropTypes.object.isRequired, + focusItem: PropTypes.func.isRequired, + focused: PropTypes.object, + projectRoot: PropTypes.string.isRequired, + selectSource: PropTypes.func.isRequired, + selectedTreeLocation: PropTypes.object, + setExpandedState: PropTypes.func.isRequired, + blackBoxRanges: PropTypes.object.isRequired, + rootItems: PropTypes.object.isRequired, + clearProjectDirectoryRoot: PropTypes.func.isRequired, + projectRootName: PropTypes.string.isRequired, + setHideOrShowIgnoredSources: PropTypes.func.isRequired, + hideIgnoredSources: PropTypes.bool.isRequired, + }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { selectedTreeLocation } = this.props; + + // We might fail to find the source if its thread is registered late, + // so that we should re-search the selected source if state.focused is null. + if ( + nextProps.selectedTreeLocation?.source && + (nextProps.selectedTreeLocation.source != selectedTreeLocation?.source || + (nextProps.selectedTreeLocation.source === + selectedTreeLocation?.source && + nextProps.selectedTreeLocation.sourceActor != + selectedTreeLocation?.sourceActor) || + !this.props.focused) + ) { + const sourceItem = getSourceItemForTreeLocation( + nextProps.selectedTreeLocation, + this.props.rootItems + ); + if (sourceItem) { + // Walk up the tree to expand all ancestor items up to the root of the tree. + const expanded = new Set(this.props.expanded); + let parentDirectory = sourceItem; + while (parentDirectory) { + expanded.add(this.getKey(parentDirectory)); + parentDirectory = this.getParent(parentDirectory); + } + this.props.setExpandedState(expanded); + this.onFocus(sourceItem); + } + } + } + + selectSourceItem = item => { + this.props.selectSource(this.props.cx, item.source, item.sourceActor); + }; + + onFocus = item => { + this.props.focusItem(item); + }; + + onActivate = item => { + if (item.type == "source") { + this.selectSourceItem(item); + } + }; + + onExpand = (item, shouldIncludeChildren) => { + this.setExpanded(item, true, shouldIncludeChildren); + }; + + onCollapse = (item, shouldIncludeChildren) => { + this.setExpanded(item, false, shouldIncludeChildren); + }; + + setExpanded = (item, isExpanded, shouldIncludeChildren) => { + const { expanded } = this.props; + let changed = false; + const expandItem = i => { + const key = this.getKey(i); + if (isExpanded) { + changed |= !expanded.has(key); + expanded.add(key); + } else { + changed |= expanded.has(key); + expanded.delete(key); + } + }; + expandItem(item); + + if (shouldIncludeChildren) { + let parents = [item]; + while (parents.length) { + const children = []; + for (const parent of parents) { + for (const child of this.getChildren(parent)) { + expandItem(child); + children.push(child); + } + } + parents = children; + } + } + if (changed) { + this.props.setExpandedState(expanded); + } + }; + + isEmpty() { + return !this.getRoots().length; + } + + renderEmptyElement(message) { + return ( + <div key="empty" className="no-sources-message"> + {message} + </div> + ); + } + + getRoots = () => { + return this.props.rootItems; + }; + + getKey = item => { + // As this is used as React key in Tree component, + // we need to update the key when switching to a new project root + // otherwise these items won't be updated and will have a buggy padding start. + const { projectRoot } = this.props; + if (projectRoot) { + return projectRoot + item.uniquePath; + } + return item.uniquePath; + }; + + getChildren = item => { + // This is the precial magic that coalesce "empty" folders, + // i.e folders which have only one sub-folder as children. + function skipEmptyDirectories(directory) { + if (directory.type != "directory") { + return directory; + } + if ( + directory.children.length == 1 && + directory.children[0].type == "directory" + ) { + return skipEmptyDirectories(directory.children[0]); + } + return directory; + } + if (item.type == "thread") { + return item.children; + } else if (item.type == "group" || item.type == "directory") { + return item.children.map(skipEmptyDirectories); + } + return []; + }; + + getParent = item => { + if (item.type == "thread") { + return null; + } + const { rootItems } = this.props; + // This is the second magic which skip empty folders + // (See getChildren comment) + function skipEmptyDirectories(directory) { + if ( + directory.type == "group" || + directory.type == "thread" || + rootItems.includes(directory) + ) { + return directory; + } + if ( + directory.children.length == 1 && + directory.children[0].type == "directory" + ) { + return skipEmptyDirectories(directory.parent); + } + return directory; + } + return skipEmptyDirectories(item.parent); + }; + + /** + * Computes 4 lists: + * - `sourcesInside`: the list of all Source Items that are + * children of the current item (can be thread/group/directory). + * This include any nested level of children. + * - `sourcesOutside`: all other Source Items. + * i.e. all sources that are in any other folder of any group/thread. + * - `allInsideBlackBoxed`, all sources of `sourcesInside` which are currently + * blackboxed. + * - `allOutsideBlackBoxed`, all sources of `sourcesOutside` which are currently + * blackboxed. + */ + getBlackBoxSourcesGroups = item => { + const allSources = []; + function collectAllSources(list, _item) { + if (_item.children) { + _item.children.forEach(i => collectAllSources(list, i)); + } + if (_item.type == "source") { + list.push(_item.source); + } + } + for (const rootItem of this.props.rootItems) { + collectAllSources(allSources, rootItem); + } + + const sourcesInside = []; + collectAllSources(sourcesInside, item); + + const sourcesOutside = allSources.filter( + source => !sourcesInside.includes(source) + ); + const allInsideBlackBoxed = sourcesInside.every( + source => this.props.blackBoxRanges[source.url] + ); + const allOutsideBlackBoxed = sourcesOutside.every( + source => this.props.blackBoxRanges[source.url] + ); + + return { + sourcesInside, + sourcesOutside, + allInsideBlackBoxed, + allOutsideBlackBoxed, + }; + }; + + renderProjectRootHeader() { + const { cx, projectRootName } = this.props; + + if (!projectRootName) { + return null; + } + + return ( + <div key="root" className="sources-clear-root-container"> + <button + className="sources-clear-root" + onClick={() => this.props.clearProjectDirectoryRoot(cx)} + title={L10N.getStr("removeDirectoryRoot.label")} + > + <AccessibleImage className="home" /> + <AccessibleImage className="breadcrumb" /> + <span className="sources-clear-root-label">{projectRootName}</span> + </button> + </div> + ); + } + + renderItem = (item, depth, focused, _, expanded) => { + const { mainThreadHost, projectRoot } = this.props; + return ( + <SourcesTreeItem + item={item} + depth={depth} + focused={focused} + autoExpand={shouldAutoExpand(item, mainThreadHost)} + expanded={expanded} + focusItem={this.onFocus} + selectSourceItem={this.selectSourceItem} + projectRoot={projectRoot} + setExpanded={this.setExpanded} + getBlackBoxSourcesGroups={this.getBlackBoxSourcesGroups} + getParent={this.getParent} + /> + ); + }; + + renderTree() { + const { expanded, focused } = this.props; + + const treeProps = { + autoExpandAll: false, + autoExpandDepth: 1, + expanded, + focused, + getChildren: this.getChildren, + getParent: this.getParent, + getKey: this.getKey, + getRoots: this.getRoots, + itemHeight: 21, + key: this.isEmpty() ? "empty" : "full", + onCollapse: this.onCollapse, + onExpand: this.onExpand, + onFocus: this.onFocus, + isExpanded: item => { + return this.props.expanded.has(this.getKey(item)); + }, + onActivate: this.onActivate, + renderItem: this.renderItem, + preventBlur: true, + }; + + return <Tree {...treeProps} />; + } + + renderPane(child) { + const { projectRoot } = this.props; + + return ( + <div + key="pane" + className={classnames("sources-pane", { + "sources-list-custom-root": !!projectRoot, + })} + > + {child} + </div> + ); + } + + renderFooter() { + if (this.props.hideIgnoredSources) { + return ( + <footer className="source-list-footer"> + {L10N.getStr("ignoredSourcesHidden")} + <button + className="devtools-togglebutton" + onClick={() => this.props.setHideOrShowIgnoredSources(false)} + title={L10N.getStr("showIgnoredSources.tooltip.label")} + > + {L10N.getStr("showIgnoredSources")} + </button> + </footer> + ); + } + return null; + } + + render() { + const { projectRoot } = this.props; + return ( + <div + key="pane" + className={classnames("sources-list", { + "sources-list-custom-root": !!projectRoot, + })} + > + {this.isEmpty() ? ( + this.renderEmptyElement(L10N.getStr("noSourcesText")) + ) : ( + <> + {this.renderProjectRootHeader()} + {this.renderTree()} + {this.renderFooter()} + </> + )} + </div> + ); + } +} + +function getTreeLocation(state, location) { + // In the SourceTree, we never show the pretty printed sources and only + // the minified version, so if we are selecting a pretty file, fake selecting + // the minified version. + if (location?.source.isPrettyPrinted) { + const source = getGeneratedSourceByURL( + state, + getRawSourceURL(location.source.url) + ); + if (source) { + return createLocation({ + source, + // A source actor is required by getSourceItemForTreeLocation + // in order to know in which thread this source relates to. + sourceActor: location.sourceActor, + }); + } + } + return location; +} + +const mapStateToProps = state => { + const rootItems = getSourcesTreeSources(state); + + return { + cx: getContext(state), + selectedTreeLocation: getTreeLocation(state, getSelectedLocation(state)), + mainThreadHost: getMainThreadHost(state), + expanded: getExpandedState(state), + focused: getFocusedSourceItem(state), + projectRoot: getProjectDirectoryRoot(state), + rootItems, + blackBoxRanges: getBlackBoxRanges(state), + projectRootName: getProjectDirectoryRootName(state), + hideIgnoredSources: getHideIgnoredSources(state), + }; +}; + +export default connect(mapStateToProps, { + selectSource: actions.selectSource, + setExpandedState: actions.setExpandedState, + focusItem: actions.focusItem, + clearProjectDirectoryRoot: actions.clearProjectDirectoryRoot, + setHideOrShowIgnoredSources: actions.setHideOrShowIgnoredSources, +})(SourcesTree); |