tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

QuickOpenModal.js (14476B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
      4 
      5 import React, { Component } from "devtools/client/shared/vendor/react";
      6 import { div } from "devtools/client/shared/vendor/react-dom-factories";
      7 import PropTypes from "devtools/client/shared/vendor/react-prop-types";
      8 import { connect } from "devtools/client/shared/vendor/react-redux";
      9 import { basename } from "../utils/path";
     10 import { createLocation } from "../utils/location";
     11 
     12 const fuzzyAldrin = require("resource://devtools/client/shared/vendor/fuzzaldrin-plus.js");
     13 const { throttle } = require("resource://devtools/shared/throttle.js");
     14 
     15 import actions from "../actions/index";
     16 import {
     17  getDisplayedSourcesList,
     18  getQuickOpenQuery,
     19  getQuickOpenType,
     20  getSelectedLocation,
     21  getSettledSourceTextContent,
     22  getOpenedSources,
     23  getBlackBoxRanges,
     24  getProjectDirectoryRoot,
     25 } from "../selectors/index";
     26 import { memoizeLast } from "../utils/memoizeLast";
     27 import { searchKeys } from "../constants";
     28 import {
     29  formatSymbol,
     30  parseLineColumn,
     31  formatShortcutResults,
     32  formatSourceForList,
     33 } from "../utils/quick-open";
     34 import Modal from "./shared/Modal";
     35 import SearchInput from "./shared/SearchInput";
     36 import ResultList from "./shared/ResultList";
     37 
     38 const maxResults = 100;
     39 
     40 const SIZE_BIG = { size: "big" };
     41 const SIZE_DEFAULT = {};
     42 
     43 function filter(values, query, key = "value") {
     44  const preparedQuery = fuzzyAldrin.prepareQuery(query);
     45 
     46  return fuzzyAldrin.filter(values, query, {
     47    key,
     48    maxResults,
     49    preparedQuery,
     50  });
     51 }
     52 
     53 export class QuickOpenModal extends Component {
     54  // Put it on the class so it can be retrieved in tests
     55  static UPDATE_RESULTS_THROTTLE = 100;
     56 
     57  #willUnmountCalled = false;
     58 
     59  constructor(props) {
     60    super(props);
     61    this.state = { results: null, selectedIndex: 0 };
     62  }
     63 
     64  static get propTypes() {
     65    return {
     66      closeQuickOpen: PropTypes.func.isRequired,
     67      displayedSources: PropTypes.array.isRequired,
     68      blackBoxRanges: PropTypes.object.isRequired,
     69      highlightLineRange: PropTypes.func.isRequired,
     70      clearHighlightLineRange: PropTypes.func.isRequired,
     71      query: PropTypes.string.isRequired,
     72      searchType: PropTypes.oneOf([
     73        "functions",
     74        "goto",
     75        "gotoSource",
     76        "other",
     77        "shortcuts",
     78        "sources",
     79        "variables",
     80      ]).isRequired,
     81      selectSpecificLocation: PropTypes.func.isRequired,
     82      selectedContentLoaded: PropTypes.bool,
     83      selectedLocation: PropTypes.object,
     84      setQuickOpenQuery: PropTypes.func.isRequired,
     85      openedSources: PropTypes.array.isRequired,
     86      toggleShortcutsModal: PropTypes.func.isRequired,
     87      projectDirectoryRoot: PropTypes.string,
     88      getFunctionSymbols: PropTypes.func.isRequired,
     89    };
     90  }
     91 
     92  setResults(results) {
     93    if (results) {
     94      results = results.slice(0, maxResults);
     95    }
     96    this.setState({ results });
     97  }
     98 
     99  componentDidMount() {
    100    const { query, shortcutsModalEnabled, toggleShortcutsModal } = this.props;
    101 
    102    this.updateResults(query);
    103 
    104    if (shortcutsModalEnabled) {
    105      toggleShortcutsModal();
    106    }
    107  }
    108 
    109  componentDidUpdate(prevProps) {
    110    const queryChanged = prevProps.query !== this.props.query;
    111 
    112    if (queryChanged) {
    113      this.updateResults(this.props.query);
    114    }
    115  }
    116 
    117  componentWillUnmount() {
    118    this.#willUnmountCalled = true;
    119  }
    120 
    121  closeModal = () => {
    122    this.props.closeQuickOpen();
    123  };
    124 
    125  dropGoto = query => {
    126    const index = query.indexOf(":");
    127    return index !== -1 ? query.slice(0, index) : query;
    128  };
    129 
    130  formatSources = memoizeLast(
    131    (displayedSources, openedSources, blackBoxRanges, projectDirectoryRoot) => {
    132      // Note that we should format all displayed sources,
    133      // the actual filtering will only be done late from `searchSources()`
    134      return displayedSources.map(source => {
    135        const isBlackBoxed = !!blackBoxRanges[source.url];
    136        const hasTabOpened = openedSources.includes(source);
    137        return formatSourceForList(
    138          source,
    139          hasTabOpened,
    140          isBlackBoxed,
    141          projectDirectoryRoot
    142        );
    143      });
    144    }
    145  );
    146 
    147  searchSources = query => {
    148    const {
    149      displayedSources,
    150      openedSources,
    151      blackBoxRanges,
    152      projectDirectoryRoot,
    153    } = this.props;
    154 
    155    const sources = this.formatSources(
    156      displayedSources,
    157      openedSources,
    158      blackBoxRanges,
    159      projectDirectoryRoot
    160    );
    161    const results =
    162      query == "" ? sources : filter(sources, this.dropGoto(query));
    163    return this.setResults(results);
    164  };
    165 
    166  searchSymbols = async query => {
    167    const { getFunctionSymbols, selectedLocation } = this.props;
    168    if (!selectedLocation) {
    169      return this.setResults([]);
    170    }
    171    let results = await getFunctionSymbols(selectedLocation, maxResults);
    172 
    173    if (query === "@" || query === "#") {
    174      results = results.map(formatSymbol);
    175      return this.setResults(results);
    176    }
    177    results = filter(results, query.slice(1), "name");
    178    results = results.map(formatSymbol);
    179    return this.setResults(results);
    180  };
    181 
    182  searchShortcuts = query => {
    183    const results = formatShortcutResults();
    184    if (query == "?") {
    185      this.setResults(results);
    186    } else {
    187      this.setResults(filter(results, query.slice(1)));
    188    }
    189  };
    190 
    191  /**
    192   * This method is called when we just opened the modal and the query input is empty
    193   */
    194  showTopSources = () => {
    195    const { openedSources, blackBoxRanges, projectDirectoryRoot } = this.props;
    196    let { displayedSources } = this.props;
    197 
    198    // If there is some tabs opened, only show tab's sources.
    199    // Otherwise, we display all visible sources (per SourceTree definition),
    200    // setResults will restrict the number of results to a maximum limit.
    201    if (openedSources.length) {
    202      displayedSources = displayedSources.filter(
    203        source => !!source.url && openedSources.includes(source)
    204      );
    205    }
    206 
    207    this.setResults(
    208      this.formatSources(
    209        displayedSources,
    210        openedSources,
    211        blackBoxRanges,
    212        projectDirectoryRoot
    213      )
    214    );
    215  };
    216 
    217  updateResults = throttle(async query => {
    218    try {
    219      if (this.isGotoQuery()) {
    220        return;
    221      }
    222 
    223      if (query == "" && !this.isShortcutQuery()) {
    224        this.showTopSources();
    225        return;
    226      }
    227 
    228      if (this.isSymbolSearch()) {
    229        await this.searchSymbols(query);
    230        return;
    231      }
    232 
    233      if (this.isShortcutQuery()) {
    234        this.searchShortcuts(query);
    235        return;
    236      }
    237 
    238      this.searchSources(query);
    239    } catch (e) {
    240      // Due to throttling this might get scheduled after the component and the
    241      // toolbox are destroyed.
    242      if (this.#willUnmountCalled) {
    243        console.warn("Throttled QuickOpen.updateResults failed", e);
    244      } else {
    245        throw e;
    246      }
    247    }
    248  }, QuickOpenModal.UPDATE_RESULTS_THROTTLE);
    249 
    250  setModifier = item => {
    251    if (["@", "#", ":"].includes(item.id)) {
    252      this.props.setQuickOpenQuery(item.id);
    253    }
    254  };
    255 
    256  selectResultItem = (e, item) => {
    257    if (item == null) {
    258      return;
    259    }
    260 
    261    if (this.isShortcutQuery()) {
    262      this.setModifier(item);
    263      return;
    264    }
    265 
    266    if (this.isGotoSourceQuery()) {
    267      const location = parseLineColumn(this.props.query);
    268      this.gotoLocation({ ...location, source: item.source });
    269      return;
    270    }
    271 
    272    if (this.isSymbolSearch()) {
    273      this.gotoLocation({
    274        line:
    275          item.location && item.location.start ? item.location.start.line : 0,
    276      });
    277      return;
    278    }
    279 
    280    this.gotoLocation({ source: item.source, line: 0 });
    281  };
    282 
    283  onSelectResultItem = item => {
    284    const { selectedLocation, highlightLineRange, clearHighlightLineRange } =
    285      this.props;
    286    if (
    287      selectedLocation == null ||
    288      !this.isSymbolSearch() ||
    289      !this.isFunctionQuery()
    290    ) {
    291      return;
    292    }
    293 
    294    if (item.location) {
    295      highlightLineRange({
    296        start: item.location.start.line,
    297        end: item.location.end.line,
    298        sourceId: selectedLocation.source.id,
    299      });
    300    } else {
    301      clearHighlightLineRange();
    302    }
    303  };
    304 
    305  traverseResults = e => {
    306    const direction = e.key === "ArrowUp" ? -1 : 1;
    307    const { selectedIndex, results } = this.state;
    308    const resultCount = this.getResultCount();
    309    const index = selectedIndex + direction;
    310    const nextIndex = (index + resultCount) % resultCount || 0;
    311 
    312    this.setState({ selectedIndex: nextIndex });
    313 
    314    if (results != null) {
    315      this.onSelectResultItem(results[nextIndex]);
    316    }
    317  };
    318 
    319  gotoLocation = location => {
    320    const { selectSpecificLocation, selectedLocation } = this.props;
    321 
    322    if (location != null) {
    323      const sourceLocation = createLocation({
    324        source: location.source || selectedLocation?.source,
    325        line: location.line,
    326        column: location.column || 0,
    327      });
    328      selectSpecificLocation(sourceLocation);
    329      this.closeModal();
    330    }
    331  };
    332 
    333  onChange = e => {
    334    const { selectedLocation, selectedContentLoaded, setQuickOpenQuery } =
    335      this.props;
    336    setQuickOpenQuery(e.target.value);
    337    const noSource = !selectedLocation || !selectedContentLoaded;
    338    if ((noSource && this.isSymbolSearch()) || this.isGotoQuery()) {
    339      return;
    340    }
    341 
    342    // Wait for the next tick so that reducer updates are complete.
    343    const targetValue = e.target.value;
    344    setTimeout(() => this.updateResults(targetValue), 0);
    345  };
    346 
    347  onKeyDown = e => {
    348    const { query } = this.props;
    349    const { results, selectedIndex } = this.state;
    350    const isGoToQuery = this.isGotoQuery();
    351 
    352    if (!results && !isGoToQuery) {
    353      return;
    354    }
    355 
    356    if (e.key === "Enter") {
    357      if (isGoToQuery) {
    358        const location = parseLineColumn(query);
    359        this.gotoLocation(location);
    360        return;
    361      }
    362 
    363      if (results) {
    364        this.selectResultItem(e, results[selectedIndex]);
    365        return;
    366      }
    367    }
    368 
    369    if (e.key === "Tab") {
    370      this.closeModal();
    371      return;
    372    }
    373 
    374    if (["ArrowUp", "ArrowDown"].includes(e.key)) {
    375      e.preventDefault();
    376      this.traverseResults(e);
    377    }
    378  };
    379 
    380  getResultCount = () => {
    381    const { results } = this.state;
    382    return results && results.length ? results.length : 0;
    383  };
    384 
    385  // Query helpers
    386  isFunctionQuery = () => this.props.searchType === "functions";
    387  isSymbolSearch = () => this.isFunctionQuery();
    388  isGotoQuery = () => this.props.searchType === "goto";
    389  isGotoSourceQuery = () => this.props.searchType === "gotoSource";
    390  isShortcutQuery = () => this.props.searchType === "shortcuts";
    391  isSourcesQuery = () => this.props.searchType === "sources";
    392  isSourceSearch = () => this.isSourcesQuery() || this.isGotoSourceQuery();
    393 
    394  /* eslint-disable react/no-danger */
    395  renderHighlight(candidateString, query) {
    396    const options = {
    397      wrap: {
    398        tagOpen: '<mark class="highlight">',
    399        tagClose: "</mark>",
    400      },
    401    };
    402    const html = fuzzyAldrin.wrap(candidateString, query, options);
    403    return div({
    404      dangerouslySetInnerHTML: {
    405        __html: html,
    406      },
    407    });
    408  }
    409 
    410  highlightMatching = (query, results) => {
    411    let newQuery = query;
    412    if (newQuery === "") {
    413      return results;
    414    }
    415    newQuery = query.replace(/[@:#?]/gi, " ");
    416 
    417    return results.map(result => {
    418      if (typeof result.title == "string") {
    419        return {
    420          ...result,
    421          title: this.renderHighlight(
    422            result.title,
    423            basename(newQuery),
    424            "title"
    425          ),
    426        };
    427      }
    428      return result;
    429    });
    430  };
    431 
    432  shouldShowErrorEmoji() {
    433    const { query } = this.props;
    434    if (this.isGotoQuery()) {
    435      return !/^:\d*$/.test(query);
    436    }
    437    return !!query && !this.getResultCount();
    438  }
    439 
    440  getSummaryMessage() {
    441    let summaryMsg = "";
    442    if (this.isGotoQuery()) {
    443      summaryMsg = L10N.getStr("shortcuts.gotoLine");
    444    } else if (this.isFunctionQuery() && !this.state.results) {
    445      summaryMsg = L10N.getStr("loadingText");
    446    }
    447    return summaryMsg;
    448  }
    449 
    450  render() {
    451    const { query } = this.props;
    452    const { selectedIndex, results } = this.state;
    453 
    454    const items = this.highlightMatching(query, results || []);
    455    const expanded = !!items && !!items.length;
    456    return React.createElement(
    457      Modal,
    458      {
    459        handleClose: this.closeModal,
    460      },
    461      React.createElement(SearchInput, {
    462        query,
    463        hasPrefix: true,
    464        count: this.getResultCount(),
    465        placeholder: L10N.getStr("sourceSearch.search2"),
    466        summaryMsg: this.getSummaryMessage(),
    467        showErrorEmoji: this.shouldShowErrorEmoji(),
    468        isLoading: false,
    469        onChange: this.onChange,
    470        onKeyDown: this.onKeyDown,
    471        handleClose: this.closeModal,
    472        expanded,
    473        showClose: false,
    474        searchKey: searchKeys.QUICKOPEN_SEARCH,
    475        showExcludePatterns: false,
    476        showSearchModifiers: false,
    477        selectedItemId:
    478          expanded && items[selectedIndex] ? items[selectedIndex].id : "",
    479        ...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT),
    480      }),
    481      results &&
    482        React.createElement(ResultList, {
    483          key: "results",
    484          items,
    485          selected: selectedIndex,
    486          selectItem: this.selectResultItem,
    487          ref: "resultList",
    488          expanded,
    489          ...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT),
    490        })
    491    );
    492  }
    493 }
    494 
    495 /* istanbul ignore next: ignoring testing of redux connection stuff */
    496 function mapStateToProps(state) {
    497  const selectedLocation = getSelectedLocation(state);
    498  const displayedSources = getDisplayedSourcesList(state);
    499  const openedSources = getOpenedSources(state);
    500 
    501  return {
    502    displayedSources,
    503    blackBoxRanges: getBlackBoxRanges(state),
    504    projectDirectoryRoot: getProjectDirectoryRoot(state),
    505    selectedLocation,
    506    selectedContentLoaded: selectedLocation
    507      ? !!getSettledSourceTextContent(state, selectedLocation)
    508      : undefined,
    509    query: getQuickOpenQuery(state),
    510    searchType: getQuickOpenType(state),
    511    openedSources,
    512  };
    513 }
    514 
    515 export default connect(mapStateToProps, {
    516  selectSpecificLocation: actions.selectSpecificLocation,
    517  setQuickOpenQuery: actions.setQuickOpenQuery,
    518  highlightLineRange: actions.highlightLineRange,
    519  clearHighlightLineRange: actions.clearHighlightLineRange,
    520  closeQuickOpen: actions.closeQuickOpen,
    521  getFunctionSymbols: actions.getFunctionSymbols,
    522 })(QuickOpenModal);