tor-browser

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

ProjectSearch.js (12771B)


      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 {
      7  button,
      8  div,
      9  span,
     10 } from "devtools/client/shared/vendor/react-dom-factories";
     11 import PropTypes from "devtools/client/shared/vendor/react-prop-types";
     12 import { connect } from "devtools/client/shared/vendor/react-redux";
     13 import actions from "../../actions/index";
     14 
     15 import { getEditor } from "../../utils/editor/index";
     16 import { searchKeys } from "../../constants";
     17 
     18 import { getRelativePath } from "../../utils/sources-tree/utils";
     19 import {
     20  getProjectSearchQuery,
     21  getNavigateCounter,
     22 } from "../../selectors/index";
     23 
     24 import SearchInput from "../shared/SearchInput";
     25 import DebuggerImage from "../shared/DebuggerImage";
     26 
     27 const { PluralForm } = require("resource://devtools/shared/plural-form.js");
     28 const classnames = require("resource://devtools/client/shared/classnames.js");
     29 const Tree = require("resource://devtools/client/shared/components/Tree.js");
     30 const { debounce } = require("resource://devtools/shared/debounce.js");
     31 const { throttle } = require("resource://devtools/shared/throttle.js");
     32 
     33 const {
     34  HTMLTooltip,
     35 } = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js");
     36 
     37 export const statusType = {
     38  initial: "INITIAL",
     39  fetching: "FETCHING",
     40  cancelled: "CANCELLED",
     41  done: "DONE",
     42  error: "ERROR",
     43 };
     44 
     45 function getFilePath(item, index) {
     46  return item.type === "RESULT"
     47    ? `${item.location.source.id}-${index || "$"}`
     48    : `${item.location.source.id}-${item.location.line}-${
     49        item.location.column
     50      }-${index || "$"}`;
     51 }
     52 
     53 export class ProjectSearch extends Component {
     54  constructor(props) {
     55    super(props);
     56 
     57    this.state = {
     58      // We may restore a previous state when changing tabs in the primary panes,
     59      // or when restoring primary panes from collapse.
     60      query: this.props.query || "",
     61 
     62      focusedItem: null,
     63      expanded: new Set(),
     64      results: [],
     65      navigateCounter: null,
     66      status: statusType.done,
     67    };
     68    // Use throttle for updating results in order to prevent delaying showing result until the end of the search
     69    this.onUpdatedResults = throttle(this.onUpdatedResults.bind(this), 100);
     70    // Use debounce for input processing in order to wait for the end of user input edition before triggerring the search
     71    this.doSearch = debounce(this.doSearch.bind(this), 100);
     72    this.doSearch();
     73  }
     74 
     75  static get propTypes() {
     76    return {
     77      doSearchForHighlight: PropTypes.func.isRequired,
     78      query: PropTypes.string.isRequired,
     79      searchSources: PropTypes.func.isRequired,
     80      selectSpecificLocationOrSameUrl: PropTypes.func.isRequired,
     81    };
     82  }
     83 
     84  async doSearch() {
     85    // Cancel any previous async ongoing search
     86    if (this.searchAbortController) {
     87      this.searchAbortController.abort();
     88    }
     89 
     90    if (!this.state.query) {
     91      this.setState({ status: statusType.done });
     92      return;
     93    }
     94 
     95    this.setState({
     96      status: statusType.fetching,
     97      results: [],
     98      navigateCounter: this.props.navigateCounter,
     99    });
    100 
    101    // Setup an AbortController whose main goal is to be able to cancel the asynchronous
    102    // operation done by the `searchSources` action.
    103    // This allows allows the React Component to receive partial updates
    104    // to render results as they are available.
    105    this.searchAbortController = new AbortController();
    106 
    107    await this.props.searchSources(
    108      this.state.query,
    109      this.onUpdatedResults,
    110      this.searchAbortController.signal
    111    );
    112  }
    113 
    114  onUpdatedResults(results, done, signal) {
    115    // debounce may delay the execution after this search has been cancelled
    116    if (signal.aborted) {
    117      return;
    118    }
    119 
    120    this.setState({
    121      results,
    122      status: done ? statusType.done : statusType.fetching,
    123    });
    124  }
    125 
    126  selectMatchItem = async matchItem => {
    127    const foundMatchingSource =
    128      await this.props.selectSpecificLocationOrSameUrl(matchItem.location);
    129    // When we reload, or if the source's target has been destroyed,
    130    // we may no longer have the source available in the reducer.
    131    // In such case `selectSpecificLocationOrSameUrl` will return false.
    132    if (!foundMatchingSource) {
    133      // When going over results via the key arrows and Enter, we may display many tooltips at once.
    134      if (this.tooltip) {
    135        this.tooltip.hide();
    136      }
    137      // Go down to line-number otherwise HTMLTooltip's call to getBoundingClientRect would return (0, 0) position for the tooltip
    138      const element = document.querySelector(
    139        ".project-text-search .tree-node.focused .result .line-number"
    140      );
    141      const tooltip = new HTMLTooltip(element.ownerDocument, {
    142        className: "unavailable-source",
    143        type: "arrow",
    144      });
    145      tooltip.panel.textContent = L10N.getStr(
    146        "projectTextSearch.sourceNoLongerAvailable"
    147      );
    148      tooltip.setContentSize({ height: "auto" });
    149      tooltip.show(element);
    150      this.tooltip = tooltip;
    151      return;
    152    }
    153    this.props.doSearchForHighlight(this.state.query, getEditor());
    154  };
    155 
    156  highlightMatches = lineMatch => {
    157    const { value, matchIndex, match } = lineMatch;
    158    const len = match.length;
    159    return span(
    160      {
    161        className: "line-value",
    162      },
    163      span(
    164        {
    165          className: "line-match",
    166          key: 0,
    167        },
    168        value.slice(0, matchIndex)
    169      ),
    170      span(
    171        {
    172          className: "query-match",
    173          key: 1,
    174        },
    175        value.substr(matchIndex, len)
    176      ),
    177      span(
    178        {
    179          className: "line-match",
    180          key: 2,
    181        },
    182        value.slice(matchIndex + len, value.length)
    183      )
    184    );
    185  };
    186 
    187  getResultCount = () =>
    188    this.state.results.reduce((count, file) => count + file.matches.length, 0);
    189 
    190  onKeyDown = e => {
    191    if (e.key === "Escape") {
    192      return;
    193    }
    194 
    195    e.stopPropagation();
    196 
    197    this.setState({ focusedItem: null });
    198    this.doSearch();
    199  };
    200 
    201  onHistoryScroll = query => {
    202    this.setState({ query });
    203    this.doSearch();
    204  };
    205 
    206  // This can be called by Tree when manually selecting node via arrow keys and Enter.
    207  onActivate = item => {
    208    if (item && item.type === "MATCH") {
    209      this.selectMatchItem(item);
    210    }
    211  };
    212 
    213  onFocus = item => {
    214    if (this.state.focusedItem !== item) {
    215      this.setState({
    216        focusedItem: item,
    217      });
    218    }
    219  };
    220 
    221  inputOnChange = e => {
    222    const inputValue = e.target.value;
    223    this.setState({ query: inputValue });
    224    this.doSearch();
    225  };
    226 
    227  renderFile = (file, focused, expanded) => {
    228    const matchesLength = file.matches.length;
    229    const matches = ` (${matchesLength} match${matchesLength > 1 ? "es" : ""})`;
    230    return div(
    231      {
    232        className: classnames("file-result", {
    233          focused,
    234        }),
    235        key: file.location.source.id,
    236      },
    237      React.createElement(DebuggerImage, {
    238        name: "arrow",
    239        className: classnames({
    240          expanded,
    241        }),
    242      }),
    243      React.createElement(DebuggerImage, {
    244        name: "file",
    245      }),
    246      span(
    247        {
    248          className: "file-path",
    249        },
    250        file.location.source.url
    251          ? getRelativePath(file.location.source.url)
    252          : file.location.source.shortName
    253      ),
    254      span(
    255        {
    256          className: "matches-summary",
    257        },
    258        matches
    259      )
    260    );
    261  };
    262 
    263  renderMatch = (match, focused) => {
    264    return div(
    265      {
    266        className: classnames("result", {
    267          focused,
    268        }),
    269        onClick: () => this.selectMatchItem(match),
    270      },
    271      span(
    272        {
    273          className: "line-number",
    274          key: match.location.line,
    275        },
    276        match.location.line
    277      ),
    278      this.highlightMatches(match)
    279    );
    280  };
    281 
    282  renderItem = (item, depth, focused, _, expanded) => {
    283    if (item.type === "RESULT") {
    284      return this.renderFile(item, focused, expanded);
    285    }
    286    return this.renderMatch(item, focused);
    287  };
    288 
    289  renderRefreshButton() {
    290    if (!this.state.query) {
    291      return null;
    292    }
    293 
    294    // Highlight the refresh button when the current search results
    295    // are based on the previous document. doSearch will save the "navigate counter"
    296    // into state, while props will report the current "navigate counter".
    297    // The "navigate counter" is incremented each time we navigate to a new page.
    298    const highlight =
    299      this.state.navigateCounter != null &&
    300      this.state.navigateCounter != this.props.navigateCounter;
    301    return button(
    302      {
    303        className: classnames("refresh-btn devtools-button", {
    304          highlight,
    305        }),
    306        title: highlight
    307          ? L10N.getStr("projectTextSearch.refreshButtonTooltipOnNavigation")
    308          : L10N.getStr("projectTextSearch.refreshButtonTooltip"),
    309        onClick: this.doSearch,
    310      },
    311      React.createElement(DebuggerImage, {
    312        name: "refresh",
    313      })
    314    );
    315  }
    316 
    317  renderResultsToolbar() {
    318    if (!this.state.query) {
    319      return null;
    320    }
    321    return div(
    322      { className: "project-search-results-toolbar" },
    323      span({ className: "results-count" }, this.renderSummary()),
    324      this.renderRefreshButton()
    325    );
    326  }
    327 
    328  renderResults() {
    329    const { status, results } = this.state;
    330    if (!this.state.query) {
    331      return null;
    332    }
    333    if (results.length) {
    334      return React.createElement(Tree, {
    335        getRoots: () => results,
    336        getChildren: file => file.matches || [],
    337        autoExpandAll: true,
    338        autoExpandDepth: 1,
    339        autoExpandNodeChildrenLimit: 100,
    340        getParent: () => null,
    341        getPath: getFilePath,
    342        renderItem: this.renderItem,
    343        focused: this.state.focusedItem,
    344        onFocus: this.onFocus,
    345        onActivate: this.onActivate,
    346        isExpanded: item => {
    347          return this.state.expanded.has(item);
    348        },
    349        onExpand: item => {
    350          const { expanded } = this.state;
    351          expanded.add(item);
    352          this.setState({
    353            expanded,
    354          });
    355        },
    356        onCollapse: item => {
    357          const { expanded } = this.state;
    358          expanded.delete(item);
    359          this.setState({
    360            expanded,
    361          });
    362        },
    363        preventBlur: true,
    364        getKey: getFilePath,
    365      });
    366    }
    367    const msg =
    368      status === statusType.fetching
    369        ? L10N.getStr("loadingText")
    370        : L10N.getStr("projectTextSearch.noResults");
    371    return div(
    372      {
    373        className: "no-result-msg",
    374      },
    375      msg
    376    );
    377  }
    378 
    379  renderSummary = () => {
    380    if (this.state.query === "") {
    381      return "";
    382    }
    383    const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary2");
    384    const count = this.getResultCount();
    385    if (count === 0) {
    386      return "";
    387    }
    388    return PluralForm.get(count, resultsSummaryString).replace("#1", count);
    389  };
    390 
    391  shouldShowErrorEmoji() {
    392    return !this.getResultCount() && this.state.status === statusType.done;
    393  }
    394 
    395  renderInput() {
    396    const { status } = this.state;
    397    return React.createElement(SearchInput, {
    398      query: this.state.query,
    399      count: this.getResultCount(),
    400      placeholder: L10N.getStr("projectTextSearch.placeholder"),
    401      size: "small",
    402      showErrorEmoji: this.shouldShowErrorEmoji(),
    403      isLoading: status === statusType.fetching,
    404      onChange: this.inputOnChange,
    405      onKeyDown: this.onKeyDown,
    406      onHistoryScroll: this.onHistoryScroll,
    407      showClose: false,
    408      showExcludePatterns: true,
    409      excludePatternsLabel: L10N.getStr(
    410        "projectTextSearch.excludePatterns.label"
    411      ),
    412      excludePatternsPlaceholder: L10N.getStr(
    413        "projectTextSearch.excludePatterns.placeholder"
    414      ),
    415      ref: "searchInput",
    416      showSearchModifiers: true,
    417      searchKey: searchKeys.PROJECT_SEARCH,
    418      onToggleSearchModifier: this.doSearch,
    419    });
    420  }
    421 
    422  render() {
    423    return div(
    424      {
    425        className: "search-container",
    426      },
    427      div(
    428        {
    429          className: "project-text-search",
    430        },
    431        div(
    432          {
    433            className: "header",
    434          },
    435          this.renderInput()
    436        ),
    437        this.renderResultsToolbar(),
    438        this.renderResults()
    439      )
    440    );
    441  }
    442 }
    443 
    444 ProjectSearch.contextTypes = {
    445  shortcuts: PropTypes.object,
    446 };
    447 
    448 const mapStateToProps = state => ({
    449  query: getProjectSearchQuery(state),
    450  navigateCounter: getNavigateCounter(state),
    451 });
    452 
    453 export default connect(mapStateToProps, {
    454  searchSources: actions.searchSources,
    455  selectSpecificLocationOrSameUrl: actions.selectSpecificLocationOrSameUrl,
    456  doSearchForHighlight: actions.doSearchForHighlight,
    457 })(ProjectSearch);