diff options
Diffstat (limited to 'devtools/client/debugger/src/components/QuickOpenModal.js')
-rw-r--r-- | devtools/client/debugger/src/components/QuickOpenModal.js | 508 |
1 files changed, 508 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..aa3d4f73b6 --- /dev/null +++ b/devtools/client/debugger/src/components/QuickOpenModal.js @@ -0,0 +1,508 @@ +/* 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 "devtools/client/shared/vendor/react"; +import { div } from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { connect } from "devtools/client/shared/vendor/react-redux"; +import { basename } from "../utils/path"; +import { createLocation } from "../utils/location"; + +const fuzzyAldrin = require("resource://devtools/client/shared/vendor/fuzzaldrin-plus.js"); +const { throttle } = require("resource://devtools/shared/throttle.js"); + +import actions from "../actions/index"; +import { + getDisplayedSourcesList, + getQuickOpenQuery, + getQuickOpenType, + getSelectedLocation, + getSettledSourceTextContent, + getSourceTabs, + getBlackBoxRanges, + getProjectDirectoryRoot, +} from "../selectors/index"; +import { memoizeLast } from "../utils/memoizeLast"; +import { searchKeys } from "../constants"; +import { + formatSymbol, + parseLineColumn, + formatShortcutResults, + formatSourceForList, +} from "../utils/quick-open"; +import Modal from "./shared/Modal"; +import SearchInput from "./shared/SearchInput"; +import ResultList from "./shared/ResultList"; + +const maxResults = 100; + +const SIZE_BIG = { size: "big" }; +const SIZE_DEFAULT = {}; + +function filter(values, query, key = "value") { + const preparedQuery = fuzzyAldrin.prepareQuery(query); + + return fuzzyAldrin.filter(values, query, { + key, + 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, + displayedSources: PropTypes.array.isRequired, + blackBoxRanges: PropTypes.object.isRequired, + highlightLineRange: PropTypes.func.isRequired, + clearHighlightLineRange: 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, + selectedLocation: PropTypes.object, + setQuickOpenQuery: PropTypes.func.isRequired, + openedTabUrls: PropTypes.array.isRequired, + toggleShortcutsModal: PropTypes.func.isRequired, + projectDirectoryRoot: PropTypes.string, + getFunctionSymbols: 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 queryChanged = prevProps.query !== this.props.query; + + if (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, openedTabUrls, 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 = openedTabUrls.includes(source.url); + return formatSourceForList( + source, + hasTabOpened, + isBlackBoxed, + projectDirectoryRoot + ); + }); + } + ); + + searchSources = query => { + const { + displayedSources, + openedTabUrls, + blackBoxRanges, + projectDirectoryRoot, + } = this.props; + + const sources = this.formatSources( + displayedSources, + openedTabUrls, + blackBoxRanges, + projectDirectoryRoot + ); + const results = + query == "" ? sources : filter(sources, this.dropGoto(query)); + return this.setResults(results); + }; + + searchSymbols = async query => { + const { getFunctionSymbols, selectedLocation } = this.props; + if (!selectedLocation) { + return this.setResults([]); + } + let results = await getFunctionSymbols(selectedLocation, maxResults); + + if (query === "@" || query === "#") { + results = results.map(formatSymbol); + return this.setResults(results); + } + results = filter(results, query.slice(1), "name"); + results = results.map(formatSymbol); + 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 { openedTabUrls, 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 (openedTabUrls.length) { + displayedSources = displayedSources.filter( + source => !!source.url && openedTabUrls.includes(source.url) + ); + } + + this.setResults( + this.formatSources( + displayedSources, + openedTabUrls, + 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, source: item.source }); + return; + } + + if (this.isSymbolSearch()) { + this.gotoLocation({ + line: + item.location && item.location.start ? item.location.start.line : 0, + }); + return; + } + + this.gotoLocation({ source: item.source, line: 0 }); + }; + + onSelectResultItem = item => { + const { selectedLocation, highlightLineRange, clearHighlightLineRange } = + this.props; + if ( + selectedLocation == null || + !this.isSymbolSearch() || + !this.isFunctionQuery() + ) { + return; + } + + if (item.location) { + highlightLineRange({ + start: item.location.start.line, + end: item.location.end.line, + sourceId: selectedLocation.source.id, + }); + } else { + clearHighlightLineRange(); + } + }; + + 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 { selectSpecificLocation, selectedLocation } = this.props; + + if (location != null) { + selectSpecificLocation( + createLocation({ + source: location.source || selectedLocation?.source, + line: location.line, + column: location.column, + }) + ); + this.closeModal(); + } + }; + + onChange = e => { + const { selectedLocation, selectedContentLoaded, setQuickOpenQuery } = + this.props; + setQuickOpenQuery(e.target.value); + const noSource = !selectedLocation || !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 { query } = this.props; + const { results, selectedIndex } = this.state; + const isGoToQuery = this.isGotoQuery(); + + if (!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.state.results) { + summaryMsg = L10N.getStr("loadingText"); + } + return summaryMsg; + } + + render() { + const { query } = this.props; + const { selectedIndex, results } = this.state; + + const items = this.highlightMatching(query, results || []); + const expanded = !!items && !!items.length; + return React.createElement( + Modal, + { + handleClose: this.closeModal, + }, + React.createElement(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, + searchKey: searchKeys.QUICKOPEN_SEARCH, + showExcludePatterns: false, + showSearchModifiers: false, + selectedItemId: + expanded && items[selectedIndex] ? items[selectedIndex].id : "", + ...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT), + }), + results && + React.createElement(ResultList, { + key: "results", + items: items, + selected: selectedIndex, + selectItem: this.selectResultItem, + ref: "resultList", + expanded: expanded, + ...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT), + }) + ); + } +} + +/* istanbul ignore next: ignoring testing of redux connection stuff */ +function mapStateToProps(state) { + const selectedLocation = getSelectedLocation(state); + const displayedSources = getDisplayedSourcesList(state); + const tabs = getSourceTabs(state); + const openedTabUrls = [...new Set(tabs.map(tab => tab.url))]; + + return { + displayedSources, + blackBoxRanges: getBlackBoxRanges(state), + projectDirectoryRoot: getProjectDirectoryRoot(state), + selectedLocation, + selectedContentLoaded: selectedLocation + ? !!getSettledSourceTextContent(state, selectedLocation) + : undefined, + query: getQuickOpenQuery(state), + searchType: getQuickOpenType(state), + openedTabUrls, + }; +} + +export default connect(mapStateToProps, { + selectSpecificLocation: actions.selectSpecificLocation, + setQuickOpenQuery: actions.setQuickOpenQuery, + highlightLineRange: actions.highlightLineRange, + clearHighlightLineRange: actions.clearHighlightLineRange, + closeQuickOpen: actions.closeQuickOpen, + getFunctionSymbols: actions.getFunctionSymbols, +})(QuickOpenModal); |