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.css158
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/Outline.js388
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css23
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js68
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css227
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js480
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/Sources.css244
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js352
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js249
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/index.js133
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/moz.build15
11 files changed, 2337 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..6eb890f2d8
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.css
@@ -0,0 +1,158 @@
+/* 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-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);
+
+ /* Since the buttons are on the bottom left edge, we need to adjust the outline so
+ it's not off-screen */
+ outline-offset: -2px;
+
+ &.active {
+ background: var(--theme-selection-background);
+ color: var(--theme-selection-color);
+
+ &:focus-visible {
+ /* When the button is active, it has a similar background color than the outline color
+ so we put the focus box-shadow inside the element to make the focus indicator visible */
+ box-shadow: inset var(--theme-outline-box-shadow);
+ }
+ }
+}
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..79ebf7a38e
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.js
@@ -0,0 +1,388 @@
+/* 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 "devtools/client/shared/vendor/react";
+import {
+ div,
+ ul,
+ li,
+ span,
+ h2,
+ button,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import { containsPosition, positionAfter } from "../../utils/ast";
+import { createLocation } from "../../utils/location";
+
+import actions from "../../actions/index";
+import {
+ getSelectedLocation,
+ getCursorPosition,
+ getSelectedSourceTextContent,
+} from "../../selectors/index";
+
+import OutlineFilter from "./OutlineFilter";
+import PreviewFunction from "../shared/PreviewFunction";
+
+import { isFulfilled } from "../../utils/async-value";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+const {
+ score: fuzzaldrinScore,
+} = require("resource://devtools/client/shared/vendor/fuzzaldrin-plus.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, symbols: null };
+ }
+
+ static get propTypes() {
+ return {
+ alphabetizeOutline: PropTypes.bool.isRequired,
+ cursorPosition: PropTypes.object,
+ flashLineRange: PropTypes.func.isRequired,
+ onAlphabetizeClick: PropTypes.func.isRequired,
+ selectLocation: PropTypes.func.isRequired,
+ selectedLocation: PropTypes.object.isRequired,
+ getFunctionSymbols: PropTypes.func.isRequired,
+ getClassSymbols: PropTypes.func.isRequired,
+ canFetchSymbols: PropTypes.bool,
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.canFetchSymbols) {
+ return;
+ }
+ this.getClassAndFunctionSymbols();
+ }
+
+ componentDidUpdate(prevProps) {
+ const { cursorPosition, selectedLocation, canFetchSymbols } = this.props;
+ if (cursorPosition && cursorPosition !== prevProps.cursorPosition) {
+ this.setFocus(cursorPosition);
+ }
+
+ if (
+ this.focusedElRef &&
+ !isVisible(this.focusedElRef, this.refs.outlineList)
+ ) {
+ this.focusedElRef.scrollIntoView({ block: "center" });
+ }
+
+ // Lets make sure the source text has been loaded and is different
+ if (canFetchSymbols && prevProps.selectedLocation !== selectedLocation) {
+ this.getClassAndFunctionSymbols();
+ }
+ }
+
+ async getClassAndFunctionSymbols() {
+ const { selectedLocation, getFunctionSymbols, getClassSymbols } =
+ this.props;
+
+ const functions = await getFunctionSymbols(selectedLocation);
+ const classes = await getClassSymbols(selectedLocation);
+
+ this.setState({ symbols: { functions, classes } });
+ }
+
+ async setFocus(cursorPosition) {
+ const { symbols } = this.state;
+
+ let classes = [];
+ let functions = [];
+
+ if (symbols) {
+ ({ classes, functions } = symbols);
+ }
+
+ // Find items that enclose the selected location
+ const enclosedItems = [...classes, ...functions].filter(
+ ({ name, location }) => 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 { selectedLocation, selectLocation } = this.props;
+ if (!selectedLocation || !selectedItem) {
+ return;
+ }
+
+ selectLocation(
+ createLocation({
+ source: selectedLocation.source,
+ line: selectedItem.location.start.line,
+ column: selectedItem.location.start.column,
+ })
+ );
+
+ this.setState({ focusedItem: selectedItem });
+ }
+
+ onContextMenu(event, func) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const { symbols } = this.state;
+ this.props.showOutlineContextMenu(event, func, symbols);
+ }
+
+ updateFilter = filter => {
+ this.setState({ filter: filter.trim() });
+ };
+
+ renderPlaceholder() {
+ const placeholderMessage = this.props.selectedLocation
+ ? L10N.getStr("outline.noFunctions")
+ : L10N.getStr("outline.noFileSelected");
+ return div(
+ {
+ className: "outline-pane-info",
+ },
+ placeholderMessage
+ );
+ }
+
+ renderLoading() {
+ return div(
+ {
+ className: "outline-pane-info",
+ },
+ L10N.getStr("loadingText")
+ );
+ }
+
+ 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",
+ },
+ "λ"
+ ),
+ React.createElement(PreviewFunction, {
+ func: {
+ name,
+ parameterNames,
+ },
+ })
+ );
+ }
+
+ renderClassHeader(klass) {
+ return div(
+ null,
+ span(
+ {
+ className: "keyword",
+ },
+ "class"
+ ),
+ " ",
+ klass
+ );
+ }
+
+ renderClassFunctions(klass, functions) {
+ const { symbols } = this.state;
+
+ 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)
+ ),
+ ul(
+ {
+ className: "outline-list__class-list",
+ },
+ classFunctions.map(func => this.renderFunction(func))
+ )
+ );
+ }
+
+ 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))
+ );
+ }
+
+ renderFooter() {
+ return div(
+ {
+ className: "outline-footer",
+ },
+ button(
+ {
+ onClick: this.props.onAlphabetizeClick,
+ className: this.props.alphabetizeOutline ? "active" : "",
+ },
+ L10N.getStr("outline.sortLabel")
+ )
+ );
+ }
+
+ render() {
+ const { selectedLocation } = this.props;
+ const { filter, symbols } = this.state;
+
+ if (!selectedLocation) {
+ return this.renderPlaceholder();
+ }
+
+ if (!symbols) {
+ return this.renderLoading();
+ }
+
+ const { functions } = symbols;
+
+ if (functions.length === 0) {
+ return this.renderPlaceholder();
+ }
+
+ return div(
+ {
+ className: "outline",
+ },
+ div(
+ null,
+ React.createElement(OutlineFilter, {
+ filter: filter,
+ updateFilter: this.updateFilter,
+ }),
+ this.renderFunctions(functions),
+ this.renderFooter()
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const selectedSourceTextContent = getSelectedSourceTextContent(state);
+ return {
+ selectedLocation: getSelectedLocation(state),
+ canFetchSymbols:
+ selectedSourceTextContent && isFulfilled(selectedSourceTextContent),
+ cursorPosition: getCursorPosition(state),
+ };
+};
+
+export default connect(mapStateToProps, {
+ selectLocation: actions.selectLocation,
+ showOutlineContextMenu: actions.showOutlineContextMenu,
+ getFunctionSymbols: actions.getFunctionSymbols,
+ getClassSymbols: actions.getClassSymbols,
+})(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..787e527490
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css
@@ -0,0 +1,23 @@
+/* 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-offset: -2px;
+}
+
+.outline-filter-input::placeholder {
+ color: var(--theme-text-color-alt);
+ opacity: 1;
+}
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..12f6fed2b7
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js
@@ -0,0 +1,68 @@
+/* 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 { Component } from "devtools/client/shared/vendor/react";
+import {
+ form,
+ div,
+ input,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+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(
+ null,
+ 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,
+ })
+ )
+ );
+ }
+}
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..eb11149a79
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css
@@ -0,0 +1,227 @@
+/* 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);
+}
+
+.unavailable-source {
+ white-space: pre;
+
+ .tooltip-panel {
+ padding: 1em;
+ }
+}
+
+.project-text-search .result .line-value {
+ grid-column: 2;
+ padding-block: 1px;
+ padding-inline-end: 4px;
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+ outline-offset: -2px;
+}
+
+.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-search-results-toolbar {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ background-color: var(--theme-accordion-header-background);
+ border-bottom: 1px solid var(--theme-splitter-color);
+ padding: 2px 8px;
+ align-items: center;
+ gap: 4px;
+}
+
+
+.project-text-search .refresh-btn {
+ background-color: transparent;
+ padding: 2px;
+ display: grid;
+ --size: 16px;
+ --highlight-size: 5px;
+ --remain-size: calc(var(--size) - var(--highlight-size));
+ width: var(--size);
+ aspect-ratio: 1;
+ box-sizing: content-box;
+ grid-template-rows: var(--highlight-size) var(--remain-size);
+ grid-template-columns: var(--remain-size) var(--highlight-size);
+
+ &.devtools-button:focus-visible {
+ outline: var(--theme-focus-outline);
+ }
+
+ &.highlight::after {
+ content: "";
+ display: block;
+ grid-row: 1 / 2;
+ grid-column: 2 / 3;
+ height: 5px;
+ width: 5px;
+ background-color: var(--blue-40);
+ border-radius: 100%;
+ outline: 1px solid var(--theme-sidebar-background);
+ z-index: 1;
+ }
+
+ .img {
+ grid-row: 1 / -1;
+ grid-column: 1 / -1;
+ transition: rotate 0.2s;
+ width: 14px; height: 14px;
+
+ .highlight & {
+ rotate: 0.75turn;
+ }
+ }
+}
+
+.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..68b08aed2b
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js
@@ -0,0 +1,480 @@
+/* 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 "devtools/client/shared/vendor/react";
+import {
+ button,
+ div,
+ span,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import actions from "../../actions/index";
+
+import { getEditor } from "../../utils/editor/index";
+import { searchKeys } from "../../constants";
+
+import { getRelativePath } from "../../utils/sources-tree/utils";
+import { getFormattedSourceId } from "../../utils/source";
+import {
+ getProjectSearchQuery,
+ getNavigateCounter,
+} from "../../selectors/index";
+
+import SearchInput from "../shared/SearchInput";
+import AccessibleImage from "../shared/AccessibleImage";
+
+const { PluralForm } = require("resource://devtools/shared/plural-form.js");
+const classnames = require("resource://devtools/client/shared/classnames.js");
+const Tree = require("resource://devtools/client/shared/components/Tree.js");
+const { debounce } = require("resource://devtools/shared/debounce.js");
+const { throttle } = require("resource://devtools/shared/throttle.js");
+
+const {
+ HTMLTooltip,
+} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js");
+
+export const statusType = {
+ initial: "INITIAL",
+ fetching: "FETCHING",
+ cancelled: "CANCELLED",
+ done: "DONE",
+ error: "ERROR",
+};
+
+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 = {
+ // We may restore a previous state when changing tabs in the primary panes,
+ // or when restoring primary panes from collapse.
+ query: this.props.query || "",
+
+ inputFocused: false,
+ focusedItem: null,
+ expanded: new Set(),
+ results: [],
+ navigateCounter: null,
+ status: statusType.done,
+ };
+ // Use throttle for updating results in order to prevent delaying showing result until the end of the search
+ this.onUpdatedResults = throttle(this.onUpdatedResults.bind(this), 100);
+ // Use debounce for input processing in order to wait for the end of user input edition before triggerring the search
+ this.doSearch = debounce(this.doSearch.bind(this), 100);
+ this.doSearch();
+ }
+
+ static get propTypes() {
+ return {
+ doSearchForHighlight: PropTypes.func.isRequired,
+ query: PropTypes.string.isRequired,
+ results: PropTypes.array.isRequired,
+ searchSources: PropTypes.func.isRequired,
+ selectSpecificLocationOrSameUrl: PropTypes.func.isRequired,
+ status: PropTypes.oneOf([
+ "INITIAL",
+ "FETCHING",
+ "CANCELED",
+ "DONE",
+ "ERROR",
+ ]).isRequired,
+ modifiers: PropTypes.object,
+ toggleProjectSearchModifier: PropTypes.func,
+ };
+ }
+
+ async doSearch() {
+ // Cancel any previous async ongoing search
+ if (this.searchAbortController) {
+ this.searchAbortController.abort();
+ }
+
+ if (!this.state.query) {
+ this.setState({ status: statusType.done });
+ return;
+ }
+
+ this.setState({
+ status: statusType.fetching,
+ results: [],
+ navigateCounter: this.props.navigateCounter,
+ });
+
+ // Setup an AbortController whose main goal is to be able to cancel the asynchronous
+ // operation done by the `searchSources` action.
+ // This allows allows the React Component to receive partial updates
+ // to render results as they are available.
+ this.searchAbortController = new AbortController();
+
+ await this.props.searchSources(
+ this.state.query,
+ this.onUpdatedResults,
+ this.searchAbortController.signal
+ );
+ }
+
+ onUpdatedResults(results, done, signal) {
+ // debounce may delay the execution after this search has been cancelled
+ if (signal.aborted) {
+ return;
+ }
+
+ this.setState({
+ results,
+ status: done ? statusType.done : statusType.fetching,
+ });
+ }
+
+ selectMatchItem = async matchItem => {
+ const foundMatchingSource =
+ await this.props.selectSpecificLocationOrSameUrl(matchItem.location);
+ // When we reload, or if the source's target has been destroyed,
+ // we may no longer have the source available in the reducer.
+ // In such case `selectSpecificLocationOrSameUrl` will return false.
+ if (!foundMatchingSource) {
+ // When going over results via the key arrows and Enter, we may display many tooltips at once.
+ if (this.tooltip) {
+ this.tooltip.hide();
+ }
+ // Go down to line-number otherwise HTMLTooltip's call to getBoundingClientRect would return (0, 0) position for the tooltip
+ const element = document.querySelector(
+ ".project-text-search .tree-node.focused .result .line-number"
+ );
+ const tooltip = new HTMLTooltip(element.ownerDocument, {
+ className: "unavailable-source",
+ type: "arrow",
+ });
+ tooltip.panel.textContent = L10N.getStr(
+ "projectTextSearch.sourceNoLongerAvailable"
+ );
+ tooltip.setContentSize({ height: "auto" });
+ tooltip.show(element);
+ this.tooltip = tooltip;
+ return;
+ }
+ this.props.doSearchForHighlight(
+ this.state.query,
+ 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(
+ {
+ className: "query-match",
+ key: 1,
+ },
+ value.substr(matchIndex, len)
+ ),
+ span(
+ {
+ className: "line-match",
+ key: 2,
+ },
+ value.slice(matchIndex + len, value.length)
+ )
+ );
+ };
+
+ getResultCount = () =>
+ this.state.results.reduce((count, file) => count + file.matches.length, 0);
+
+ onKeyDown = e => {
+ if (e.key === "Escape") {
+ return;
+ }
+
+ e.stopPropagation();
+
+ this.setState({ focusedItem: null });
+ this.doSearch();
+ };
+
+ onHistoryScroll = query => {
+ this.setState({ query });
+ this.doSearch();
+ };
+
+ // This can be called by Tree when manually selecting node via arrow keys and Enter.
+ onActivate = item => {
+ if (item && item.type === "MATCH") {
+ this.selectMatchItem(item);
+ }
+ };
+
+ onFocus = item => {
+ if (this.state.focusedItem !== item) {
+ this.setState({
+ focusedItem: item,
+ });
+ }
+ };
+
+ inputOnChange = e => {
+ const inputValue = e.target.value;
+ this.setState({ query: inputValue });
+ this.doSearch();
+ };
+
+ 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,
+ },
+ React.createElement(AccessibleImage, {
+ className: classnames("arrow", {
+ expanded,
+ }),
+ }),
+ React.createElement(AccessibleImage, {
+ className: "file",
+ }),
+ span(
+ {
+ className: "file-path",
+ },
+ file.location.source.url
+ ? getRelativePath(file.location.source.url)
+ : getFormattedSourceId(file.location.source.id)
+ ),
+ span(
+ {
+ className: "matches-summary",
+ },
+ matches
+ )
+ );
+ };
+
+ renderMatch = (match, focused) => {
+ return div(
+ {
+ className: classnames("result", {
+ focused,
+ }),
+ onClick: () => this.selectMatchItem(match),
+ },
+ span(
+ {
+ className: "line-number",
+ key: match.location.line,
+ },
+ match.location.line
+ ),
+ this.highlightMatches(match)
+ );
+ };
+
+ renderItem = (item, depth, focused, _, expanded) => {
+ if (item.type === "RESULT") {
+ return this.renderFile(item, focused, expanded);
+ }
+ return this.renderMatch(item, focused);
+ };
+
+ renderRefreshButton() {
+ if (!this.state.query) {
+ return null;
+ }
+
+ // Highlight the refresh button when the current search results
+ // are based on the previous document. doSearch will save the "navigate counter"
+ // into state, while props will report the current "navigate counter".
+ // The "navigate counter" is incremented each time we navigate to a new page.
+ const highlight =
+ this.state.navigateCounter != null &&
+ this.state.navigateCounter != this.props.navigateCounter;
+ return button(
+ {
+ className: classnames("refresh-btn devtools-button", {
+ highlight,
+ }),
+ title: highlight
+ ? L10N.getStr("projectTextSearch.refreshButtonTooltipOnNavigation")
+ : L10N.getStr("projectTextSearch.refreshButtonTooltip"),
+ onClick: this.doSearch,
+ },
+ React.createElement(AccessibleImage, {
+ className: "refresh",
+ })
+ );
+ }
+
+ renderResultsToolbar() {
+ if (!this.state.query) {
+ return null;
+ }
+ return div(
+ { className: "project-search-results-toolbar" },
+ span({ className: "results-count" }, this.renderSummary()),
+ this.renderRefreshButton()
+ );
+ }
+
+ renderResults() {
+ const { status, results } = this.state;
+ if (!this.state.query) {
+ return null;
+ }
+ if (results.length) {
+ return React.createElement(Tree, {
+ getRoots: () => results,
+ getChildren: file => file.matches || [],
+ autoExpandAll: true,
+ autoExpandDepth: 1,
+ autoExpandNodeChildrenLimit: 100,
+ getParent: item => null,
+ getPath: getFilePath,
+ renderItem: this.renderItem,
+ focused: this.state.focusedItem,
+ onFocus: this.onFocus,
+ onActivate: this.onActivate,
+ 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
+ );
+ }
+
+ renderSummary = () => {
+ if (this.state.query === "") {
+ return "";
+ }
+ const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary2");
+ const count = this.getResultCount();
+ if (count === 0) {
+ return "";
+ }
+ return PluralForm.get(count, resultsSummaryString).replace("#1", count);
+ };
+
+ shouldShowErrorEmoji() {
+ return !this.getResultCount() && this.state.status === statusType.done;
+ }
+
+ renderInput() {
+ const { status } = this.state;
+ return React.createElement(SearchInput, {
+ query: this.state.query,
+ count: this.getResultCount(),
+ placeholder: L10N.getStr("projectTextSearch.placeholder"),
+ size: "small",
+ showErrorEmoji: this.shouldShowErrorEmoji(),
+ 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,
+ });
+ }
+
+ render() {
+ return div(
+ {
+ className: "search-container",
+ },
+ div(
+ {
+ className: "project-text-search",
+ },
+ div(
+ {
+ className: "header",
+ },
+ this.renderInput()
+ ),
+ this.renderResultsToolbar(),
+ this.renderResults()
+ )
+ );
+ }
+}
+
+ProjectSearch.contextTypes = {
+ shortcuts: PropTypes.object,
+};
+
+const mapStateToProps = state => ({
+ query: getProjectSearchQuery(state),
+ navigateCounter: getNavigateCounter(state),
+});
+
+export default connect(mapStateToProps, {
+ searchSources: actions.searchSources,
+ selectSpecificLocationOrSameUrl: actions.selectSpecificLocationOrSameUrl,
+ 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..68c28655be
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Sources.css
@@ -0,0 +1,244 @@
+/* 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;
+
+ & * {
+ user-select: none;
+ }
+
+ /* Tabs header */
+ & .tabs-navigation {
+ height: var(--editor-header-height) !important;
+
+ & .tabs-menu {
+ /* override margin set by the Tabs component */
+ margin: 0 !important;
+ }
+
+ & .tab {
+ flex: 1;
+ overflow: hidden;
+ display: inline-flex;
+ align-items: center;
+ }
+
+ & [role="tab"] {
+ padding: 4px 8px;
+ flex: 1;
+ }
+ }
+}
+
+
+
+/***********************/
+/* 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);
+ flex-shrink: 0;
+ 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..286e673706
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js
@@ -0,0 +1,352 @@
+/* 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,
+ Fragment,
+} from "devtools/client/shared/vendor/react";
+import {
+ div,
+ button,
+ span,
+ footer,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+// Selectors
+import {
+ getMainThreadHost,
+ getExpandedState,
+ getProjectDirectoryRoot,
+ getProjectDirectoryRootName,
+ getSourcesTreeSources,
+ getFocusedSourceItem,
+ getHideIgnoredSources,
+} from "../../selectors/index";
+
+// Actions
+import actions from "../../actions/index";
+
+// Components
+import SourcesTreeItem from "./SourcesTreeItem";
+import AccessibleImage from "../shared/AccessibleImage";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+const Tree = require("resource://devtools/client/shared/components/Tree.js");
+
+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;
+}
+
+class SourcesTree extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+ }
+
+ static get propTypes() {
+ return {
+ mainThreadHost: PropTypes.string.isRequired,
+ expanded: PropTypes.object.isRequired,
+ focusItem: PropTypes.func.isRequired,
+ focused: PropTypes.object,
+ projectRoot: PropTypes.string.isRequired,
+ selectSource: PropTypes.func.isRequired,
+ setExpandedState: PropTypes.func.isRequired,
+ rootItems: PropTypes.object.isRequired,
+ clearProjectDirectoryRoot: PropTypes.func.isRequired,
+ projectRootName: PropTypes.string.isRequired,
+ setHideOrShowIgnoredSources: PropTypes.func.isRequired,
+ hideIgnoredSources: PropTypes.bool.isRequired,
+ };
+ }
+
+ selectSourceItem = item => {
+ this.props.selectSource(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) => {
+ // Note that setExpandedState relies on us to clone this Set
+ // which is going to be store as-is in the reducer.
+ const expanded = new Set(this.props.expanded);
+
+ 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
+ );
+ }
+
+ 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);
+ };
+
+ renderProjectRootHeader() {
+ const { 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(),
+ title: L10N.getStr("removeDirectoryRoot.label"),
+ },
+ React.createElement(AccessibleImage, {
+ className: "home",
+ }),
+ React.createElement(AccessibleImage, {
+ className: "breadcrumb",
+ }),
+ span(
+ {
+ className: "sources-clear-root-label",
+ },
+ projectRootName
+ )
+ )
+ );
+ }
+
+ renderItem = (item, depth, focused, _, expanded) => {
+ const { mainThreadHost } = this.props;
+ return React.createElement(SourcesTreeItem, {
+ item,
+ depth,
+ focused,
+ autoExpand: shouldAutoExpand(item, mainThreadHost),
+ expanded,
+ focusItem: this.onFocus,
+ selectSourceItem: this.selectSourceItem,
+ setExpanded: this.setExpanded,
+ 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,
+ 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 React.createElement(Tree, treeProps);
+ }
+
+ renderPane(child) {
+ const { projectRoot } = this.props;
+ return div(
+ {
+ key: "pane",
+ className: classnames("sources-pane", {
+ "sources-list-custom-root": !!projectRoot,
+ }),
+ },
+ child
+ );
+ }
+
+ 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")
+ )
+ );
+ }
+ 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"))
+ : React.createElement(
+ Fragment,
+ null,
+ this.renderProjectRootHeader(),
+ this.renderTree(),
+ this.renderFooter()
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ mainThreadHost: getMainThreadHost(state),
+ expanded: getExpandedState(state),
+ focused: getFocusedSourceItem(state),
+ projectRoot: getProjectDirectoryRoot(state),
+ rootItems: getSourcesTreeSources(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..fd5ceca46d
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js
@@ -0,0 +1,249 @@
+/* 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 "devtools/client/shared/vendor/react";
+import { div, span } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import SourceIcon from "../shared/SourceIcon";
+import AccessibleImage from "../shared/AccessibleImage";
+
+import {
+ getGeneratedSourceByURL,
+ isSourceOverridden,
+ getHideIgnoredSources,
+} from "../../selectors/index";
+import actions from "../../actions/index";
+
+import { sourceTypes } from "../../utils/source";
+import { createLocation } from "../../utils/location";
+import { safeDecodeItemName } from "../../utils/sources-tree/utils";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+class SourceTreeItem extends Component {
+ static get propTypes() {
+ return {
+ autoExpand: PropTypes.bool.isRequired,
+ depth: PropTypes.bool.isRequired,
+ expanded: PropTypes.bool.isRequired,
+ focusItem: PropTypes.func.isRequired,
+ focused: PropTypes.bool.isRequired,
+ hasMatchingGeneratedSource: PropTypes.bool.isRequired,
+ item: PropTypes.object.isRequired,
+ selectSourceItem: PropTypes.func.isRequired,
+ setExpanded: PropTypes.func.isRequired,
+ getParent: PropTypes.func.isRequired,
+ isOverridden: PropTypes.bool,
+ hideIgnoredSources: 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 => {
+ event.stopPropagation();
+ event.preventDefault();
+ this.props.showSourceTreeItemContextMenu(
+ event,
+ this.props.item,
+ this.props.depth,
+ this.props.setExpanded,
+ this.renderItemName()
+ );
+ };
+
+ renderItemArrow() {
+ const { item, expanded } = this.props;
+ return item.type != "source"
+ ? React.createElement(AccessibleImage, {
+ className: classnames("arrow", {
+ expanded,
+ }),
+ })
+ : span({
+ className: "img no-arrow",
+ });
+ }
+
+ renderIcon(item) {
+ if (item.type == "thread") {
+ const icon = item.thread.targetType.includes("worker")
+ ? "worker"
+ : "window";
+ return React.createElement(AccessibleImage, {
+ className: classnames(icon),
+ });
+ }
+ if (item.type == "group") {
+ if (item.groupName === "Webpack") {
+ return React.createElement(AccessibleImage, {
+ className: "webpack",
+ });
+ } else if (item.groupName === "Angular") {
+ return React.createElement(AccessibleImage, {
+ className: "angular",
+ });
+ }
+ // Check if the group relates to an extension.
+ // This happens when a webextension injects a content script.
+ if (item.isForExtensionSource) {
+ return React.createElement(AccessibleImage, {
+ className: "extension",
+ });
+ }
+ return React.createElement(AccessibleImage, {
+ className: "globe-small",
+ });
+ }
+ if (item.type == "directory") {
+ return React.createElement(AccessibleImage, {
+ className: "folder",
+ });
+ }
+ if (item.type == "source") {
+ const { source, sourceActor } = item;
+ return React.createElement(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() {
+ 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, focused, hasMatchingGeneratedSource, hideIgnoredSources } =
+ this.props;
+
+ if (hideIgnoredSources && item.isBlackBoxed) {
+ return null;
+ }
+ const suffix = hasMatchingGeneratedSource
+ ? span(
+ {
+ className: "suffix",
+ },
+ L10N.getStr("sourceFooter.mappedSuffix")
+ )
+ : 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),
+ span(
+ {
+ className: "label",
+ },
+ this.renderItemName(),
+ suffix
+ )
+ );
+ }
+}
+
+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 {
+ hasMatchingGeneratedSource: getHasMatchingGeneratedSource(state, source),
+ isOverridden: isSourceOverridden(state, source),
+ hideIgnoredSources: getHideIgnoredSources(state),
+ };
+ }
+ return {};
+};
+
+export default connect(mapStateToProps, {
+ showSourceTreeItemContextMenu: actions.showSourceTreeItemContextMenu,
+})(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..a8f6bc9a33
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/index.js
@@ -0,0 +1,133 @@
+/* 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 "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import actions from "../../actions/index";
+import { getSelectedPrimaryPaneTab } from "../../selectors/index";
+import { prefs } from "../../utils/prefs";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import { primaryPaneTabs } from "../../constants";
+
+import Outline from "./Outline";
+import SourcesTree from "./SourcesTree";
+import ProjectSearch from "./ProjectSearch";
+
+const {
+ TabPanel,
+ Tabs,
+} = require("resource://devtools/client/shared/components/tabs/Tabs.js");
+
+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 {
+ 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();
+ }
+ };
+
+ render() {
+ const { selectedTab } = this.props;
+ return React.createElement(
+ "aside",
+ {
+ className: "tab-panel sources-panel",
+ },
+ React.createElement(
+ Tabs,
+ {
+ activeTab: tabs.indexOf(selectedTab),
+ onAfterChange: this.onActivateTab,
+ },
+ React.createElement(
+ TabPanel,
+ {
+ id: "sources-tab",
+ key: `sources-tab${
+ selectedTab === primaryPaneTabs.SOURCES ? "-selected" : ""
+ }`,
+ className: "tab sources-tab",
+ title: L10N.getStr("sources.header"),
+ },
+ React.createElement(SourcesTree, null)
+ ),
+ React.createElement(
+ TabPanel,
+ {
+ id: "outline-tab",
+ key: `outline-tab${
+ selectedTab === primaryPaneTabs.OUTLINE ? "-selected" : ""
+ }`,
+ className: "tab outline-tab",
+ title: L10N.getStr("outline.header"),
+ },
+ React.createElement(Outline, {
+ alphabetizeOutline: this.state.alphabetizeOutline,
+ onAlphabetizeClick: this.onAlphabetizeClick,
+ })
+ ),
+ React.createElement(
+ TabPanel,
+ {
+ id: "search-tab",
+ key: `search-tab${
+ selectedTab === primaryPaneTabs.PROJECT_SEARCH ? "-selected" : ""
+ }`,
+ className: "tab search-tab",
+ title: L10N.getStr("search.header"),
+ },
+ React.createElement(ProjectSearch, null)
+ )
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ 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",
+)