summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/components/QuickOpenModal.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/debugger/src/components/QuickOpenModal.js')
-rw-r--r--devtools/client/debugger/src/components/QuickOpenModal.js519
1 files changed, 519 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/components/QuickOpenModal.js b/devtools/client/debugger/src/components/QuickOpenModal.js
new file mode 100644
index 0000000000..d7a3f6179d
--- /dev/null
+++ b/devtools/client/debugger/src/components/QuickOpenModal.js
@@ -0,0 +1,519 @@
+/* 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 fuzzyAldrin from "fuzzaldrin-plus";
+import { basename } from "../utils/path";
+
+const { throttle } = require("devtools/shared/throttle");
+
+import actions from "../actions";
+import {
+ getDisplayedSourcesList,
+ getQuickOpenEnabled,
+ getQuickOpenQuery,
+ getQuickOpenType,
+ getSelectedSource,
+ getSelectedLocation,
+ getSettledSourceTextContent,
+ getSymbols,
+ getTabs,
+ getContext,
+ getBlackBoxRanges,
+ getProjectDirectoryRoot,
+} from "../selectors";
+import { memoizeLast } from "../utils/memoizeLast";
+import { scrollList } from "../utils/result-list";
+import {
+ formatSymbols,
+ parseLineColumn,
+ formatShortcutResults,
+ formatSourceForList,
+} from "../utils/quick-open";
+import Modal from "./shared/Modal";
+import SearchInput from "./shared/SearchInput";
+import ResultList from "./shared/ResultList";
+
+import "./QuickOpenModal.css";
+
+const maxResults = 100;
+
+const SIZE_BIG = { size: "big" };
+const SIZE_DEFAULT = {};
+
+function filter(values, query) {
+ const preparedQuery = fuzzyAldrin.prepareQuery(query);
+
+ return fuzzyAldrin.filter(values, query, {
+ key: "value",
+ maxResults,
+ preparedQuery,
+ });
+}
+
+export class QuickOpenModal extends Component {
+ // Put it on the class so it can be retrieved in tests
+ static UPDATE_RESULTS_THROTTLE = 100;
+
+ constructor(props) {
+ super(props);
+ this.state = { results: null, selectedIndex: 0 };
+ }
+
+ static get propTypes() {
+ return {
+ closeQuickOpen: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ displayedSources: PropTypes.array.isRequired,
+ blackBoxRanges: PropTypes.object.isRequired,
+ enabled: PropTypes.bool.isRequired,
+ highlightLineRange: PropTypes.func.isRequired,
+ query: PropTypes.string.isRequired,
+ searchType: PropTypes.oneOf([
+ "functions",
+ "goto",
+ "gotoSource",
+ "other",
+ "shortcuts",
+ "sources",
+ "variables",
+ ]).isRequired,
+ selectSpecificLocation: PropTypes.func.isRequired,
+ selectedContentLoaded: PropTypes.bool,
+ selectedSource: PropTypes.object,
+ setQuickOpenQuery: PropTypes.func.isRequired,
+ shortcutsModalEnabled: PropTypes.bool.isRequired,
+ symbols: PropTypes.object.isRequired,
+ symbolsLoading: PropTypes.bool.isRequired,
+ tabUrls: PropTypes.array.isRequired,
+ toggleShortcutsModal: PropTypes.func.isRequired,
+ };
+ }
+
+ setResults(results) {
+ if (results) {
+ results = results.slice(0, maxResults);
+ }
+ this.setState({ results });
+ }
+
+ componentDidMount() {
+ const { query, shortcutsModalEnabled, toggleShortcutsModal } = this.props;
+
+ this.updateResults(query);
+
+ if (shortcutsModalEnabled) {
+ toggleShortcutsModal();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const nowEnabled = !prevProps.enabled && this.props.enabled;
+ const queryChanged = prevProps.query !== this.props.query;
+
+ if (this.refs.resultList && this.refs.resultList.refs) {
+ scrollList(this.refs.resultList.refs, this.state.selectedIndex);
+ }
+
+ if (nowEnabled || queryChanged) {
+ this.updateResults(this.props.query);
+ }
+ }
+
+ closeModal = () => {
+ this.props.closeQuickOpen();
+ };
+
+ dropGoto = query => {
+ const index = query.indexOf(":");
+ return index !== -1 ? query.slice(0, index) : query;
+ };
+
+ formatSources = memoizeLast(
+ (displayedSources, tabUrls, blackBoxRanges, projectDirectoryRoot) => {
+ // Note that we should format all displayed sources,
+ // the actual filtering will only be done late from `searchSources()`
+ return displayedSources.map(source => {
+ const isBlackBoxed = !!blackBoxRanges[source.url];
+ const hasTabOpened = tabUrls.includes(source.url);
+ return formatSourceForList(
+ source,
+ hasTabOpened,
+ isBlackBoxed,
+ projectDirectoryRoot
+ );
+ });
+ }
+ );
+
+ searchSources = query => {
+ const {
+ displayedSources,
+ tabUrls,
+ blackBoxRanges,
+ projectDirectoryRoot,
+ } = this.props;
+
+ const sources = this.formatSources(
+ displayedSources,
+ tabUrls,
+ blackBoxRanges,
+ projectDirectoryRoot
+ );
+ const results =
+ query == "" ? sources : filter(sources, this.dropGoto(query));
+ return this.setResults(results);
+ };
+
+ searchSymbols = query => {
+ const {
+ symbols: { functions },
+ } = this.props;
+
+ let results = functions;
+ results = results.filter(result => result.title !== "anonymous");
+
+ if (query === "@" || query === "#") {
+ return this.setResults(results);
+ }
+ results = filter(results, query.slice(1));
+ return this.setResults(results);
+ };
+
+ searchShortcuts = query => {
+ const results = formatShortcutResults();
+ if (query == "?") {
+ this.setResults(results);
+ } else {
+ this.setResults(filter(results, query.slice(1)));
+ }
+ };
+
+ /**
+ * This method is called when we just opened the modal and the query input is empty
+ */
+ showTopSources = () => {
+ const { tabUrls, blackBoxRanges, projectDirectoryRoot } = this.props;
+ let { displayedSources } = this.props;
+
+ // If there is some tabs opened, only show tab's sources.
+ // Otherwise, we display all visible sources (per SourceTree definition),
+ // setResults will restrict the number of results to a maximum limit.
+ if (tabUrls.length) {
+ displayedSources = displayedSources.filter(
+ source => !!source.url && tabUrls.includes(source.url)
+ );
+ }
+
+ this.setResults(
+ this.formatSources(
+ displayedSources,
+ tabUrls,
+ blackBoxRanges,
+ projectDirectoryRoot
+ )
+ );
+ };
+
+ updateResults = throttle(query => {
+ if (this.isGotoQuery()) {
+ return;
+ }
+
+ if (query == "" && !this.isShortcutQuery()) {
+ this.showTopSources();
+ return;
+ }
+
+ if (this.isSymbolSearch()) {
+ this.searchSymbols(query);
+ return;
+ }
+
+ if (this.isShortcutQuery()) {
+ this.searchShortcuts(query);
+ return;
+ }
+
+ this.searchSources(query);
+ }, QuickOpenModal.UPDATE_RESULTS_THROTTLE);
+
+ setModifier = item => {
+ if (["@", "#", ":"].includes(item.id)) {
+ this.props.setQuickOpenQuery(item.id);
+ }
+ };
+
+ selectResultItem = (e, item) => {
+ if (item == null) {
+ return;
+ }
+
+ if (this.isShortcutQuery()) {
+ this.setModifier(item);
+ return;
+ }
+
+ if (this.isGotoSourceQuery()) {
+ const location = parseLineColumn(this.props.query);
+ this.gotoLocation({ ...location, sourceId: item.id });
+ return;
+ }
+
+ if (this.isSymbolSearch()) {
+ this.gotoLocation({
+ line:
+ item.location && item.location.start ? item.location.start.line : 0,
+ });
+ return;
+ }
+
+ this.gotoLocation({ sourceId: item.id, line: 0 });
+ };
+
+ onSelectResultItem = item => {
+ const { selectedSource, highlightLineRange } = this.props;
+ if (
+ selectedSource == null ||
+ !this.isSymbolSearch() ||
+ !this.isFunctionQuery()
+ ) {
+ return;
+ }
+
+ highlightLineRange({
+ ...(item.location != null
+ ? { start: item.location.start.line, end: item.location.end.line }
+ : {}),
+ sourceId: selectedSource.id,
+ });
+ };
+
+ traverseResults = e => {
+ const direction = e.key === "ArrowUp" ? -1 : 1;
+ const { selectedIndex, results } = this.state;
+ const resultCount = this.getResultCount();
+ const index = selectedIndex + direction;
+ const nextIndex = (index + resultCount) % resultCount || 0;
+
+ this.setState({ selectedIndex: nextIndex });
+
+ if (results != null) {
+ this.onSelectResultItem(results[nextIndex]);
+ }
+ };
+
+ gotoLocation = location => {
+ const { cx, selectSpecificLocation, selectedSource } = this.props;
+
+ if (location != null) {
+ const selectedSourceId = selectedSource ? selectedSource.id : "";
+ const sourceId = location.sourceId ? location.sourceId : selectedSourceId;
+ selectSpecificLocation(cx, {
+ sourceId,
+ line: location.line,
+ column: location.column,
+ });
+ this.closeModal();
+ }
+ };
+
+ onChange = e => {
+ const {
+ selectedSource,
+ selectedContentLoaded,
+ setQuickOpenQuery,
+ } = this.props;
+ setQuickOpenQuery(e.target.value);
+ const noSource = !selectedSource || !selectedContentLoaded;
+ if ((noSource && this.isSymbolSearch()) || this.isGotoQuery()) {
+ return;
+ }
+
+ // Wait for the next tick so that reducer updates are complete.
+ const targetValue = e.target.value;
+ setTimeout(() => this.updateResults(targetValue), 0);
+ };
+
+ onKeyDown = e => {
+ const { enabled, query } = this.props;
+ const { results, selectedIndex } = this.state;
+ const isGoToQuery = this.isGotoQuery();
+
+ if ((!enabled || !results) && !isGoToQuery) {
+ return;
+ }
+
+ if (e.key === "Enter") {
+ if (isGoToQuery) {
+ const location = parseLineColumn(query);
+ this.gotoLocation(location);
+ return;
+ }
+
+ if (results) {
+ this.selectResultItem(e, results[selectedIndex]);
+ return;
+ }
+ }
+
+ if (e.key === "Tab") {
+ this.closeModal();
+ return;
+ }
+
+ if (["ArrowUp", "ArrowDown"].includes(e.key)) {
+ e.preventDefault();
+ this.traverseResults(e);
+ }
+ };
+
+ getResultCount = () => {
+ const { results } = this.state;
+ return results && results.length ? results.length : 0;
+ };
+
+ // Query helpers
+ isFunctionQuery = () => this.props.searchType === "functions";
+ isSymbolSearch = () => this.isFunctionQuery();
+ isGotoQuery = () => this.props.searchType === "goto";
+ isGotoSourceQuery = () => this.props.searchType === "gotoSource";
+ isShortcutQuery = () => this.props.searchType === "shortcuts";
+ isSourcesQuery = () => this.props.searchType === "sources";
+ isSourceSearch = () => this.isSourcesQuery() || this.isGotoSourceQuery();
+
+ /* eslint-disable react/no-danger */
+ renderHighlight(candidateString, query, name) {
+ const options = {
+ wrap: {
+ tagOpen: '<mark class="highlight">',
+ tagClose: "</mark>",
+ },
+ };
+ const html = fuzzyAldrin.wrap(candidateString, query, options);
+ return <div dangerouslySetInnerHTML={{ __html: html }} />;
+ }
+
+ highlightMatching = (query, results) => {
+ let newQuery = query;
+ if (newQuery === "") {
+ return results;
+ }
+ newQuery = query.replace(/[@:#?]/gi, " ");
+
+ return results.map(result => {
+ if (typeof result.title == "string") {
+ return {
+ ...result,
+ title: this.renderHighlight(
+ result.title,
+ basename(newQuery),
+ "title"
+ ),
+ };
+ }
+ return result;
+ });
+ };
+
+ shouldShowErrorEmoji() {
+ const { query } = this.props;
+ if (this.isGotoQuery()) {
+ return !/^:\d*$/.test(query);
+ }
+ return !!query && !this.getResultCount();
+ }
+
+ getSummaryMessage() {
+ let summaryMsg = "";
+ if (this.isGotoQuery()) {
+ summaryMsg = L10N.getStr("shortcuts.gotoLine");
+ } else if (this.isFunctionQuery() && this.props.symbolsLoading) {
+ summaryMsg = L10N.getStr("loadingText");
+ }
+ return summaryMsg;
+ }
+
+ render() {
+ const { enabled, query } = this.props;
+ const { selectedIndex, results } = this.state;
+
+ if (!enabled) {
+ return null;
+ }
+ const items = this.highlightMatching(query, results || []);
+ const expanded = !!items && !!items.length;
+
+ return (
+ <Modal in={enabled} handleClose={this.closeModal}>
+ <SearchInput
+ query={query}
+ hasPrefix={true}
+ count={this.getResultCount()}
+ placeholder={L10N.getStr("sourceSearch.search2")}
+ summaryMsg={this.getSummaryMessage()}
+ showErrorEmoji={this.shouldShowErrorEmoji()}
+ isLoading={false}
+ onChange={this.onChange}
+ onKeyDown={this.onKeyDown}
+ handleClose={this.closeModal}
+ expanded={expanded}
+ showClose={false}
+ selectedItemId={
+ expanded && items[selectedIndex] ? items[selectedIndex].id : ""
+ }
+ {...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT)}
+ />
+ {results && (
+ <ResultList
+ key="results"
+ items={items}
+ selected={selectedIndex}
+ selectItem={this.selectResultItem}
+ ref="resultList"
+ expanded={expanded}
+ {...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT)}
+ />
+ )}
+ </Modal>
+ );
+ }
+}
+
+/* istanbul ignore next: ignoring testing of redux connection stuff */
+function mapStateToProps(state) {
+ const selectedSource = getSelectedSource(state);
+ const displayedSources = getDisplayedSourcesList(state);
+ const tabs = getTabs(state);
+ const tabUrls = [...new Set(tabs.map(tab => tab.url))];
+ const symbols = getSymbols(state, selectedSource);
+ const location = getSelectedLocation(state);
+
+ return {
+ cx: getContext(state),
+ enabled: getQuickOpenEnabled(state),
+ displayedSources,
+ blackBoxRanges: getBlackBoxRanges(state),
+ projectDirectoryRoot: getProjectDirectoryRoot(state),
+ selectedSource,
+ selectedContentLoaded: location
+ ? !!getSettledSourceTextContent(state, location)
+ : undefined,
+ symbols: formatSymbols(symbols, maxResults),
+ symbolsLoading: !symbols,
+ query: getQuickOpenQuery(state),
+ searchType: getQuickOpenType(state),
+ tabUrls,
+ };
+}
+
+/* istanbul ignore next: ignoring testing of redux connection stuff */
+export default connect(mapStateToProps, {
+ selectSpecificLocation: actions.selectSpecificLocation,
+ setQuickOpenQuery: actions.setQuickOpenQuery,
+ highlightLineRange: actions.highlightLineRange,
+ closeQuickOpen: actions.closeQuickOpen,
+})(QuickOpenModal);