diff --git a/devtools/client/debugger/test/mochitest/browser_dbg-project-search.js b/devtools/client/debugger/test/mochitest/browser_dbg-project-search.js
new file mode 100644
index 0000000..f1b638d
--- /dev/null
+++ b/devtools/client/debugger/test/mochitest/browser_dbg-project-search.js
@@ -0,0 +1,306 @@
+/* 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 . */
+
+// Testing various project search features
+
+"use strict";
+
+requestLongerTimeout(3);
+
+add_task(async function testProjectSearchCloseOnNavigation() {
+ const dbg = await initDebugger(
+ "doc-script-switching.html",
+ "script-switching-01.js"
+ );
+
+ await selectSource(dbg, "script-switching-01.js");
+
+ await openProjectSearch(dbg);
+
+ ok(
+ !findElement(dbg, "projectSearchRefreshButton"),
+ "The refresh button is only visible after having done a search"
+ );
+
+ await doProjectSearch(dbg, "function", 2);
+
+ is(getExpandedResultsCount(dbg), 9);
+
+ is(dbg.selectors.getActiveSearch(), "project");
+
+ const refreshButton = findElement(dbg, "projectSearchRefreshButton");
+ ok(
+ refreshButton,
+ "Refresh button is visible right after search is completed"
+ );
+ ok(
+ !refreshButton.classList.contains("highlight"),
+ "Refresh button is *not* highlighted by default"
+ );
+
+ await navigate(dbg, "doc-scripts.html");
+
+ // Project search is still visible after navigation
+ is(dbg.selectors.getActiveSearch(), "project");
+ // With same search results
+ is(getExpandedResultsCount(dbg), 9);
+
+ ok(
+ refreshButton.classList.contains("highlight"),
+ "Refresh button is highlighted after navigation"
+ );
+
+ info("Try to open a discarded source");
+ await clickElement(dbg, "fileMatch");
+
+ info("Wait for the warning popup to be visible");
+ // We are waiting for the popup to be added...
+ await waitFor(() => dbg.win.document.querySelector(".unavailable-source"));
+ // ...and verify that the popup is made visible.
+ await waitFor(() => dbg.win.document.querySelector(".tooltip-shown"));
+ info("Retry to open the discard source, this should hide the popup");
+ await clickElement(dbg, "fileMatch");
+ info("Wait for the popup to be hidden");
+ // Note that .unavailable-source won't be removed from the DOM
+ await waitFor(() => !dbg.win.document.querySelector(".tooltip-visible"));
+
+ info("Refresh results against the new page");
+ refreshButton.click();
+
+ // Wait for the search to be updated against the new page
+ await waitForSearchResults(dbg, 5);
+ is(getExpandedResultsCount(dbg), 39);
+ ok(
+ !refreshButton.classList.contains("highlight"),
+ "Refresh button is no longer highlighted after refreshing the search"
+ );
+});
+
+add_task(async function testSimpleProjectSearch() {
+ // Start with side panel collapsed so we can assert that the project search keyboard
+ // shortcut will open it.
+ await pushPref("devtools.debugger.start-panel-collapsed", true);
+
+ const dbg = await initDebugger(
+ "doc-script-switching.html",
+ "script-switching-01.js"
+ );
+
+ await openProjectSearch(dbg);
+
+ ok(
+ !!findElementWithSelector(dbg, ".project-text-search"),
+ "Project search is visible"
+ );
+
+ const searchTerm = "first";
+ await doProjectSearch(dbg, searchTerm, 1);
+
+ const queryMatch = findElement(dbg, "fileMatch").querySelector(
+ ".query-match"
+ );
+ is(
+ queryMatch.innerText,
+ searchTerm,
+ "The highlighted text matches the search term"
+ );
+
+ info("Select a result match to open the location in the source");
+ await clickElement(dbg, "fileMatch");
+ await waitForSelectedSource(dbg, "script-switching-01.js");
+
+ info("Close start sidebar");
+ const startPanelToggleButtonEl = findElementWithSelector(
+ dbg,
+ ".toggle-button.start"
+ );
+ startPanelToggleButtonEl.click();
+ await waitFor(() => startPanelToggleButtonEl.classList.contains("collapsed"));
+
+ info("Try to open project search again");
+ await openProjectSearch(dbg);
+ ok(
+ !!findElementWithSelector(dbg, ".project-text-search"),
+ "Project search is visible"
+ );
+});
+
+add_task(async function testMatchesForRegexSearches() {
+ const dbg = await initDebugger("doc-react.html", "App.js");
+ await openProjectSearch(dbg);
+
+ type(dbg, "import .* from 'react'");
+ await clickElement(dbg, "projectSearchModifiersRegexMatch");
+
+ await waitForSearchResults(dbg, 2);
+
+ const queryMatch = findAllElements(dbg, "fileMatch")[1].querySelector(
+ ".query-match"
+ );
+
+ is(
+ queryMatch.innerText,
+ "import React, { Component } from 'react'",
+ "The highlighted text matches the search term"
+ );
+
+ // Turn off the regex modifier so does not break tests below
+ await clickElement(dbg, "projectSearchModifiersRegexMatch");
+});
+
+// Test expanding search results to reveal the search matches.
+add_task(async function testExpandSearchResultsToShowMatches() {
+ const dbg = await initDebugger("doc-react.html", "App.js");
+
+ await openProjectSearch(dbg);
+ await doProjectSearch(dbg, "we", 19);
+
+ is(getExpandedResultsCount(dbg), 159);
+
+ const collapsedNodes = findAllElements(dbg, "projectSearchCollapsed");
+ is(collapsedNodes.length, 1);
+
+ collapsedNodes[0].click();
+
+ is(getExpandedResultsCount(dbg), 367);
+});
+
+add_task(async function testSearchModifiers() {
+ const dbg = await initDebugger("doc-react.html", "App.js");
+
+ await openProjectSearch(dbg);
+
+ await assertProjectSearchModifier(
+ dbg,
+ "projectSearchModifiersCaseSensitive",
+ "FIELDS",
+ "case sensitive",
+ { resultWithModifierOn: 0, resultWithModifierOff: 2 }
+ );
+ await assertProjectSearchModifier(
+ dbg,
+ "projectSearchModifiersRegexMatch",
+ `\\*`,
+ "regex match",
+ { resultWithModifierOn: 12, resultWithModifierOff: 0 }
+ );
+ await assertProjectSearchModifier(
+ dbg,
+ "projectSearchModifiersWholeWordMatch",
+ "so",
+ "whole word match",
+ { resultWithModifierOn: 6, resultWithModifierOff: 16 }
+ );
+});
+
+add_task(async function testSearchExcludePatterns() {
+ const dbg = await initDebugger("doc-react.html", "App.js");
+
+ info("Search across all files");
+ await openProjectSearch(dbg);
+ let fileResults = await doProjectSearch(dbg, "console", 5);
+
+ let resultsFromNodeModules = [...fileResults].filter(result =>
+ result.innerText.includes("node_modules")
+ );
+
+ is(
+ resultsFromNodeModules.length,
+ 3,
+ "3 results were found from node_modules"
+ );
+
+ info("Excludes search results based on multiple search patterns");
+
+ await clickElement(dbg, "excludePatternsInput");
+ type(dbg, "App.js, main.js");
+ pressKey(dbg, "Enter");
+
+ fileResults = await waitForSearchResults(dbg, 3);
+
+ const resultsFromAppJS = [...fileResults].filter(result =>
+ result.innerText.includes("App.js")
+ );
+
+ is(resultsFromAppJS.length, 0, "None of the results is from the App.js file");
+
+ const resultsFromMainJS = [...fileResults].filter(result =>
+ result.innerText.includes("main.js")
+ );
+
+ is(
+ resultsFromMainJS.length,
+ 0,
+ "None of the results is from the main.js file"
+ );
+
+ info("Excludes search results from node modules files");
+
+ await clearElement(dbg, "excludePatternsInput");
+ type(dbg, "**/node_modules/**");
+ pressKey(dbg, "Enter");
+
+ fileResults = await waitForSearchResults(dbg, 2);
+
+ resultsFromNodeModules = [...fileResults].filter(result =>
+ result.innerText.includes("node_modules")
+ );
+
+ is(
+ resultsFromNodeModules.length,
+ 0,
+ "None of the results is from the node modules files"
+ );
+
+ info("Assert that the exclude pattern is persisted across reloads");
+ await reloadSelectedTab();
+ await openProjectSearch(dbg);
+
+ const excludePatternsInputElement = await waitForElement(
+ dbg,
+ "excludePatternsInput"
+ );
+
+ is(
+ excludePatternsInputElement.value,
+ "**/node_modules/**",
+ "The exclude pattern for node modules is persisted accross reloads"
+ );
+
+ // Clear the fields so that it does not impact on the subsequent tests
+ await clearElement(dbg, "projectSearchSearchInput");
+ await clearElement(dbg, "excludePatternsInput");
+ pressKey(dbg, "Enter");
+});
+
+async function assertProjectSearchModifier(
+ dbg,
+ searchModifierBtn,
+ searchTerm,
+ title,
+ expected
+) {
+ info(`Assert ${title} search modifier`);
+
+ type(dbg, searchTerm);
+ info(`Turn on the ${title} search modifier option`);
+ await clickElement(dbg, searchModifierBtn);
+ let results = await waitForSearchResults(dbg, expected.resultWithModifierOn);
+ is(
+ results.length,
+ expected.resultWithModifierOn,
+ `${results.length} results where found`
+ );
+
+ info(`Turn off the ${title} search modifier`);
+ await clickElement(dbg, searchModifierBtn);
+
+ results = await waitForSearchResults(dbg, expected.resultWithModifierOff);
+ is(
+ results.length,
+ expected.resultWithModifierOff,
+ `${results.length} results where found`
+ );
+ await clearElement(dbg, "projectSearchSearchInput");
+}
diff --git a/devtools/client/shared/components/SearchInput.js b/devtools/client/shared/components/SearchInput.js
new file mode 100644
index 0000000..74a8e8d
--- /dev/null
+++ b/devtools/client/shared/components/SearchInput.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 . */
+
+"use strict";
+
+const React = require("devtools/client/shared/vendor/react");
+const { Component } = React;
+
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const {
+ button,
+ div,
+ label,
+ input,
+ span,
+} = require("devtools/client/shared/vendor/react-dom-factories");
+
+const DebuggerImage = require("devtools/client/shared/components/DebuggerImage");
+const CloseButton = require("devtools/client/shared/components/CloseButton");
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+const SearchModifiers = require("resource://devtools/client/shared/components/SearchModifiers.js");
+
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const locale = new LocalizationHelper(
+ "devtools/client/locales/components.properties"
+);
+
+const arrowBtn = (onClick, type, className, tooltip) => {
+ const props = {
+ className,
+ key: type,
+ onClick,
+ title: tooltip,
+ type,
+ };
+ return button(
+ props,
+ React.createElement(DebuggerImage, {
+ name: type,
+ })
+ );
+};
+
+class SearchInput extends Component {
+ static get propTypes() {
+ return {
+ count: PropTypes.number.isRequired,
+ expanded: PropTypes.bool.isRequired,
+ handleClose: PropTypes.func,
+ handleNext: PropTypes.func,
+ handlePrev: PropTypes.func,
+ hasPrefix: PropTypes.bool.isRequired,
+ isLoading: PropTypes.bool.isRequired,
+ onBlur: PropTypes.func,
+ onChange: PropTypes.func,
+ onFocus: PropTypes.func,
+ onHistoryScroll: PropTypes.func,
+ onKeyDown: PropTypes.func,
+ onKeyUp: PropTypes.func,
+ placeholder: PropTypes.string,
+ query: PropTypes.string,
+ selectedItemId: PropTypes.string,
+ shouldFocus: PropTypes.bool,
+ showClose: PropTypes.bool.isRequired,
+ showExcludePatterns: PropTypes.bool.isRequired,
+ excludePatternsLabel: PropTypes.string,
+ excludePatternsPlaceholder: PropTypes.string,
+ showErrorEmoji: PropTypes.bool.isRequired,
+ size: PropTypes.string,
+ disabled: PropTypes.bool,
+ summaryMsg: PropTypes.string,
+ searchKey: PropTypes.string.isRequired,
+ searchOptions: PropTypes.object.isRequired,
+ setSearchOptions: PropTypes.func.isRequired,
+ showSearchModifiers: PropTypes.bool.isRequired,
+ onToggleSearchModifier: PropTypes.func,
+ };
+ }
+
+ static defaultProps = {
+ expanded: false,
+ hasPrefix: false,
+ selectedItemId: "",
+ size: "",
+ showClose: true,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ history: [],
+ excludePatterns: this.props.showSearchModifiers
+ ? (props.searchOptions?.excludePatterns ?? "")
+ : null,
+ };
+ }
+
+ componentDidMount() {
+ this.setFocus();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.shouldFocus && !prevProps.shouldFocus) {
+ this.setFocus();
+ }
+ }
+
+ setFocus() {
+ if (this.$input) {
+ const _input = this.$input;
+ _input.focus();
+
+ if (!_input.value) {
+ return;
+ }
+
+ // omit prefix @:# from being selected
+ const selectStartPos = this.props.hasPrefix ? 1 : 0;
+ _input.setSelectionRange(selectStartPos, _input.value.length + 1);
+ }
+ }
+
+ renderArrowButtons() {
+ const { handleNext, handlePrev } = this.props;
+
+ return [
+ arrowBtn(
+ handlePrev,
+ "arrow-up",
+ classnames("nav-btn", "prev"),
+ locale.getStr("editor.searchResults.prevResult"),
+ DebuggerImage
+ ),
+ arrowBtn(
+ handleNext,
+ "arrow-down",
+ classnames("nav-btn", "next"),
+ locale.getStr("editor.searchResults.nextResult"),
+ DebuggerImage
+ ),
+ ];
+ }
+
+ onFocus = e => {
+ const { onFocus } = this.props;
+
+ if (onFocus) {
+ onFocus(e);
+ }
+ };
+
+ onBlur = e => {
+ const { onBlur } = this.props;
+
+ if (onBlur) {
+ onBlur(e);
+ }
+ };
+
+ onKeyDown = e => {
+ const { onHistoryScroll, onKeyDown } = this.props;
+ if (!onHistoryScroll) {
+ onKeyDown(e);
+ return;
+ }
+
+ const inputValue = e.target.value;
+ const { history } = this.state;
+ const currentHistoryIndex = history.indexOf(inputValue);
+
+ if (e.key === "Enter") {
+ this.saveEnteredTerm(inputValue);
+ onKeyDown(e);
+ return;
+ }
+
+ if (e.key === "ArrowUp") {
+ const previous =
+ currentHistoryIndex > -1 ? currentHistoryIndex - 1 : history.length - 1;
+ const previousInHistory = history[previous];
+ if (previousInHistory) {
+ e.preventDefault();
+ onHistoryScroll(previousInHistory);
+ }
+ return;
+ }
+
+ if (e.key === "ArrowDown") {
+ const next = currentHistoryIndex + 1;
+ const nextInHistory = history[next];
+ if (nextInHistory) {
+ onHistoryScroll(nextInHistory);
+ }
+ }
+ };
+
+ onExcludeKeyDown = e => {
+ if (e.key === "Enter") {
+ this.props.setSearchOptions(this.props.searchKey, {
+ excludePatterns: this.state.excludePatterns,
+ });
+ this.props.onKeyDown(e);
+ }
+ };
+
+ saveEnteredTerm(query) {
+ const { history } = this.state;
+ const previousIndex = history.indexOf(query);
+ if (previousIndex !== -1) {
+ history.splice(previousIndex, 1);
+ }
+ history.push(query);
+ this.setState({ history });
+ }
+
+ renderSummaryMsg() {
+ const { summaryMsg } = this.props;
+
+ if (!summaryMsg) {
+ return null;
+ }
+ return div(
+ {
+ className: "search-field-summary",
+ },
+ summaryMsg
+ );
+ }
+
+ renderSpinner() {
+ const { isLoading } = this.props;
+ if (!isLoading) {
+ return null;
+ }
+ return React.createElement(DebuggerImage, {
+ name: "loader",
+ className: "spin",
+ });
+ }
+
+ renderNav() {
+ const { count, handleNext, handlePrev } = this.props;
+ if ((!handleNext && !handlePrev) || !count || count == 1) {
+ return null;
+ }
+ return div(
+ {
+ className: "search-nav-buttons",
+ },
+ this.renderArrowButtons()
+ );
+ }
+
+ renderSearchModifiers() {
+ if (!this.props.showSearchModifiers) {
+ return null;
+ }
+ return React.createElement(SearchModifiers, {
+ modifiers: this.props.searchOptions,
+ onToggleSearchModifier: updatedOptions => {
+ this.props.setSearchOptions(this.props.searchKey, updatedOptions);
+ this.props.onToggleSearchModifier();
+ },
+ });
+ }
+
+ renderExcludePatterns() {
+ if (!this.props.showExcludePatterns) {
+ return null;
+ }
+ return div(
+ {
+ className: classnames("exclude-patterns-field", this.props.size),
+ },
+ label(null, this.props.excludePatternsLabel),
+ input({
+ placeholder: this.props.excludePatternsPlaceholder,
+ value: this.state.excludePatterns,
+ onKeyDown: this.onExcludeKeyDown,
+ onChange: e =>
+ this.setState({
+ excludePatterns: e.target.value,
+ }),
+ })
+ );
+ }
+
+ renderClose() {
+ if (!this.props.showClose) {
+ return null;
+ }
+ return React.createElement(
+ React.Fragment,
+ null,
+ span({
+ className: "pipe-divider",
+ }),
+ React.createElement(CloseButton, {
+ handleClick: this.props.handleClose,
+ buttonClass: this.props.size,
+ tooltip: locale.getStr("editor.searchResults.close"),
+ })
+ );
+ }
+
+ render() {
+ const {
+ expanded,
+ onChange,
+ onKeyUp,
+ placeholder,
+ query,
+ selectedItemId,
+ showErrorEmoji,
+ size,
+ disabled,
+ } = this.props;
+
+ const inputProps = {
+ className: classnames({
+ empty: showErrorEmoji,
+ }),
+ disabled,
+ onChange,
+ onKeyDown: e => this.onKeyDown(e),
+ onKeyUp,
+ onFocus: e => this.onFocus(e),
+ onBlur: e => this.onBlur(e),
+ "aria-autocomplete": "list",
+ "aria-controls": "result-list",
+ "aria-activedescendant":
+ expanded && selectedItemId ? `${selectedItemId}-title` : "",
+ placeholder,
+ value: query,
+ spellCheck: false,
+ ref: c => (this.$input = c),
+ };
+ return div(
+ {
+ className: "search-outline",
+ },
+ div(
+ {
+ className: classnames("search-field", size),
+ role: "combobox",
+ "aria-haspopup": "listbox",
+ "aria-owns": "result-list",
+ "aria-expanded": expanded,
+ },
+ React.createElement(DebuggerImage, {
+ name: "search",
+ }),
+ input(inputProps),
+ this.renderSpinner(),
+ this.renderSummaryMsg(),
+ this.renderNav(),
+ div(
+ {
+ className: "search-buttons-bar",
+ },
+ this.renderSearchModifiers(),
+ this.renderClose()
+ )
+ ),
+ this.renderExcludePatterns()
+ );
+ }
+}
+
+module.exports = SearchInput;