diff options
Diffstat (limited to 'devtools/client/debugger/src/components/QuickOpenModal.js')
-rw-r--r-- | devtools/client/debugger/src/components/QuickOpenModal.js | 489 |
1 files changed, 489 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..0066b4803f --- /dev/null +++ b/devtools/client/debugger/src/components/QuickOpenModal.js @@ -0,0 +1,489 @@ +/* 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/>. */ + +// @flow +import React, { Component } from "react"; +import { connect } from "../utils/connect"; +import fuzzyAldrin from "fuzzaldrin-plus"; +import { basename } from "../utils/path"; +import { throttle } from "lodash"; + +import actions from "../actions"; +import { + getDisplayedSourcesList, + getQuickOpenEnabled, + getQuickOpenQuery, + getQuickOpenType, + getSelectedSource, + getSourceContent, + getSymbols, + getTabs, + isSymbolsLoading, + getContext, +} from "../selectors"; +import { memoizeLast } from "../utils/memoizeLast"; +import { scrollList } from "../utils/result-list"; +import { + formatSymbols, + parseLineColumn, + formatShortcutResults, + formatSources, +} from "../utils/quick-open"; +import Modal from "./shared/Modal"; +import SearchInput from "./shared/SearchInput"; +import ResultList from "./shared/ResultList"; + +import type { + FormattedSymbolDeclarations, + QuickOpenResult, +} from "../utils/quick-open"; + +import type { Source, Context } from "../types"; +import type { QuickOpenType } from "../reducers/quick-open"; +import type { Tab } from "../reducers/tabs"; + +import "./QuickOpenModal.css"; + +type OwnProps = {| + shortcutsModalEnabled: boolean, + toggleShortcutsModal: () => void, +|}; +type Props = { + cx: Context, + enabled: boolean, + displayedSources: Source[], + selectedSource: ?Source, + selectedContentLoaded?: boolean, + query: string, + searchType: QuickOpenType, + symbols: FormattedSymbolDeclarations, + symbolsLoading: boolean, + tabs: Tab[], + shortcutsModalEnabled: boolean, + selectSpecificLocation: typeof actions.selectSpecificLocation, + setQuickOpenQuery: typeof actions.setQuickOpenQuery, + highlightLineRange: typeof actions.highlightLineRange, + closeQuickOpen: typeof actions.closeQuickOpen, + toggleShortcutsModal: () => void, +}; + +type State = { + results: ?Array<QuickOpenResult>, + selectedIndex: number, +}; + +type GotoLocationType = { + sourceId?: string, + line: number, + column?: number, +}; + +const updateResultsThrottle = 100; +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<Props, State> { + constructor(props: Props) { + super(props); + this.state = { results: null, selectedIndex: 0 }; + } + + setResults(results: ?Array<QuickOpenResult>) { + 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: Props) { + 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: string) => { + const index = query.indexOf(":"); + return index !== -1 ? query.slice(0, index) : query; + }; + + formatSources = memoizeLast((displayedSources, tabs) => { + const tabUrls = new Set(tabs.map((tab: Tab) => tab.url)); + return formatSources(displayedSources, tabUrls); + }); + + searchSources = (query: string) => { + const { displayedSources, tabs } = this.props; + + const sources = this.formatSources(displayedSources, tabs); + const results = + query == "" ? sources : filter(sources, this.dropGoto(query)); + return this.setResults(results); + }; + + searchSymbols = (query: string) => { + 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: string) => { + const results = formatShortcutResults(); + if (query == "?") { + this.setResults(results); + } else { + this.setResults(filter(results, query.slice(1))); + } + }; + + showTopSources = () => { + const { displayedSources, tabs } = this.props; + const tabUrls = new Set(tabs.map((tab: Tab) => tab.url)); + + if (tabs.length > 0) { + this.setResults( + formatSources( + displayedSources.filter( + source => !!source.url && tabUrls.has(source.url) + ), + tabUrls + ) + ); + } else { + this.setResults(formatSources(displayedSources, tabUrls)); + } + }; + + updateResults = throttle((query: string) => { + if (this.isGotoQuery()) { + return; + } + + if (query == "" && !this.isShortcutQuery()) { + return this.showTopSources(); + } + + if (this.isSymbolSearch()) { + return this.searchSymbols(query); + } + + if (this.isShortcutQuery()) { + return this.searchShortcuts(query); + } + + return this.searchSources(query); + }, updateResultsThrottle); + + setModifier = (item: QuickOpenResult) => { + if (["@", "#", ":"].includes(item.id)) { + this.props.setQuickOpenQuery(item.id); + } + }; + + selectResultItem = ( + e: SyntheticEvent<HTMLElement>, + item: ?QuickOpenResult + ) => { + if (item == null) { + return; + } + + if (this.isShortcutQuery()) { + return this.setModifier(item); + } + + if (this.isGotoSourceQuery()) { + const location = parseLineColumn(this.props.query); + return this.gotoLocation({ ...location, sourceId: item.id }); + } + + if (this.isSymbolSearch()) { + return this.gotoLocation({ + line: + item.location && item.location.start ? item.location.start.line : 0, + }); + } + + this.gotoLocation({ sourceId: item.id, line: 0 }); + }; + + onSelectResultItem = (item: QuickOpenResult) => { + const { selectedSource, highlightLineRange } = this.props; + if (selectedSource == null || !this.isSymbolSearch()) { + return; + } + + if (this.isFunctionQuery()) { + return highlightLineRange({ + ...(item.location != null + ? { start: item.location.start.line, end: item.location.end.line } + : {}), + sourceId: selectedSource.id, + }); + } + }; + + traverseResults = (e: SyntheticKeyboardEvent<HTMLElement>) => { + 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: ?GotoLocationType) => { + 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: SyntheticInputEvent<HTMLInputElement>) => { + const { + selectedSource, + selectedContentLoaded, + setQuickOpenQuery, + } = this.props; + setQuickOpenQuery(e.target.value); + const noSource = !selectedSource || !selectedContentLoaded; + if ((noSource && this.isSymbolSearch()) || this.isGotoQuery()) { + return; + } + + this.updateResults(e.target.value); + }; + + onKeyDown = (e: SyntheticKeyboardEvent<HTMLInputElement>) => { + 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); + return this.gotoLocation(location); + } + + if (results) { + return this.selectResultItem(e, results[selectedIndex]); + } + } + + if (e.key === "Tab") { + return this.closeModal(); + } + + if (["ArrowUp", "ArrowDown"].includes(e.key)) { + e.preventDefault(); + return 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: string | React$Element<"div">, + query: string, + name: string + ): string | React$Element<"div"> { + const options = { + wrap: { + tagOpen: '<mark class="highlight">', + tagClose: "</mark>", + }, + }; + const html = fuzzyAldrin.wrap(candidateString, query, options); + return <div dangerouslySetInnerHTML={{ __html: html }} />; + } + + highlightMatching = ( + query: string, + results: QuickOpenResult[] + ): QuickOpenResult[] => { + 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 > 0; + + 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); + + return { + cx: getContext(state), + enabled: getQuickOpenEnabled(state), + displayedSources, + selectedSource, + selectedContentLoaded: selectedSource + ? !!getSourceContent(state, selectedSource.id) + : undefined, + symbols: formatSymbols(getSymbols(state, selectedSource)), + symbolsLoading: isSymbolsLoading(state, selectedSource), + query: getQuickOpenQuery(state), + searchType: getQuickOpenType(state), + tabs, + }; +} + +/* istanbul ignore next: ignoring testing of redux connection stuff */ +export default connect<Props, OwnProps, _, _, _, _>(mapStateToProps, { + selectSpecificLocation: actions.selectSpecificLocation, + setQuickOpenQuery: actions.setQuickOpenQuery, + highlightLineRange: actions.highlightLineRange, + closeQuickOpen: actions.closeQuickOpen, +})(QuickOpenModal); |