summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/components/PrimaryPanes
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/debugger/src/components/PrimaryPanes')
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/Outline.css205
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/Outline.js372
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css30
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js63
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css165
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js327
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/Sources.css219
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js510
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js457
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/index.js132
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/moz.build15
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js326
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap1111
13 files changed, 3932 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/Outline.css b/devtools/client/debugger/src/components/PrimaryPanes/Outline.css
new file mode 100644
index 0000000000..cbad0bddc3
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.css
@@ -0,0 +1,205 @@
+/* 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/>. */
+
+
+.sources-panel .outline {
+ display: flex;
+ height: 100%;
+}
+
+.source-outline-tabs {
+ font-size: 12px;
+ width: 100%;
+ background: var(--theme-body-background);
+ display: flex;
+ user-select: none;
+ box-sizing: border-box;
+ height: var(--editor-header-height);
+ margin: 0;
+ padding: 0;
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.source-outline-tabs .tab {
+ align-items: center;
+ background-color: var(--theme-toolbar-background);
+ color: var(--theme-toolbar-color);
+ cursor: default;
+ display: inline-flex;
+ flex: 1;
+ justify-content: center;
+ overflow: hidden;
+ padding: 4px 8px;
+ position: relative;
+}
+
+.source-outline-tabs .tab::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 2px;
+ background-color: var(--tab-line-color, transparent);
+ transition: transform 250ms var(--animation-curve),
+ opacity 250ms var(--animation-curve);
+ opacity: 0;
+ transform: scaleX(0);
+}
+
+.source-outline-tabs .tab.active {
+ --tab-line-color: var(--tab-line-selected-color);
+ color: var(--theme-toolbar-selected-color);
+ border-bottom-color: transparent;
+}
+
+.source-outline-tabs .tab:not(.active):hover {
+ --tab-line-color: var(--tab-line-hover-color);
+ background-color: var(--theme-toolbar-hover);
+}
+
+.source-outline-tabs .tab:hover::before,
+.source-outline-tabs .tab.active::before {
+ opacity: 1;
+ transform: scaleX(1);
+}
+
+.source-outline-panel {
+ flex: 1;
+ overflow: auto;
+}
+
+.outline {
+ overflow-y: hidden;
+}
+
+.outline > div {
+ width: 100%;
+ position: relative;
+}
+
+.outline-pane-info {
+ padding: 0.5em;
+ width: 100%;
+ font-style: italic;
+ text-align: center;
+ user-select: none;
+ font-size: 12px;
+ overflow: hidden;
+}
+
+.outline-list {
+ margin: 0;
+ padding: 4px 0;
+ position: absolute;
+ top: 25px;
+ bottom: 25px;
+ left: 0;
+ right: 0;
+ list-style-type: none;
+ overflow: auto;
+}
+
+.outline-list__class-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.outline-list__class-list > .outline-list__element {
+ padding-inline-start: 2rem;
+}
+
+.outline-list__class-list .function-signature .function-name {
+ color: var(--theme-highlight-green);
+}
+
+.outline-list .function-signature .paren {
+ color: inherit;
+}
+
+.outline-list__class h2 {
+ font-weight: normal;
+ font-size: 1em;
+ padding: 3px 0;
+ padding-inline-start: 10px;
+ color: var(--blue-55);
+ margin: 0;
+}
+
+.outline-list__class:not(:first-child) h2 {
+ margin-top: 12px;
+}
+
+.outline-list h2:hover {
+ background: var(--theme-toolbar-background-hover);
+}
+
+.theme-dark .outline-list h2 {
+ color: var(--theme-highlight-blue);
+}
+
+.outline-list h2 .keyword {
+ color: var(--theme-highlight-red);
+}
+
+.outline-list__class h2.focused {
+ background: var(--theme-selection-background);
+}
+
+.outline-list__class h2.focused,
+.outline-list__class h2.focused .keyword {
+ color: var(--theme-selection-color);
+}
+
+.outline-list__element {
+ padding: 3px 10px 3px 10px;
+ cursor: default;
+ white-space: nowrap;
+}
+
+.outline-list > .outline-list__element {
+ padding-inline-start: 1rem;
+}
+
+.outline-list__element-icon {
+ padding-inline-end: 0.4rem;
+}
+
+.outline-list__element:hover {
+ background: var(--theme-toolbar-background-hover);
+}
+
+.outline-list__element.focused {
+ background: var(--theme-selection-background);
+}
+
+.outline-list__element.focused .outline-list__element-icon,
+.outline-list__element.focused .function-signature * {
+ color: var(--theme-selection-color);
+}
+
+.outline-footer {
+ display: flex;
+ box-sizing: border-box;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 25px;
+ background: var(--theme-body-background);
+ border-top: 1px solid var(--theme-splitter-color);
+ opacity: 1;
+ z-index: 1;
+ user-select: none;
+}
+
+.outline-footer button {
+ color: var(--theme-body-color);
+}
+
+.outline-footer button.active {
+ background: var(--theme-selection-background);
+ color: var(--theme-selection-color);
+}
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/Outline.js b/devtools/client/debugger/src/components/PrimaryPanes/Outline.js
new file mode 100644
index 0000000000..8e0aa17ca4
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.js
@@ -0,0 +1,372 @@
+/* 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/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { showMenu } from "../../context-menu/menu";
+import { connect } from "../../utils/connect";
+import { score as fuzzaldrinScore } from "fuzzaldrin-plus";
+
+import { containsPosition, positionAfter } from "../../utils/ast";
+import { copyToTheClipboard } from "../../utils/clipboard";
+import { findFunctionText } from "../../utils/function";
+import { createLocation } from "../../utils/location";
+
+import actions from "../../actions";
+import {
+ getSelectedLocation,
+ getSelectedSource,
+ getSelectedSourceTextContent,
+ getSymbols,
+ getCursorPosition,
+ getContext,
+} from "../../selectors";
+
+import OutlineFilter from "./OutlineFilter";
+import "./Outline.css";
+import PreviewFunction from "../shared/PreviewFunction";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+// Set higher to make the fuzzaldrin filter more specific
+const FUZZALDRIN_FILTER_THRESHOLD = 15000;
+
+/**
+ * Check whether the name argument matches the fuzzy filter argument
+ */
+const filterOutlineItem = (name, filter) => {
+ if (!filter) {
+ return true;
+ }
+
+ if (filter.length === 1) {
+ // when filter is a single char just check if it starts with the char
+ return filter.toLowerCase() === name.toLowerCase()[0];
+ }
+ return fuzzaldrinScore(name, filter) > FUZZALDRIN_FILTER_THRESHOLD;
+};
+
+// Checks if an element is visible inside its parent element
+function isVisible(element, parent) {
+ const parentRect = parent.getBoundingClientRect();
+ const elementRect = element.getBoundingClientRect();
+
+ const parentTop = parentRect.top;
+ const parentBottom = parentRect.bottom;
+ const elTop = elementRect.top;
+ const elBottom = elementRect.bottom;
+
+ return parentTop < elTop && parentBottom > elBottom;
+}
+
+export class Outline extends Component {
+ constructor(props) {
+ super(props);
+ this.focusedElRef = null;
+ this.state = { filter: "", focusedItem: null };
+ }
+
+ static get propTypes() {
+ return {
+ alphabetizeOutline: PropTypes.bool.isRequired,
+ cursorPosition: PropTypes.object,
+ cx: PropTypes.object.isRequired,
+ flashLineRange: PropTypes.func.isRequired,
+ getFunctionText: PropTypes.func.isRequired,
+ onAlphabetizeClick: PropTypes.func.isRequired,
+ selectLocation: PropTypes.func.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ symbols: PropTypes.object.isRequired,
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const { cursorPosition, symbols } = this.props;
+ if (
+ cursorPosition &&
+ symbols &&
+ cursorPosition !== prevProps.cursorPosition
+ ) {
+ this.setFocus(cursorPosition);
+ }
+
+ if (
+ this.focusedElRef &&
+ !isVisible(this.focusedElRef, this.refs.outlineList)
+ ) {
+ this.focusedElRef.scrollIntoView({ block: "center" });
+ }
+ }
+
+ setFocus(cursorPosition) {
+ const { symbols } = this.props;
+ let classes = [];
+ let functions = [];
+
+ if (symbols) {
+ ({ classes, functions } = symbols);
+ }
+
+ // Find items that enclose the selected location
+ const enclosedItems = [...classes, ...functions].filter(
+ ({ name, location }) =>
+ name != "anonymous" && containsPosition(location, cursorPosition)
+ );
+
+ if (!enclosedItems.length) {
+ this.setState({ focusedItem: null });
+ return;
+ }
+
+ // Find the closest item to the selected location to focus
+ const closestItem = enclosedItems.reduce((item, closest) =>
+ positionAfter(item.location, closest.location) ? item : closest
+ );
+
+ this.setState({ focusedItem: closestItem });
+ }
+
+ selectItem(selectedItem) {
+ const { cx, selectedSource, selectLocation } = this.props;
+ if (!selectedSource || !selectedItem) {
+ return;
+ }
+
+ selectLocation(
+ cx,
+ createLocation({
+ source: selectedSource,
+ line: selectedItem.location.start.line,
+ column: selectedItem.location.start.column,
+ })
+ );
+
+ this.setState({ focusedItem: selectedItem });
+ }
+
+ onContextMenu(event, func) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const { selectedSource, flashLineRange, getFunctionText } = this.props;
+
+ if (!selectedSource) {
+ return;
+ }
+
+ const sourceLine = func.location.start.line;
+ const functionText = getFunctionText(sourceLine);
+
+ const copyFunctionItem = {
+ id: "node-menu-copy-function",
+ label: L10N.getStr("copyFunction.label"),
+ accesskey: L10N.getStr("copyFunction.accesskey"),
+ disabled: !functionText,
+ click: () => {
+ flashLineRange({
+ start: sourceLine,
+ end: func.location.end.line,
+ sourceId: selectedSource.id,
+ });
+ return copyToTheClipboard(functionText);
+ },
+ };
+ const menuOptions = [copyFunctionItem];
+ showMenu(event, menuOptions);
+ }
+
+ updateFilter = filter => {
+ this.setState({ filter: filter.trim() });
+ };
+
+ renderPlaceholder() {
+ const placeholderMessage = this.props.selectedSource
+ ? L10N.getStr("outline.noFunctions")
+ : L10N.getStr("outline.noFileSelected");
+
+ return <div className="outline-pane-info">{placeholderMessage}</div>;
+ }
+
+ renderLoading() {
+ return (
+ <div className="outline-pane-info">{L10N.getStr("loadingText")}</div>
+ );
+ }
+
+ renderFunction(func) {
+ const { focusedItem } = this.state;
+ const { name, location, parameterNames } = func;
+ const isFocused = focusedItem === func;
+
+ return (
+ <li
+ key={`${name}:${location.start.line}:${location.start.column}`}
+ className={classnames("outline-list__element", { focused: isFocused })}
+ ref={el => {
+ if (isFocused) {
+ this.focusedElRef = el;
+ }
+ }}
+ onClick={() => this.selectItem(func)}
+ onContextMenu={e => this.onContextMenu(e, func)}
+ >
+ <span className="outline-list__element-icon">λ</span>
+ <PreviewFunction func={{ name, parameterNames }} />
+ </li>
+ );
+ }
+
+ renderClassHeader(klass) {
+ return (
+ <div>
+ <span className="keyword">class</span> {klass}
+ </div>
+ );
+ }
+
+ renderClassFunctions(klass, functions) {
+ const { symbols } = this.props;
+
+ if (!symbols || klass == null || !functions.length) {
+ return null;
+ }
+
+ const { focusedItem } = this.state;
+ const classFunc = functions.find(func => func.name === klass);
+ const classFunctions = functions.filter(func => func.klass === klass);
+ const classInfo = symbols.classes.find(c => c.name === klass);
+
+ const item = classFunc || classInfo;
+ const isFocused = focusedItem === item;
+
+ return (
+ <li
+ className="outline-list__class"
+ ref={el => {
+ if (isFocused) {
+ this.focusedElRef = el;
+ }
+ }}
+ key={klass}
+ >
+ <h2
+ className={classnames("", { focused: isFocused })}
+ onClick={() => this.selectItem(item)}
+ >
+ {classFunc
+ ? this.renderFunction(classFunc)
+ : this.renderClassHeader(klass)}
+ </h2>
+ <ul className="outline-list__class-list">
+ {classFunctions.map(func => this.renderFunction(func))}
+ </ul>
+ </li>
+ );
+ }
+
+ renderFunctions(functions) {
+ const { filter } = this.state;
+ let classes = [...new Set(functions.map(({ klass }) => klass))];
+ const namedFunctions = functions.filter(
+ ({ name, klass }) =>
+ filterOutlineItem(name, filter) && !klass && !classes.includes(name)
+ );
+
+ const classFunctions = functions.filter(
+ ({ name, klass }) => filterOutlineItem(name, filter) && !!klass
+ );
+
+ if (this.props.alphabetizeOutline) {
+ const sortByName = (a, b) => (a.name < b.name ? -1 : 1);
+ namedFunctions.sort(sortByName);
+ classes = classes.sort();
+ classFunctions.sort(sortByName);
+ }
+
+ return (
+ <ul
+ ref="outlineList"
+ className="outline-list devtools-monospace"
+ dir="ltr"
+ >
+ {namedFunctions.map(func => this.renderFunction(func))}
+ {classes.map(klass => this.renderClassFunctions(klass, classFunctions))}
+ </ul>
+ );
+ }
+
+ renderFooter() {
+ return (
+ <div className="outline-footer">
+ <button
+ onClick={this.props.onAlphabetizeClick}
+ className={this.props.alphabetizeOutline ? "active" : ""}
+ >
+ {L10N.getStr("outline.sortLabel")}
+ </button>
+ </div>
+ );
+ }
+
+ render() {
+ const { symbols, selectedSource } = this.props;
+ const { filter } = this.state;
+
+ if (!selectedSource) {
+ return this.renderPlaceholder();
+ }
+
+ if (!symbols) {
+ return this.renderLoading();
+ }
+
+ const symbolsToDisplay = symbols.functions.filter(
+ ({ name }) => name != "anonymous"
+ );
+
+ if (symbolsToDisplay.length === 0) {
+ return this.renderPlaceholder();
+ }
+
+ return (
+ <div className="outline">
+ <div>
+ <OutlineFilter filter={filter} updateFilter={this.updateFilter} />
+ {this.renderFunctions(symbolsToDisplay)}
+ {this.renderFooter()}
+ </div>
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const selectedSource = getSelectedSource(state);
+ const symbols = getSymbols(state, getSelectedLocation(state));
+
+ return {
+ cx: getContext(state),
+ symbols,
+ selectedSource,
+ cursorPosition: getCursorPosition(state),
+ getFunctionText: line => {
+ if (selectedSource) {
+ const selectedSourceTextContent = getSelectedSourceTextContent(state);
+ return findFunctionText(
+ line,
+ selectedSource,
+ selectedSourceTextContent,
+ symbols
+ );
+ }
+
+ return null;
+ },
+ };
+};
+
+export default connect(mapStateToProps, {
+ selectLocation: actions.selectLocation,
+ flashLineRange: actions.flashLineRange,
+})(Outline);
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css
new file mode 100644
index 0000000000..354093fc31
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css
@@ -0,0 +1,30 @@
+/* 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/. */
+
+.outline-filter {
+ border: 1px solid var(--theme-splitter-color);
+ border-top: 0px;
+}
+
+.outline-filter-input {
+ height: 24px;
+ width: 100%;
+ background-color: var(--theme-sidebar-background);
+ color: var(--theme-body-color);
+ font-size: inherit;
+ user-select: text;
+}
+
+.outline-filter-input.focused {
+ border: 1px solid var(--theme-highlight-blue);
+}
+
+.outline-filter-input::placeholder {
+ color: var(--theme-text-color-alt);
+ opacity: 1;
+}
+
+.theme-dark .outline-filter-input.focused {
+ border: 1px solid var(--blue-50);
+}
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js
new file mode 100644
index 0000000000..1d3daed0d9
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js
@@ -0,0 +1,63 @@
+/* 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/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./OutlineFilter.css";
+
+export default class OutlineFilter extends Component {
+ state = { focused: false };
+
+ static get propTypes() {
+ return {
+ filter: PropTypes.string.isRequired,
+ updateFilter: PropTypes.func.isRequired,
+ };
+ }
+
+ setFocus = shouldFocus => {
+ this.setState({ focused: shouldFocus });
+ };
+
+ onChange = e => {
+ this.props.updateFilter(e.target.value);
+ };
+
+ onKeyDown = e => {
+ if (e.key === "Escape" && this.props.filter !== "") {
+ // use preventDefault to override toggling the split-console which is
+ // also bound to the ESC key
+ e.preventDefault();
+ this.props.updateFilter("");
+ } else if (e.key === "Enter") {
+ // We must prevent the form submission from taking any action
+ // https://github.com/firefox-devtools/debugger/pull/7308
+ e.preventDefault();
+ }
+ };
+
+ render() {
+ const { focused } = this.state;
+ return (
+ <div className="outline-filter">
+ <form>
+ <input
+ className={classnames("outline-filter-input devtools-filterinput", {
+ focused,
+ })}
+ onFocus={() => this.setFocus(true)}
+ onBlur={() => this.setFocus(false)}
+ placeholder={L10N.getStr("outline.placeholder")}
+ value={this.props.filter}
+ type="text"
+ onChange={this.onChange}
+ onKeyDown={this.onKeyDown}
+ />
+ </form>
+ </div>
+ );
+ }
+}
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css
new file mode 100644
index 0000000000..f6d5e132ea
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css
@@ -0,0 +1,165 @@
+/* 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/>. */
+
+.search-container {
+ position: absolute;
+ top: var(--editor-header-height);
+ left: 0;
+ width: calc(100% - 1px);
+ height: calc(100% - var(--editor-header-height));
+ display: flex;
+ flex-direction: column;
+ z-index: 20;
+ overflow-y: hidden;
+
+ /* Using the same colors as the Netmonitor's --table-selection-background-hover */
+ --search-result-background-hover: rgba(209, 232, 255, 0.8);
+}
+
+.theme-dark .search-container {
+ --search-result-background-hover: rgba(53, 59, 72, 1);
+}
+
+.project-text-search {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ overflow-y: hidden;
+ height: 100%;
+}
+
+.project-text-search .result {
+ display: contents;
+ cursor: default;
+ line-height: 16px;
+ font-size: 11px;
+ font-family: var(--monospace-font-family);
+}
+
+.project-text-search .result:hover > * {
+ background-color: var(--search-result-background-hover);
+}
+
+.project-text-search .result .line-number {
+ grid-column: 1;
+ padding-block: 1px;
+ padding-inline-start: 4px;
+ padding-inline-end: 6px;
+ text-align: end;
+ color: var(--theme-text-color-alt);
+}
+
+.project-text-search .result .line-value {
+ grid-column: 2;
+ padding-block: 1px;
+ padding-inline-end: 4px;
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+}
+
+.project-text-search .result .query-match {
+ border-bottom: 1px solid var(--theme-contrast-border);
+ color: var(--theme-contrast-color);
+ background-color: var(--theme-contrast-background);
+}
+
+.project-text-search .result.focused .query-match {
+ border-bottom: none;
+ color: var(--theme-selection-background);
+ background-color: var(--theme-selection-color);
+}
+
+.project-text-search .tree-indent {
+ display: none;
+}
+
+.project-text-search .no-result-msg {
+ color: var(--theme-text-color-inactive);
+ font-size: 24px;
+ padding: 4px 15px;
+ max-width: 100%;
+ overflow-wrap: break-word;
+ hyphens: auto;
+}
+
+.project-text-search .file-result {
+ grid-column: 1/3;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ min-height: 24px;
+ padding: 2px 4px;
+ font-weight: bold;
+ font-size: 12px;
+ line-height: 16px;
+ cursor: default;
+}
+
+.project-text-search .file-result .img {
+ margin-inline: 2px;
+}
+
+.project-text-search .file-result .img.file {
+ margin-inline-end: 4px;
+}
+
+.project-text-search .file-path {
+ flex: 0 1 auto;
+ padding-inline-end: 4px;
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.project-text-search .file-path:empty {
+ display: none;
+}
+
+.project-text-search .search-field {
+ display: flex;
+ align-self: stretch;
+ flex-grow: 1;
+ width: 100%;
+ border-bottom: none;
+}
+
+.project-text-search .tree {
+ overflow-x: hidden;
+ overflow-y: auto;
+ height: 100%;
+ display: grid;
+ min-width: 100%;
+ white-space: nowrap;
+ user-select: none;
+ align-content: start;
+ /* Align the second column to the search input's text value */
+ grid-template-columns: minmax(40px, auto) 1fr;
+ padding-top: 4px;
+}
+
+/* Fake padding-bottom using a pseudo-element because Gecko doesn't render the
+ padding-bottom in a scroll container */
+.project-text-search .tree::after {
+ content: "";
+ display: block;
+ height: 4px;
+}
+
+.project-text-search .tree .tree-node {
+ display: contents;
+}
+
+/* Focus values */
+
+.project-text-search .file-result.focused,
+.project-text-search .result.focused .line-value,
+.project-text-search .result.focused .line-number {
+ color: var(--theme-selection-color);
+ background-color: var(--theme-selection-background);
+}
+
+.project-text-search .file-result.focused .img {
+ background-color: currentColor;
+}
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js
new file mode 100644
index 0000000000..922e266c40
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js
@@ -0,0 +1,327 @@
+/* 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/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import actions from "../../actions";
+
+import { getEditor } from "../../utils/editor";
+import { searchKeys } from "../../constants";
+
+import { statusType } from "../../reducers/project-text-search";
+import { getRelativePath } from "../../utils/sources-tree/utils";
+import { getFormattedSourceId } from "../../utils/source";
+import {
+ getProjectSearchResults,
+ getProjectSearchStatus,
+ getProjectSearchQuery,
+ getContext,
+} from "../../selectors";
+
+import SearchInput from "../shared/SearchInput";
+import AccessibleImage from "../shared/AccessibleImage";
+
+const { PluralForm } = require("devtools/shared/plural-form");
+const classnames = require("devtools/client/shared/classnames.js");
+const Tree = require("devtools/client/shared/components/Tree");
+
+import "./ProjectSearch.css";
+
+function getFilePath(item, index) {
+ return item.type === "RESULT"
+ ? `${item.location.source.id}-${index || "$"}`
+ : `${item.location.source.id}-${item.location.line}-${
+ item.location.column
+ }-${index || "$"}`;
+}
+
+export class ProjectSearch extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ inputValue: this.props.query || "",
+ inputFocused: false,
+ focusedItem: null,
+ expanded: new Set(),
+ };
+ }
+
+ static get propTypes() {
+ return {
+ clearSearch: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ doSearchForHighlight: PropTypes.func.isRequired,
+ query: PropTypes.string.isRequired,
+ results: PropTypes.array.isRequired,
+ searchSources: PropTypes.func.isRequired,
+ selectSpecificLocation: PropTypes.func.isRequired,
+ setActiveSearch: PropTypes.func.isRequired,
+ status: PropTypes.oneOf([
+ "INITIAL",
+ "FETCHING",
+ "CANCELED",
+ "DONE",
+ "ERROR",
+ ]).isRequired,
+ modifiers: PropTypes.object,
+ toggleProjectSearchModifier: PropTypes.func,
+ };
+ }
+
+ componentDidMount() {
+ const { shortcuts } = this.context;
+ shortcuts.on("Enter", this.onEnterPress);
+ }
+
+ componentWillUnmount() {
+ const { shortcuts } = this.context;
+ shortcuts.off("Enter", this.onEnterPress);
+ }
+
+ componentDidUpdate(prevProps) {
+ // If the query changes in redux, also change it in the UI
+ if (prevProps.query !== this.props.query) {
+ this.setState({ inputValue: this.props.query });
+ }
+ }
+
+ doSearch(searchTerm) {
+ if (searchTerm) {
+ this.props.searchSources(this.props.cx, searchTerm);
+ }
+ }
+
+ selectMatchItem = matchItem => {
+ this.props.selectSpecificLocation(this.props.cx, matchItem.location);
+ this.props.doSearchForHighlight(
+ this.state.inputValue,
+ getEditor(),
+ matchItem.location.line,
+ matchItem.location.column
+ );
+ };
+
+ highlightMatches = lineMatch => {
+ const { value, matchIndex, match } = lineMatch;
+ const len = match.length;
+
+ return (
+ <span className="line-value">
+ <span className="line-match" key={0}>
+ {value.slice(0, matchIndex)}
+ </span>
+ <span className="query-match" key={1}>
+ {value.substr(matchIndex, len)}
+ </span>
+ <span className="line-match" key={2}>
+ {value.slice(matchIndex + len, value.length)}
+ </span>
+ </span>
+ );
+ };
+
+ getResultCount = () =>
+ this.props.results.reduce((count, file) => count + file.matches.length, 0);
+
+ onKeyDown = e => {
+ if (e.key === "Escape") {
+ return;
+ }
+
+ e.stopPropagation();
+
+ this.setState({ focusedItem: null });
+ this.doSearch(this.state.inputValue);
+ };
+
+ onHistoryScroll = query => {
+ this.setState({ inputValue: query });
+ };
+
+ onEnterPress = () => {
+ // This is to select a match from the search result.
+ if (!this.state.focusedItem || this.state.inputFocused) {
+ return;
+ }
+ if (this.state.focusedItem.type === "MATCH") {
+ this.selectMatchItem(this.state.focusedItem);
+ }
+ };
+
+ onFocus = item => {
+ if (this.state.focusedItem !== item) {
+ this.setState({ focusedItem: item });
+ }
+ };
+
+ inputOnChange = e => {
+ const inputValue = e.target.value;
+ const { cx, clearSearch } = this.props;
+ this.setState({ inputValue });
+ if (inputValue === "") {
+ clearSearch(cx);
+ }
+ };
+
+ renderFile = (file, focused, expanded) => {
+ const matchesLength = file.matches.length;
+ const matches = ` (${matchesLength} match${matchesLength > 1 ? "es" : ""})`;
+ return (
+ <div
+ className={classnames("file-result", { focused })}
+ key={file.location.source.id}
+ >
+ <AccessibleImage className={classnames("arrow", { expanded })} />
+ <AccessibleImage className="file" />
+ <span className="file-path">
+ {file.location.source.url
+ ? getRelativePath(file.location.source.url)
+ : getFormattedSourceId(file.location.source.id)}
+ </span>
+ <span className="matches-summary">{matches}</span>
+ </div>
+ );
+ };
+
+ renderMatch = (match, focused) => {
+ return (
+ <div
+ className={classnames("result", { focused })}
+ onClick={() => setTimeout(() => this.selectMatchItem(match), 50)}
+ >
+ <span className="line-number" key={match.location.line}>
+ {match.location.line}
+ </span>
+ {this.highlightMatches(match)}
+ </div>
+ );
+ };
+
+ renderItem = (item, depth, focused, _, expanded) => {
+ if (item.type === "RESULT") {
+ return this.renderFile(item, focused, expanded);
+ }
+ return this.renderMatch(item, focused);
+ };
+
+ renderResults = () => {
+ const { status, results } = this.props;
+ if (!this.props.query) {
+ return null;
+ }
+ if (results.length) {
+ return (
+ <Tree
+ getRoots={() => results}
+ getChildren={file => file.matches || []}
+ itemHeight={24}
+ autoExpandAll={true}
+ autoExpandDepth={1}
+ autoExpandNodeChildrenLimit={100}
+ getParent={item => null}
+ getPath={getFilePath}
+ renderItem={this.renderItem}
+ focused={this.state.focusedItem}
+ onFocus={this.onFocus}
+ isExpanded={item => {
+ return this.state.expanded.has(item);
+ }}
+ onExpand={item => {
+ const { expanded } = this.state;
+ expanded.add(item);
+ this.setState({ expanded });
+ }}
+ onCollapse={item => {
+ const { expanded } = this.state;
+ expanded.delete(item);
+ this.setState({ expanded });
+ }}
+ getKey={getFilePath}
+ />
+ );
+ }
+ const msg =
+ status === statusType.fetching
+ ? L10N.getStr("loadingText")
+ : L10N.getStr("projectTextSearch.noResults");
+ return <div className="no-result-msg absolute-center">{msg}</div>;
+ };
+
+ renderSummary = () => {
+ if (this.props.query !== "") {
+ const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary2");
+ const count = this.getResultCount();
+ return PluralForm.get(count, resultsSummaryString).replace("#1", count);
+ }
+ return "";
+ };
+
+ shouldShowErrorEmoji() {
+ return !this.getResultCount() && this.props.status === statusType.done;
+ }
+
+ renderInput() {
+ const { status } = this.props;
+
+ return (
+ <SearchInput
+ query={this.state.inputValue}
+ count={this.getResultCount()}
+ placeholder={L10N.getStr("projectTextSearch.placeholder")}
+ size="small"
+ showErrorEmoji={this.shouldShowErrorEmoji()}
+ summaryMsg={this.renderSummary()}
+ isLoading={status === statusType.fetching}
+ onChange={this.inputOnChange}
+ onFocus={() => this.setState({ inputFocused: true })}
+ onBlur={() => this.setState({ inputFocused: false })}
+ onKeyDown={this.onKeyDown}
+ onHistoryScroll={this.onHistoryScroll}
+ showClose={false}
+ showExcludePatterns={true}
+ excludePatternsLabel={L10N.getStr(
+ "projectTextSearch.excludePatterns.label"
+ )}
+ excludePatternsPlaceholder={L10N.getStr(
+ "projectTextSearch.excludePatterns.placeholder"
+ )}
+ ref="searchInput"
+ showSearchModifiers={true}
+ searchKey={searchKeys.PROJECT_SEARCH}
+ onToggleSearchModifier={() => this.doSearch(this.state.inputValue)}
+ />
+ );
+ }
+
+ render() {
+ return (
+ <div className="search-container">
+ <div className="project-text-search">
+ <div className="header">{this.renderInput()}</div>
+ {this.renderResults()}
+ </div>
+ </div>
+ );
+ }
+}
+
+ProjectSearch.contextTypes = {
+ shortcuts: PropTypes.object,
+};
+
+const mapStateToProps = state => ({
+ cx: getContext(state),
+ results: getProjectSearchResults(state),
+ query: getProjectSearchQuery(state),
+ status: getProjectSearchStatus(state),
+});
+
+export default connect(mapStateToProps, {
+ searchSources: actions.searchSources,
+ clearSearch: actions.clearSearch,
+ selectSpecificLocation: actions.selectSpecificLocation,
+ setActiveSearch: actions.setActiveSearch,
+ doSearchForHighlight: actions.doSearchForHighlight,
+})(ProjectSearch);
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/Sources.css b/devtools/client/debugger/src/components/PrimaryPanes/Sources.css
new file mode 100644
index 0000000000..e0e251cb47
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Sources.css
@@ -0,0 +1,219 @@
+/* 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/>. */
+
+.sources-panel {
+ background-color: var(--theme-sidebar-background);
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+}
+
+.sources-panel * {
+ user-select: none;
+}
+
+/***********************/
+/* Souces Panel layout */
+/***********************/
+
+.sources-list {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+}
+
+.sources-list .sources-clear-root-container {
+ grid-area: custom-root;
+}
+
+.sources-list :is(.tree, .no-sources-message) {
+ grid-area: sources-tree-or-empty-message;
+}
+
+/****************/
+/* Custom root */
+/****************/
+
+.sources-clear-root {
+ padding: 4px 8px;
+ width: 100%;
+ text-align: start;
+ white-space: nowrap;
+ color: inherit;
+ display: flex;
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.sources-clear-root .home {
+ background-color: var(--theme-icon-dimmed-color);
+}
+
+.sources-clear-root .breadcrumb {
+ width: 5px;
+ margin: 0 2px 0 6px;
+ vertical-align: bottom;
+ background: var(--theme-text-color-alt);
+}
+
+.sources-clear-root-label {
+ margin-left: 5px;
+ line-height: 16px;
+}
+
+/*****************/
+/* Sources tree */
+/*****************/
+
+.sources-list .tree {
+ flex-grow: 1;
+ padding: 4px 0;
+ user-select: none;
+
+ white-space: nowrap;
+ overflow: auto;
+ min-width: 100%;
+
+ display: grid;
+ grid-template-columns: 1fr;
+ align-content: start;
+
+ line-height: 1.4em;
+}
+
+.sources-list .tree .node {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding-block: 8px;
+ padding-inline: 6px 8px;
+}
+
+.sources-list .tree .tree-node:not(.focused):hover {
+ background: var(--theme-toolbar-background-hover);
+}
+
+.sources-list .tree button {
+ display: block;
+}
+
+.sources-list .tree .node {
+ padding: 2px 3px;
+ position: relative;
+}
+
+.sources-list .tree .node.focused {
+ color: var(--theme-selection-color);
+ background-color: var(--theme-selection-background);
+}
+
+html:not([dir="rtl"]) .sources-list .tree .node > div {
+ margin-left: 10px;
+}
+
+html[dir="rtl"] .sources-list .tree .node > div {
+ margin-right: 10px;
+}
+
+.sources-list .tree-node button {
+ position: fixed;
+}
+
+.sources-list .img {
+ margin-inline-end: 4px;
+}
+
+.sources-list .tree .focused .img {
+ --icon-color: #ffffff;
+ background-color: var(--icon-color);
+ fill: var(--icon-color);
+}
+
+/* Use the same width as .img.arrow */
+.sources-list .tree .img.no-arrow {
+ width: 10px;
+ visibility: hidden;
+}
+
+.sources-list .tree .label .suffix {
+ font-style: italic;
+ font-size: 0.9em;
+ color: var(--theme-comment);
+}
+
+.sources-list .tree .focused .label .suffix {
+ color: inherit;
+}
+
+.theme-dark .source-list .node.focused {
+ background-color: var(--theme-tab-toolbar-background);
+}
+
+.sources-list .tree .blackboxed {
+ color: #806414;
+}
+
+.sources-list .img.blackBox {
+ mask-size: 13px;
+ background-color: #806414;
+}
+
+.sources-list .tree .label {
+ display: inline-block;
+ line-height: 16px;
+}
+
+.source-list-footer {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 5px;
+ justify-content: center;
+ text-align: center;
+ min-height: var(--editor-footer-height);
+ border-block-start: 1px solid var(--theme-warning-border);
+ user-select: none;
+ padding: 3px 10px;
+ color: var(--theme-warning-color);
+ background-color: var(--theme-warning-background);
+}
+
+.source-list-footer .devtools-togglebutton {
+ background-color: var(--theme-toolbar-hover);
+}
+
+.source-list-footer .devtools-togglebutton:hover {
+ background-color: var(--theme-toolbar-hover);
+ cursor: pointer;
+}
+
+
+/* Removes start margin when a custom root is used */
+.sources-list-custom-root
+ .tree
+ > .tree-node[data-expandable="false"][aria-level="0"] {
+ padding-inline-start: 4px;
+}
+
+.sources-list .tree-node[data-expandable="false"] .tree-indent:last-of-type {
+ margin-inline-end: 0;
+}
+
+
+/*****************/
+/* No Sources */
+/*****************/
+
+.no-sources-message {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-style: italic;
+ text-align: center;
+ padding: 0.5em;
+ font-size: 12px;
+ user-select: none;
+}
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);
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js
new file mode 100644
index 0000000000..874df4c77c
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js
@@ -0,0 +1,457 @@
+/* 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/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import { showMenu } from "../../context-menu/menu";
+
+import SourceIcon from "../shared/SourceIcon";
+import AccessibleImage from "../shared/AccessibleImage";
+
+import {
+ getGeneratedSourceByURL,
+ getContext,
+ getFirstSourceActorForGeneratedSource,
+ isSourceOverridden,
+ getHideIgnoredSources,
+ isSourceMapIgnoreListEnabled,
+ isSourceOnSourceMapIgnoreList,
+} from "../../selectors";
+import actions from "../../actions";
+
+import { shouldBlackbox, sourceTypes } from "../../utils/source";
+import { copyToTheClipboard } from "../../utils/clipboard";
+import { saveAsLocalFile } from "../../utils/utils";
+import { createLocation } from "../../utils/location";
+import { safeDecodeItemName } from "../../utils/sources-tree/utils";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+class SourceTreeItem extends Component {
+ static get propTypes() {
+ return {
+ autoExpand: PropTypes.bool.isRequired,
+ blackBoxSources: PropTypes.func.isRequired,
+ clearProjectDirectoryRoot: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ depth: PropTypes.number.isRequired,
+ expanded: PropTypes.bool.isRequired,
+ focusItem: PropTypes.func.isRequired,
+ focused: PropTypes.bool.isRequired,
+ getBlackBoxSourcesGroups: PropTypes.func.isRequired,
+ hasMatchingGeneratedSource: PropTypes.bool.isRequired,
+ item: PropTypes.object.isRequired,
+ loadSourceText: PropTypes.func.isRequired,
+ getFirstSourceActorForGeneratedSource: PropTypes.func.isRequired,
+ projectRoot: PropTypes.string.isRequired,
+ selectSourceItem: PropTypes.func.isRequired,
+ setExpanded: PropTypes.func.isRequired,
+ setProjectDirectoryRoot: PropTypes.func.isRequired,
+ toggleBlackBox: PropTypes.func.isRequired,
+ getParent: PropTypes.func.isRequired,
+ setOverrideSource: PropTypes.func.isRequired,
+ removeOverrideSource: PropTypes.func.isRequired,
+ isOverridden: PropTypes.bool,
+ hideIgnoredSources: PropTypes.bool,
+ isSourceOnIgnoreList: PropTypes.bool,
+ };
+ }
+
+ componentDidMount() {
+ const { autoExpand, item } = this.props;
+ if (autoExpand) {
+ this.props.setExpanded(item, true, false);
+ }
+ }
+
+ onClick = e => {
+ const { item, focusItem, selectSourceItem } = this.props;
+
+ focusItem(item);
+ if (item.type == "source") {
+ selectSourceItem(item);
+ }
+ };
+
+ onContextMenu = event => {
+ const copySourceUri2Label = L10N.getStr("copySourceUri2");
+ const copySourceUri2Key = L10N.getStr("copySourceUri2.accesskey");
+ const setDirectoryRootLabel = L10N.getStr("setDirectoryRoot.label");
+ const setDirectoryRootKey = L10N.getStr("setDirectoryRoot.accesskey");
+ const removeDirectoryRootLabel = L10N.getStr("removeDirectoryRoot.label");
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ const menuOptions = [];
+
+ const { item, isOverridden, cx, isSourceOnIgnoreList } = this.props;
+ if (item.type == "source") {
+ const { source } = item;
+ const copySourceUri2 = {
+ id: "node-menu-copy-source",
+ label: copySourceUri2Label,
+ accesskey: copySourceUri2Key,
+ disabled: false,
+ click: () => copyToTheClipboard(source.url),
+ };
+
+ const ignoreStr = item.isBlackBoxed ? "unignore" : "ignore";
+ const blackBoxMenuItem = {
+ id: "node-menu-blackbox",
+ label: L10N.getStr(`ignoreContextItem.${ignoreStr}`),
+ accesskey: L10N.getStr(`ignoreContextItem.${ignoreStr}.accesskey`),
+ disabled: isSourceOnIgnoreList || !shouldBlackbox(source),
+ click: () => this.props.toggleBlackBox(cx, source),
+ };
+ const downloadFileItem = {
+ id: "node-menu-download-file",
+ label: L10N.getStr("downloadFile.label"),
+ accesskey: L10N.getStr("downloadFile.accesskey"),
+ disabled: false,
+ click: () => this.saveLocalFile(cx, source),
+ };
+
+ const overrideStr = !isOverridden ? "override" : "removeOverride";
+ const overridesItem = {
+ id: "node-menu-overrides",
+ label: L10N.getStr(`overridesContextItem.${overrideStr}`),
+ accesskey: L10N.getStr(`overridesContextItem.${overrideStr}.accesskey`),
+ disabled: !!source.isHTML,
+ click: () => this.handleLocalOverride(cx, source, isOverridden),
+ };
+
+ menuOptions.push(
+ copySourceUri2,
+ blackBoxMenuItem,
+ downloadFileItem,
+ overridesItem
+ );
+ }
+
+ // All other types other than source are folder-like
+ if (item.type != "source") {
+ this.addCollapseExpandAllOptions(menuOptions, item);
+
+ const { depth, projectRoot } = this.props;
+
+ if (projectRoot == item.uniquePath) {
+ menuOptions.push({
+ id: "node-remove-directory-root",
+ label: removeDirectoryRootLabel,
+ disabled: false,
+ click: () => this.props.clearProjectDirectoryRoot(cx),
+ });
+ } else {
+ menuOptions.push({
+ id: "node-set-directory-root",
+ label: setDirectoryRootLabel,
+ accesskey: setDirectoryRootKey,
+ disabled: false,
+ click: () =>
+ this.props.setProjectDirectoryRoot(
+ cx,
+ item.uniquePath,
+ this.renderItemName(depth)
+ ),
+ });
+ }
+
+ this.addBlackboxAllOption(menuOptions, item);
+ }
+
+ showMenu(event, menuOptions);
+ };
+
+ saveLocalFile = async (cx, source) => {
+ if (!source) {
+ return null;
+ }
+
+ const data = await this.props.loadSourceText(cx, source);
+ if (!data) {
+ return null;
+ }
+ return saveAsLocalFile(data.value, source.displayURL.filename);
+ };
+
+ handleLocalOverride = async (cx, source, isOverridden) => {
+ if (!isOverridden) {
+ const localPath = await this.saveLocalFile(cx, source);
+ if (localPath) {
+ this.props.setOverrideSource(cx, source, localPath);
+ }
+ } else {
+ this.props.removeOverrideSource(cx, source);
+ }
+ };
+
+ addBlackboxAllOption = (menuOptions, item) => {
+ const { cx, depth, projectRoot } = this.props;
+ const {
+ sourcesInside,
+ sourcesOutside,
+ allInsideBlackBoxed,
+ allOutsideBlackBoxed,
+ } = this.props.getBlackBoxSourcesGroups(item);
+
+ let blackBoxInsideMenuItemLabel;
+ let blackBoxOutsideMenuItemLabel;
+ if (depth === 0 || (depth === 1 && projectRoot === "")) {
+ blackBoxInsideMenuItemLabel = allInsideBlackBoxed
+ ? L10N.getStr("unignoreAllInGroup.label")
+ : L10N.getStr("ignoreAllInGroup.label");
+ if (sourcesOutside.length) {
+ blackBoxOutsideMenuItemLabel = allOutsideBlackBoxed
+ ? L10N.getStr("unignoreAllOutsideGroup.label")
+ : L10N.getStr("ignoreAllOutsideGroup.label");
+ }
+ } else {
+ blackBoxInsideMenuItemLabel = allInsideBlackBoxed
+ ? L10N.getStr("unignoreAllInDir.label")
+ : L10N.getStr("ignoreAllInDir.label");
+ if (sourcesOutside.length) {
+ blackBoxOutsideMenuItemLabel = allOutsideBlackBoxed
+ ? L10N.getStr("unignoreAllOutsideDir.label")
+ : L10N.getStr("ignoreAllOutsideDir.label");
+ }
+ }
+
+ const blackBoxInsideMenuItem = {
+ id: allInsideBlackBoxed
+ ? "node-unblackbox-all-inside"
+ : "node-blackbox-all-inside",
+ label: blackBoxInsideMenuItemLabel,
+ disabled: false,
+ click: () =>
+ this.props.blackBoxSources(cx, sourcesInside, !allInsideBlackBoxed),
+ };
+
+ if (sourcesOutside.length) {
+ menuOptions.push({
+ id: "node-blackbox-all",
+ label: L10N.getStr("ignoreAll.label"),
+ submenu: [
+ blackBoxInsideMenuItem,
+ {
+ id: allOutsideBlackBoxed
+ ? "node-unblackbox-all-outside"
+ : "node-blackbox-all-outside",
+ label: blackBoxOutsideMenuItemLabel,
+ disabled: false,
+ click: () =>
+ this.props.blackBoxSources(
+ cx,
+ sourcesOutside,
+ !allOutsideBlackBoxed
+ ),
+ },
+ ],
+ });
+ } else {
+ menuOptions.push(blackBoxInsideMenuItem);
+ }
+ };
+
+ addCollapseExpandAllOptions = (menuOptions, item) => {
+ const { setExpanded } = this.props;
+
+ menuOptions.push({
+ id: "node-menu-collapse-all",
+ label: L10N.getStr("collapseAll.label"),
+ disabled: false,
+ click: () => setExpanded(item, false, true),
+ });
+
+ menuOptions.push({
+ id: "node-menu-expand-all",
+ label: L10N.getStr("expandAll.label"),
+ disabled: false,
+ click: () => setExpanded(item, true, true),
+ });
+ };
+
+ renderItemArrow() {
+ const { item, expanded } = this.props;
+ return item.type != "source" ? (
+ <AccessibleImage className={classnames("arrow", { expanded })} />
+ ) : (
+ <span className="img no-arrow" />
+ );
+ }
+
+ renderIcon(item, depth) {
+ if (item.type == "thread") {
+ const icon = item.thread.targetType.includes("worker")
+ ? "worker"
+ : "window";
+ return <AccessibleImage className={classnames(icon)} />;
+ }
+ if (item.type == "group") {
+ if (item.groupName === "Webpack") {
+ return <AccessibleImage className="webpack" />;
+ } else if (item.groupName === "Angular") {
+ return <AccessibleImage className="angular" />;
+ }
+ // Check if the group relates to an extension.
+ // This happens when a webextension injects a content script.
+ if (item.isForExtensionSource) {
+ return <AccessibleImage className="extension" />;
+ }
+
+ return <AccessibleImage className="globe-small" />;
+ }
+ if (item.type == "directory") {
+ return <AccessibleImage className="folder" />;
+ }
+ if (item.type == "source") {
+ const { source, sourceActor } = item;
+ return (
+ <SourceIcon
+ location={createLocation({ source, sourceActor })}
+ modifier={icon => {
+ // In the SourceTree, extension files should use the file-extension based icon,
+ // whereas we use the extension icon in other Components (eg. source tabs and breakpoints pane).
+ if (icon === "extension") {
+ return (
+ sourceTypes[source.displayURL.fileExtension] || "javascript"
+ );
+ }
+ return icon + (this.props.isOverridden ? " override" : "");
+ }}
+ />
+ );
+ }
+
+ return null;
+ }
+
+ renderItemName(depth) {
+ const { item } = this.props;
+
+ if (item.type == "thread") {
+ const { thread } = item;
+ return (
+ thread.name +
+ (thread.serviceWorkerStatus ? ` (${thread.serviceWorkerStatus})` : "")
+ );
+ }
+ if (item.type == "group") {
+ return safeDecodeItemName(item.groupName);
+ }
+ if (item.type == "directory") {
+ const parentItem = this.props.getParent(item);
+ return safeDecodeItemName(
+ item.path.replace(parentItem.path, "").replace(/^\//, "")
+ );
+ }
+ if (item.type == "source") {
+ const { displayURL } = item.source;
+ const name =
+ displayURL.filename + (displayURL.search ? displayURL.search : "");
+ return safeDecodeItemName(name);
+ }
+
+ return null;
+ }
+
+ renderItemTooltip() {
+ const { item } = this.props;
+
+ if (item.type == "thread") {
+ return item.thread.name;
+ }
+ if (item.type == "group") {
+ return item.groupName;
+ }
+ if (item.type == "directory") {
+ return item.path;
+ }
+ if (item.type == "source") {
+ return item.source.url;
+ }
+
+ return null;
+ }
+
+ render() {
+ const {
+ item,
+ depth,
+ focused,
+ hasMatchingGeneratedSource,
+ hideIgnoredSources,
+ } = this.props;
+
+ if (hideIgnoredSources && item.isBlackBoxed) {
+ return null;
+ }
+ const suffix = hasMatchingGeneratedSource ? (
+ <span className="suffix">{L10N.getStr("sourceFooter.mappedSuffix")}</span>
+ ) : null;
+
+ return (
+ <div
+ className={classnames("node", {
+ focused,
+ blackboxed: item.type == "source" && item.isBlackBoxed,
+ })}
+ key={item.path}
+ onClick={this.onClick}
+ onContextMenu={this.onContextMenu}
+ title={this.renderItemTooltip()}
+ >
+ {this.renderItemArrow()}
+ {this.renderIcon(item, depth)}
+ <span className="label">
+ {this.renderItemName(depth)}
+ {suffix}
+ </span>
+ </div>
+ );
+ }
+}
+
+function getHasMatchingGeneratedSource(state, source) {
+ if (!source || !source.isOriginal) {
+ return false;
+ }
+
+ return !!getGeneratedSourceByURL(state, source.url);
+}
+
+const mapStateToProps = (state, props) => {
+ const { item } = props;
+ if (item.type == "source") {
+ const { source } = item;
+ return {
+ cx: getContext(state),
+ hasMatchingGeneratedSource: getHasMatchingGeneratedSource(state, source),
+ getFirstSourceActorForGeneratedSource: (sourceId, threadId) =>
+ getFirstSourceActorForGeneratedSource(state, sourceId, threadId),
+ isOverridden: isSourceOverridden(state, source),
+ hideIgnoredSources: getHideIgnoredSources(state),
+ isSourceOnIgnoreList:
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, source),
+ };
+ }
+ return {
+ cx: getContext(state),
+ getFirstSourceActorForGeneratedSource: (sourceId, threadId) =>
+ getFirstSourceActorForGeneratedSource(state, sourceId, threadId),
+ };
+};
+
+export default connect(mapStateToProps, {
+ setProjectDirectoryRoot: actions.setProjectDirectoryRoot,
+ clearProjectDirectoryRoot: actions.clearProjectDirectoryRoot,
+ toggleBlackBox: actions.toggleBlackBox,
+ loadSourceText: actions.loadSourceText,
+ blackBoxSources: actions.blackBoxSources,
+ setBlackBoxAllOutside: actions.setBlackBoxAllOutside,
+ setOverrideSource: actions.setOverrideSource,
+ removeOverrideSource: actions.removeOverrideSource,
+})(SourceTreeItem);
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/index.js b/devtools/client/debugger/src/components/PrimaryPanes/index.js
new file mode 100644
index 0000000000..c0ab3075bd
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/index.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/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { Tab, Tabs, TabList, TabPanels } from "react-aria-components/src/tabs";
+
+import actions from "../../actions";
+import { getSelectedPrimaryPaneTab, getContext } from "../../selectors";
+import { prefs } from "../../utils/prefs";
+import { connect } from "../../utils/connect";
+import { primaryPaneTabs } from "../../constants";
+import { formatKeyShortcut } from "../../utils/text";
+
+import Outline from "./Outline";
+import SourcesTree from "./SourcesTree";
+import ProjectSearch from "./ProjectSearch";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./Sources.css";
+
+const tabs = [
+ primaryPaneTabs.SOURCES,
+ primaryPaneTabs.OUTLINE,
+ primaryPaneTabs.PROJECT_SEARCH,
+];
+
+class PrimaryPanes extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ alphabetizeOutline: prefs.alphabetizeOutline,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ cx: PropTypes.object.isRequired,
+ projectRootName: PropTypes.string.isRequired,
+ selectedTab: PropTypes.oneOf(tabs).isRequired,
+ setPrimaryPaneTab: PropTypes.func.isRequired,
+ setActiveSearch: PropTypes.func.isRequired,
+ closeActiveSearch: PropTypes.func.isRequired,
+ };
+ }
+
+ onAlphabetizeClick = () => {
+ const alphabetizeOutline = !prefs.alphabetizeOutline;
+ prefs.alphabetizeOutline = alphabetizeOutline;
+ this.setState({ alphabetizeOutline });
+ };
+
+ onActivateTab = index => {
+ const tab = tabs.at(index);
+ this.props.setPrimaryPaneTab(tab);
+ if (tab == primaryPaneTabs.PROJECT_SEARCH) {
+ this.props.setActiveSearch(tab);
+ } else {
+ this.props.closeActiveSearch();
+ }
+ };
+
+ renderTabList() {
+ return [
+ <Tab
+ className={classnames("tab sources-tab", {
+ active: this.props.selectedTab === primaryPaneTabs.SOURCES,
+ })}
+ key="sources-tab"
+ >
+ {formatKeyShortcut(L10N.getStr("sources.header"))}
+ </Tab>,
+ <Tab
+ className={classnames("tab outline-tab", {
+ active: this.props.selectedTab === primaryPaneTabs.OUTLINE,
+ })}
+ key="outline-tab"
+ >
+ {formatKeyShortcut(L10N.getStr("outline.header"))}
+ </Tab>,
+ <Tab
+ className={classnames("tab search-tab", {
+ active: this.props.selectedTab === primaryPaneTabs.PROJECT_SEARCH,
+ })}
+ key="search-tab"
+ >
+ {formatKeyShortcut(L10N.getStr("search.header"))}
+ </Tab>,
+ ];
+ }
+
+ render() {
+ const { selectedTab } = this.props;
+ return (
+ <Tabs
+ activeIndex={tabs.indexOf(selectedTab)}
+ className="sources-panel"
+ onActivateTab={this.onActivateTab}
+ >
+ <TabList className="source-outline-tabs">
+ {this.renderTabList()}
+ </TabList>
+ <TabPanels className="source-outline-panel" hasFocusableContent>
+ <SourcesTree />
+ <Outline
+ alphabetizeOutline={this.state.alphabetizeOutline}
+ onAlphabetizeClick={this.onAlphabetizeClick}
+ />
+ <ProjectSearch />
+ </TabPanels>
+ </Tabs>
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ cx: getContext(state),
+ selectedTab: getSelectedPrimaryPaneTab(state),
+ };
+};
+
+const connector = connect(mapStateToProps, {
+ setPrimaryPaneTab: actions.setPrimaryPaneTab,
+ setActiveSearch: actions.setActiveSearch,
+ closeActiveSearch: actions.closeActiveSearch,
+});
+
+export default connector(PrimaryPanes);
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/moz.build b/devtools/client/debugger/src/components/PrimaryPanes/moz.build
new file mode 100644
index 0000000000..fc73b7bee7
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/moz.build
@@ -0,0 +1,15 @@
+# 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 += []
+
+CompiledModules(
+ "index.js",
+ "Outline.js",
+ "OutlineFilter.js",
+ "ProjectSearch.js",
+ "SourcesTree.js",
+ "SourcesTreeItem.js",
+)
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js b/devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js
new file mode 100644
index 0000000000..10f9f197fe
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js
@@ -0,0 +1,326 @@
+/* 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/>. */
+
+import React from "react";
+import { Provider } from "react-redux";
+import configureStore from "redux-mock-store";
+import PropTypes from "prop-types";
+
+import { mount, shallow } from "enzyme";
+import { ProjectSearch } from "../ProjectSearch";
+import { statusType } from "../../../reducers/project-text-search";
+import { mockcx } from "../../../utils/test-mockup";
+import { searchKeys } from "../../../constants";
+
+const hooks = { on: [], off: [] };
+const shortcuts = {
+ dispatch(eventName) {
+ hooks.on.forEach(hook => {
+ if (hook.event === eventName) {
+ hook.cb();
+ }
+ });
+ hooks.off.forEach(hook => {
+ if (hook.event === eventName) {
+ hook.cb();
+ }
+ });
+ },
+ on: jest.fn((event, cb) => hooks.on.push({ event, cb })),
+ off: jest.fn((event, cb) => hooks.off.push({ event, cb })),
+};
+
+const context = { shortcuts };
+
+const testResults = [
+ {
+ location: {
+ source: {
+ url: "testFilePath1",
+ },
+ },
+ type: "RESULT",
+ matches: [
+ {
+ match: "match1",
+ value: "some thing match1",
+ location: {
+ source: {},
+ column: 30,
+ },
+ type: "MATCH",
+ },
+ {
+ match: "match2",
+ value: "some thing match2",
+ location: {
+ source: {},
+ column: 60,
+ },
+ type: "MATCH",
+ },
+ {
+ match: "match3",
+ value: "some thing match3",
+ location: {
+ source: {},
+ column: 90,
+ },
+ type: "MATCH",
+ },
+ ],
+ },
+ {
+ location: {
+ source: {
+ url: "testFilePath2",
+ },
+ },
+ type: "RESULT",
+ matches: [
+ {
+ match: "match4",
+ value: "some thing match4",
+ location: {
+ source: {},
+ column: 80,
+ },
+ type: "MATCH",
+ },
+ {
+ match: "match5",
+ value: "some thing match5",
+ location: {
+ source: {},
+ column: 40,
+ },
+ type: "MATCH",
+ },
+ ],
+ },
+];
+
+const testMatch = {
+ type: "MATCH",
+ match: "match1",
+ value: "some thing match1",
+ sourceId: "some-target/source42",
+ location: {
+ source: {
+ id: "some-target/source42",
+ },
+ line: 3,
+ column: 30,
+ },
+};
+
+function render(overrides = {}, mounted = false) {
+ const mockStore = configureStore([]);
+ const store = mockStore({
+ ui: {
+ mutableSearchOptions: {
+ [searchKeys.PROJECT_SEARCH]: {
+ regexMatch: false,
+ wholeWord: false,
+ caseSensitive: false,
+ excludePatterns: "",
+ },
+ },
+ },
+ });
+ const props = {
+ cx: mockcx,
+ status: "DONE",
+ sources: {},
+ results: [],
+ query: "foo",
+ activeSearch: "project",
+ closeProjectSearch: jest.fn(),
+ searchSources: jest.fn(),
+ clearSearch: jest.fn(),
+ updateSearchStatus: jest.fn(),
+ selectSpecificLocation: jest.fn(),
+ doSearchForHighlight: jest.fn(),
+ setActiveSearch: jest.fn(),
+ ...overrides,
+ };
+
+ if (mounted) {
+ return mount(
+ <Provider store={store}>
+ <ProjectSearch {...props} />
+ </Provider>,
+ { context, childContextTypes: { shortcuts: PropTypes.object } }
+ ).childAt(0);
+ }
+
+ return shallow(
+ <Provider store={store}>
+ <ProjectSearch {...props} />
+ </Provider>,
+ { context }
+ ).dive();
+}
+
+describe("ProjectSearch", () => {
+ beforeEach(() => {
+ context.shortcuts.on.mockClear();
+ context.shortcuts.off.mockClear();
+ });
+
+ it("renders nothing when disabled", () => {
+ const component = render({ activeSearch: "" });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("where <Enter> has not been pressed", () => {
+ const component = render({ query: "" });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("found no search results", () => {
+ const component = render();
+ expect(component).toMatchSnapshot();
+ });
+
+ it("should display loading message while search is in progress", () => {
+ const component = render({
+ query: "match",
+ status: statusType.fetching,
+ });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("found search results", () => {
+ const component = render(
+ {
+ query: "match",
+ results: testResults,
+ },
+ true
+ );
+ expect(component).toMatchSnapshot();
+ });
+
+ it("turns off shortcuts on unmount", () => {
+ const component = render({
+ query: "",
+ });
+ expect(component).toMatchSnapshot();
+ component.unmount();
+ expect(context.shortcuts.off).toHaveBeenCalled();
+ });
+
+ it("calls inputOnChange", () => {
+ const component = render(
+ {
+ results: testResults,
+ },
+ true
+ );
+ component
+ .find("SearchInput .search-field input")
+ .simulate("change", { target: { value: "bar" } });
+ expect(component.state().inputValue).toEqual("bar");
+ });
+
+ it("onKeyDown Escape/Other", () => {
+ const searchSources = jest.fn();
+ const component = render(
+ {
+ results: testResults,
+ searchSources,
+ },
+ true
+ );
+ component
+ .find("SearchInput .search-field input")
+ .simulate("keydown", { key: "Escape" });
+ expect(searchSources).not.toHaveBeenCalled();
+ searchSources.mockClear();
+ component
+ .find("SearchInput .search-field input")
+ .simulate("keydown", { key: "Other", stopPropagation: jest.fn() });
+ expect(searchSources).not.toHaveBeenCalled();
+ });
+
+ it("onKeyDown Enter", () => {
+ const searchSources = jest.fn();
+ const component = render(
+ {
+ results: testResults,
+ searchSources,
+ },
+ true
+ );
+ component
+ .find("SearchInput .search-field input")
+ .simulate("keydown", { key: "Enter", stopPropagation: jest.fn() });
+ expect(searchSources).toHaveBeenCalledWith(mockcx, "foo");
+ });
+
+ it("onEnterPress shortcut no match or setExpanded", () => {
+ const selectSpecificLocation = jest.fn();
+ const component = render(
+ {
+ results: testResults,
+ selectSpecificLocation,
+ },
+ true
+ );
+ component.instance().state.focusedItem = null;
+ shortcuts.dispatch("Enter");
+ expect(selectSpecificLocation).not.toHaveBeenCalled();
+ });
+
+ it("onEnterPress shortcut match", () => {
+ const selectSpecificLocation = jest.fn();
+ const component = render(
+ {
+ results: testResults,
+ selectSpecificLocation,
+ },
+ true
+ );
+ component.instance().state.focusedItem = { ...testMatch };
+ shortcuts.dispatch("Enter");
+ expect(selectSpecificLocation).toHaveBeenCalledWith(mockcx, {
+ source: {
+ id: "some-target/source42",
+ },
+ line: 3,
+ column: 30,
+ });
+ });
+
+ it("state.inputValue responds to prop.query changes", () => {
+ const component = render({ query: "foo" });
+ expect(component.state().inputValue).toEqual("foo");
+ component.setProps({ query: "" });
+ expect(component.state().inputValue).toEqual("");
+ });
+
+ describe("showErrorEmoji", () => {
+ it("false if not done & results", () => {
+ const component = render({
+ status: statusType.fetching,
+ results: testResults,
+ });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("false if not done & no results", () => {
+ const component = render({
+ status: statusType.fetching,
+ });
+ expect(component).toMatchSnapshot();
+ });
+
+ // "false if done & has results"
+ // is the same test as "found search results"
+
+ // "true if done & has no results"
+ // is the same test as "found no search results"
+ });
+});
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap b/devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap
new file mode 100644
index 0000000000..4be18c4753
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap
@@ -0,0 +1,1111 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ProjectSearch found no search results 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={0}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={false}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="foo"
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="0 results"
+ />
+ </div>
+ <div
+ className="no-result-msg absolute-center"
+ >
+ No results found
+ </div>
+ </div>
+</div>
+`;
+
+exports[`ProjectSearch found search results 1`] = `
+<ProjectSearch
+ activeSearch="project"
+ clearSearch={[MockFunction]}
+ closeProjectSearch={[MockFunction]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ doSearchForHighlight={[MockFunction]}
+ query="match"
+ results={
+ Array [
+ Object {
+ "location": Object {
+ "source": Object {
+ "url": "testFilePath1",
+ },
+ },
+ "matches": Array [
+ Object {
+ "location": Object {
+ "column": 30,
+ "source": Object {},
+ },
+ "match": "match1",
+ "type": "MATCH",
+ "value": "some thing match1",
+ },
+ Object {
+ "location": Object {
+ "column": 60,
+ "source": Object {},
+ },
+ "match": "match2",
+ "type": "MATCH",
+ "value": "some thing match2",
+ },
+ Object {
+ "location": Object {
+ "column": 90,
+ "source": Object {},
+ },
+ "match": "match3",
+ "type": "MATCH",
+ "value": "some thing match3",
+ },
+ ],
+ "type": "RESULT",
+ },
+ Object {
+ "location": Object {
+ "source": Object {
+ "url": "testFilePath2",
+ },
+ },
+ "matches": Array [
+ Object {
+ "location": Object {
+ "column": 80,
+ "source": Object {},
+ },
+ "match": "match4",
+ "type": "MATCH",
+ "value": "some thing match4",
+ },
+ Object {
+ "location": Object {
+ "column": 40,
+ "source": Object {},
+ },
+ "match": "match5",
+ "type": "MATCH",
+ "value": "some thing match5",
+ },
+ ],
+ "type": "RESULT",
+ },
+ ]
+ }
+ searchSources={[MockFunction]}
+ selectSpecificLocation={[MockFunction]}
+ setActiveSearch={[MockFunction]}
+ sources={Object {}}
+ status="DONE"
+ updateSearchStatus={[MockFunction]}
+>
+ <div
+ className="search-container"
+ >
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={5}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={false}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="match"
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="5 results"
+ >
+ <SearchInput
+ count={5}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ expanded={false}
+ hasPrefix={false}
+ isLoading={false}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="match"
+ searchKey="project-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="5 results"
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field small"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ >
+ <span
+ className="img search"
+ />
+ </AccessibleImage>
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className=""
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Find in files…"
+ spellCheck={false}
+ value="match"
+ />
+ <div
+ className="search-field-summary"
+ >
+ 5 results
+ </div>
+ <div
+ className="search-buttons-bar"
+ >
+ <SearchModifiers
+ modifiers={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ onToggleSearchModifier={[Function]}
+ >
+ <div
+ className="search-modifiers"
+ >
+ <span
+ className="pipe-divider"
+ />
+ <button
+ className="regex-match-btn "
+ onKeyDown={[Function]}
+ onMouseDown={[Function]}
+ title="Use Regular Expression"
+ >
+ <span
+ className="regex-match"
+ />
+ </button>
+ <button
+ className="case-sensitive-btn "
+ onKeyDown={[Function]}
+ onMouseDown={[Function]}
+ title="Match Case"
+ >
+ <span
+ className="case-match"
+ />
+ </button>
+ <button
+ className="whole-word-btn "
+ onKeyDown={[Function]}
+ onMouseDown={[Function]}
+ title="Match Whole Word"
+ >
+ <span
+ className="whole-word-match"
+ />
+ </button>
+ </div>
+ </SearchModifiers>
+ </div>
+ </div>
+ <div
+ className="exclude-patterns-field small"
+ >
+ <label>
+ files to exclude
+ </label>
+ <input
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="e.g. **/node_modules/**,app.js"
+ value=""
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ </div>
+ <Tree
+ autoExpandAll={true}
+ autoExpandDepth={1}
+ autoExpandNodeChildrenLimit={100}
+ focused={null}
+ getChildren={[Function]}
+ getKey={[Function]}
+ getParent={[Function]}
+ getPath={[Function]}
+ getRoots={[Function]}
+ isExpanded={[Function]}
+ itemHeight={24}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ onFocus={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-activedescendant={null}
+ className="tree "
+ onBlur={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ onKeyPress={[Function]}
+ onKeyUp={[Function]}
+ role="tree"
+ style={Object {}}
+ tabIndex="0"
+ >
+ <TreeNode
+ active={false}
+ depth={0}
+ expanded={true}
+ focused={false}
+ id="undefined-$"
+ index={0}
+ isExpandable={true}
+ item={
+ Object {
+ "location": Object {
+ "source": Object {
+ "url": "testFilePath1",
+ },
+ },
+ "matches": Array [
+ Object {
+ "location": Object {
+ "column": 30,
+ "source": Object {},
+ },
+ "match": "match1",
+ "type": "MATCH",
+ "value": "some thing match1",
+ },
+ Object {
+ "location": Object {
+ "column": 60,
+ "source": Object {},
+ },
+ "match": "match2",
+ "type": "MATCH",
+ "value": "some thing match2",
+ },
+ Object {
+ "location": Object {
+ "column": 90,
+ "source": Object {},
+ },
+ "match": "match3",
+ "type": "MATCH",
+ "value": "some thing match3",
+ },
+ ],
+ "type": "RESULT",
+ }
+ }
+ key="undefined-$-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-expanded={true}
+ aria-level={1}
+ className="tree-node"
+ data-expandable={true}
+ id="undefined-$"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <div
+ className="file-result"
+ >
+ <AccessibleImage
+ className="arrow expanded"
+ >
+ <span
+ className="img arrow expanded"
+ />
+ </AccessibleImage>
+ <AccessibleImage
+ className="file"
+ >
+ <span
+ className="img file"
+ />
+ </AccessibleImage>
+ <span
+ className="file-path"
+ />
+ <span
+ className="matches-summary"
+ >
+ (3 matches)
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ <TreeNode
+ active={false}
+ depth={1}
+ expanded={false}
+ focused={false}
+ id="undefined-undefined-30-1"
+ index={1}
+ isExpandable={false}
+ item={
+ Object {
+ "location": Object {
+ "column": 30,
+ "source": Object {},
+ },
+ "match": "match1",
+ "type": "MATCH",
+ "value": "some thing match1",
+ }
+ }
+ key="undefined-undefined-30-1-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-level={2}
+ className="tree-node"
+ data-expandable={false}
+ id="undefined-undefined-30-1"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <span
+ className="tree-indent tree-last-indent"
+ >
+ ​
+ </span>
+ <div
+ className="result"
+ onClick={[Function]}
+ >
+ <span
+ className="line-number"
+ />
+ <span
+ className="line-value"
+ >
+ <span
+ className="line-match"
+ key="0"
+ >
+ some thing match1
+ </span>
+ <span
+ className="query-match"
+ key="1"
+ >
+ some t
+ </span>
+ <span
+ className="line-match"
+ key="2"
+ >
+ some thing match1
+ </span>
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ <TreeNode
+ active={false}
+ depth={1}
+ expanded={false}
+ focused={false}
+ id="undefined-undefined-60-2"
+ index={2}
+ isExpandable={false}
+ item={
+ Object {
+ "location": Object {
+ "column": 60,
+ "source": Object {},
+ },
+ "match": "match2",
+ "type": "MATCH",
+ "value": "some thing match2",
+ }
+ }
+ key="undefined-undefined-60-2-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-level={2}
+ className="tree-node"
+ data-expandable={false}
+ id="undefined-undefined-60-2"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <span
+ className="tree-indent tree-last-indent"
+ >
+ ​
+ </span>
+ <div
+ className="result"
+ onClick={[Function]}
+ >
+ <span
+ className="line-number"
+ />
+ <span
+ className="line-value"
+ >
+ <span
+ className="line-match"
+ key="0"
+ >
+ some thing match2
+ </span>
+ <span
+ className="query-match"
+ key="1"
+ >
+ some t
+ </span>
+ <span
+ className="line-match"
+ key="2"
+ >
+ some thing match2
+ </span>
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ <TreeNode
+ active={false}
+ depth={1}
+ expanded={false}
+ focused={false}
+ id="undefined-undefined-90-3"
+ index={3}
+ isExpandable={false}
+ item={
+ Object {
+ "location": Object {
+ "column": 90,
+ "source": Object {},
+ },
+ "match": "match3",
+ "type": "MATCH",
+ "value": "some thing match3",
+ }
+ }
+ key="undefined-undefined-90-3-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-level={2}
+ className="tree-node"
+ data-expandable={false}
+ id="undefined-undefined-90-3"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <span
+ className="tree-indent tree-last-indent"
+ >
+ ​
+ </span>
+ <div
+ className="result"
+ onClick={[Function]}
+ >
+ <span
+ className="line-number"
+ />
+ <span
+ className="line-value"
+ >
+ <span
+ className="line-match"
+ key="0"
+ >
+ some thing match3
+ </span>
+ <span
+ className="query-match"
+ key="1"
+ >
+ some t
+ </span>
+ <span
+ className="line-match"
+ key="2"
+ >
+ some thing match3
+ </span>
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ <TreeNode
+ active={false}
+ depth={0}
+ expanded={true}
+ focused={false}
+ id="undefined-4"
+ index={4}
+ isExpandable={true}
+ item={
+ Object {
+ "location": Object {
+ "source": Object {
+ "url": "testFilePath2",
+ },
+ },
+ "matches": Array [
+ Object {
+ "location": Object {
+ "column": 80,
+ "source": Object {},
+ },
+ "match": "match4",
+ "type": "MATCH",
+ "value": "some thing match4",
+ },
+ Object {
+ "location": Object {
+ "column": 40,
+ "source": Object {},
+ },
+ "match": "match5",
+ "type": "MATCH",
+ "value": "some thing match5",
+ },
+ ],
+ "type": "RESULT",
+ }
+ }
+ key="undefined-4-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-expanded={true}
+ aria-level={1}
+ className="tree-node"
+ data-expandable={true}
+ id="undefined-4"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <div
+ className="file-result"
+ >
+ <AccessibleImage
+ className="arrow expanded"
+ >
+ <span
+ className="img arrow expanded"
+ />
+ </AccessibleImage>
+ <AccessibleImage
+ className="file"
+ >
+ <span
+ className="img file"
+ />
+ </AccessibleImage>
+ <span
+ className="file-path"
+ />
+ <span
+ className="matches-summary"
+ >
+ (2 matches)
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ <TreeNode
+ active={false}
+ depth={1}
+ expanded={false}
+ focused={false}
+ id="undefined-undefined-80-5"
+ index={5}
+ isExpandable={false}
+ item={
+ Object {
+ "location": Object {
+ "column": 80,
+ "source": Object {},
+ },
+ "match": "match4",
+ "type": "MATCH",
+ "value": "some thing match4",
+ }
+ }
+ key="undefined-undefined-80-5-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-level={2}
+ className="tree-node"
+ data-expandable={false}
+ id="undefined-undefined-80-5"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <span
+ className="tree-indent tree-last-indent"
+ >
+ ​
+ </span>
+ <div
+ className="result"
+ onClick={[Function]}
+ >
+ <span
+ className="line-number"
+ />
+ <span
+ className="line-value"
+ >
+ <span
+ className="line-match"
+ key="0"
+ >
+ some thing match4
+ </span>
+ <span
+ className="query-match"
+ key="1"
+ >
+ some t
+ </span>
+ <span
+ className="line-match"
+ key="2"
+ >
+ some thing match4
+ </span>
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ <TreeNode
+ active={false}
+ depth={1}
+ expanded={false}
+ focused={false}
+ id="undefined-undefined-40-6"
+ index={6}
+ isExpandable={false}
+ item={
+ Object {
+ "location": Object {
+ "column": 40,
+ "source": Object {},
+ },
+ "match": "match5",
+ "type": "MATCH",
+ "value": "some thing match5",
+ }
+ }
+ key="undefined-undefined-40-6-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-level={2}
+ className="tree-node"
+ data-expandable={false}
+ id="undefined-undefined-40-6"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <span
+ className="tree-indent tree-last-indent"
+ >
+ ​
+ </span>
+ <div
+ className="result"
+ onClick={[Function]}
+ >
+ <span
+ className="line-number"
+ />
+ <span
+ className="line-value"
+ >
+ <span
+ className="line-match"
+ key="0"
+ >
+ some thing match5
+ </span>
+ <span
+ className="query-match"
+ key="1"
+ >
+ some t
+ </span>
+ <span
+ className="line-match"
+ key="2"
+ >
+ some thing match5
+ </span>
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ </div>
+ </Tree>
+ </div>
+ </div>
+</ProjectSearch>
+`;
+
+exports[`ProjectSearch renders nothing when disabled 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={0}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={false}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="foo"
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="0 results"
+ />
+ </div>
+ <div
+ className="no-result-msg absolute-center"
+ >
+ No results found
+ </div>
+ </div>
+</div>
+`;
+
+exports[`ProjectSearch should display loading message while search is in progress 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={0}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={true}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="match"
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="0 results"
+ />
+ </div>
+ <div
+ className="no-result-msg absolute-center"
+ >
+ Loading…
+ </div>
+ </div>
+</div>
+`;
+
+exports[`ProjectSearch showErrorEmoji false if not done & no results 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={0}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={true}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="foo"
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="0 results"
+ />
+ </div>
+ <div
+ className="no-result-msg absolute-center"
+ >
+ Loading…
+ </div>
+ </div>
+</div>
+`;
+
+exports[`ProjectSearch showErrorEmoji false if not done & results 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={5}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={true}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="foo"
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="5 results"
+ />
+ </div>
+ <Tree
+ autoExpandAll={true}
+ autoExpandDepth={1}
+ autoExpandNodeChildrenLimit={100}
+ focused={null}
+ getChildren={[Function]}
+ getKey={[Function]}
+ getParent={[Function]}
+ getPath={[Function]}
+ getRoots={[Function]}
+ isExpanded={[Function]}
+ itemHeight={24}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ onFocus={[Function]}
+ renderItem={[Function]}
+ />
+ </div>
+</div>
+`;
+
+exports[`ProjectSearch turns off shortcuts on unmount 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={0}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={false}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query=""
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg=""
+ />
+ </div>
+ </div>
+</div>
+`;
+
+exports[`ProjectSearch where <Enter> has not been pressed 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={0}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={false}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query=""
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg=""
+ />
+ </div>
+ </div>
+</div>
+`;