/* 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 { div } = require("devtools/client/shared/vendor/react-dom-factories"); const { PluralForm } = require("resource://devtools/shared/plural-form.js"); const { debounce } = require("resource://devtools/shared/debounce.js"); const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); const locale = new LocalizationHelper( "devtools/client/locales/components.properties" ); const SEARCH_IN_FILE_SHORTCUT = locale.getStr("sourceSearch.search.key2"); class FileSearchBar extends Component { static get propTypes() { return { closeFileSearch: PropTypes.func.isRequired, editor: PropTypes.object, modifiers: PropTypes.object.isRequired, searchInFileEnabled: PropTypes.bool.isRequired, setCursorLocation: PropTypes.func.isRequired, textContent: PropTypes.object, setActiveSearch: PropTypes.func.isRequired, querySearchWorker: PropTypes.func.isRequired, shortcuts: PropTypes.object, SearchInput: PropTypes.oneOfType([PropTypes.func, PropTypes.object]) .isRequired, searchKey: PropTypes.string.isRequired, shouldScroll: PropTypes.bool.isRequired, scrollList: PropTypes.func.isRequired, clearSearchEditor: PropTypes.func.isRequired, find: PropTypes.func.isRequired, findNext: PropTypes.func.isRequired, findPrev: PropTypes.func.isRequired, }; } constructor(props) { super(props); this.state = { query: "", selectedResultIndex: 0, results: { matches: [], matchIndex: -1, count: 0, index: -1, }, inputFocused: false, }; } componentDidMount() { // overwrite this.doSearch with debounced version to // reduce frequency of queries this.doSearch = debounce(this.doSearch, 100); const { shortcuts } = this.props; if (!shortcuts) { return; } shortcuts.on(SEARCH_IN_FILE_SHORTCUT, this.toggleSearch); shortcuts.on("Escape", this.onEscape); } componentDidUpdate(prevProps) { if (this.refs.resultList && this.refs.resultList.refs) { this.props.scrollList( this.refs.resultList.refs, this.state.selectedResultIndex ); } const { query } = this.state; const didEnableSearch = !prevProps.searchInFileEnabled && this.props.searchInFileEnabled; const didTextContentChange = prevProps.textContent !== this.props.textContent; if ( query && (didEnableSearch || (this.props.searchInFileEnabled && didTextContentChange)) ) { this.doSearch(query, this.props.shouldScroll); } } componentWillUnmount() { const { shortcuts } = this.props; if (shortcuts) { shortcuts.off(SEARCH_IN_FILE_SHORTCUT, this.toggleSearch); shortcuts.off("Escape", this.onEscape); } if (this.doSearch?.cancel) { this.doSearch.cancel(); } } onEscape = e => { this.closeSearch(e); }; clearSearch = () => { const { editor } = this.props; if (!editor) { return; } editor.clearSearchMatches(); editor.removePositionContentMarker("active-selection-marker"); }; closeSearch = e => { const { closeFileSearch, editor, searchInFileEnabled } = this.props; this.clearSearch(); if (editor && searchInFileEnabled) { closeFileSearch(); e.stopPropagation(); e.preventDefault(); } this.setState({ inputFocused: false }); }; toggleSearch = e => { e.stopPropagation(); e.preventDefault(); const { editor, searchInFileEnabled, setActiveSearch } = this.props; // Set inputFocused to false, so that search query is highlighted whenever // search shortcut is used, even if the input already has focus. this.setState({ inputFocused: false }); if (!searchInFileEnabled) { setActiveSearch("file"); } if (searchInFileEnabled && editor) { const selectedText = editor.getSelectedText(); const query = selectedText || this.state.query; if (query !== "") { this.setState({ query, inputFocused: true }); this.doSearch(query); } else { this.setState({ query: "", inputFocused: true }); } } }; doSearch = async (query, shouldScroll = true) => { const { editor, modifiers, textContent } = this.props; if (!editor || !textContent || !modifiers) { return; } const selectedContent = textContent; const ctx = { editor, cm: editor.codeMirror }; if (!query) { this.props.clearSearchEditor(ctx); return; } let text; if (selectedContent.type === "wasm") { text = editor.renderWasmText(selectedContent).join("\n"); } else { text = selectedContent.value; } const matches = await this.props.querySearchWorker(query, text, modifiers); const results = this.props.find(ctx, query, true, modifiers, { shouldScroll, }); this.setSearchResults(results, matches, shouldScroll); }; traverseResults = (e, reverse = false) => { e.stopPropagation(); e.preventDefault(); const { editor } = this.props; if (!editor) { return; } const ctx = { editor, cm: editor.codeMirror }; const { modifiers, findPrev, findNext } = this.props; const { query } = this.state; const { matches } = this.state.results; if (query === "" && !this.props.searchInFileEnabled) { this.props.setActiveSearch("file"); } if (modifiers) { const findArgs = [ctx, query, true, modifiers]; const results = reverse ? findPrev(...findArgs) : findNext(...findArgs); this.setSearchResults(results, matches, true); } }; /** * Update the state with the results and matches from the search. * This will also scroll to result's location in CodeMirror. * * @param {object} results * @param {Array} matches * @returns */ setSearchResults(results, matches, shouldScroll) { if (!results) { this.setState({ results: { matches, matchIndex: 0, count: matches.length, index: -1, }, }); return; } const { ch, line } = results; let matchContent = ""; const matchIndex = matches.findIndex(elm => { if (elm.line === line && elm.ch === ch) { matchContent = elm.match; return true; } return false; }); if (shouldScroll) { this.props.setCursorLocation(line, ch, matchContent); } // Only change the selected location if we should scroll to it, // otherwise we are most likely updating the search results while being paused // and don't want to change the selected location from the current paused location this.setState({ results: { matches, matchIndex, count: matches.length, index: ch, }, }); } onChange = e => { this.setState({ query: e.target.value }); return this.doSearch(e.target.value); }; onFocus = () => { this.setState({ inputFocused: true }); }; onBlur = () => { this.setState({ inputFocused: false }); }; onKeyDown = e => { if (e.key !== "Enter" && e.key !== "F3") { return; } e.preventDefault(); this.traverseResults(e, e.shiftKey); }; onHistoryScroll = query => { this.setState({ query }); this.doSearch(query); }; // Renderers buildSummaryMsg() { const { query, results: { matchIndex, count, index }, } = this.state; if (query.trim() == "") { return ""; } if (count == 0) { return locale.getStr("editor.noResultsFound"); } if (index == -1) { const resultsSummaryString = locale.getStr( "sourceSearch.resultsSummary2" ); return PluralForm.get(count, resultsSummaryString).replace("#1", count); } const searchResultsString = locale.getStr("editor.searchResults1"); return PluralForm.get(count, searchResultsString) .replace("#1", count) .replace("%d", matchIndex + 1); } shouldShowErrorEmoji() { const { query, results: { count }, } = this.state; return !!query && !count; } render() { const { searchInFileEnabled, searchKey, SearchInput } = this.props; const { results: { count }, } = this.state; if (!searchInFileEnabled) { return div(null); } return div( { className: "search-bar", }, React.createElement(SearchInput, { query: this.state.query, count, placeholder: locale.getStr("sourceSearch.search.placeholder2"), summaryMsg: this.buildSummaryMsg(), isLoading: false, onChange: this.onChange, onFocus: this.onFocus, onBlur: this.onBlur, showErrorEmoji: this.shouldShowErrorEmoji(), onKeyDown: this.onKeyDown, onHistoryScroll: this.onHistoryScroll, handleNext: e => this.traverseResults(e, false), handlePrev: e => this.traverseResults(e, true), shouldFocus: this.state.inputFocused, showClose: true, showExcludePatterns: false, handleClose: this.closeSearch, showSearchModifiers: true, searchKey, onToggleSearchModifier: () => this.doSearch(this.state.query), }) ); } } module.exports = FileSearchBar;