/* 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 . */
// @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 { 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 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,
selectedIndex: number
};
type GotoLocationType = {
sourceId?: string,
line: number,
column?: number
};
const updateResultsThrottle = 100;
const maxResults = 100;
function filter(values, query) {
const preparedQuery = fuzzyAldrin.prepareQuery(query);
return fuzzyAldrin.filter(values, query, {
key: "value",
maxResults: maxResults,
preparedQuery
});
}
export class QuickOpenModal extends Component {
constructor(props: Props) {
super(props);
this.state = { results: null, selectedIndex: 0 };
}
setResults(results: ?Array) {
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) => {
return query.split(":")[0];
};
searchSources = (query: string) => {
const { displayedSources, tabs } = this.props;
const tabUrls = new Set(tabs.map((tab: Tab) => tab.url));
const sources = formatSources(displayedSources, tabUrls);
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,
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 (!this.isSymbolSearch() || selectedSource == null) {
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) => {
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;
const selectedSourceId = selectedSource ? selectedSource.id : "";
if (location != null) {
const sourceId = location.sourceId ? location.sourceId : selectedSourceId;
selectSpecificLocation(cx, {
sourceId,
line: location.line,
column: location.column
});
this.closeModal();
}
};
onChange = (e: SyntheticInputEvent) => {
const {
selectedSource,
selectedContentLoaded,
setQuickOpenQuery
} = this.props;
setQuickOpenQuery(e.target.value);
const noSource = !selectedSource || !selectedContentLoaded;
if ((this.isSymbolSearch() && noSource) || this.isGotoQuery()) {
return;
}
this.updateResults(e.target.value);
};
onKeyDown = (e: SyntheticKeyboardEvent) => {
const { enabled, query } = this.props;
const { results, selectedIndex } = this.state;
if (!this.isGotoQuery() && (!enabled || !results)) {
return;
}
if (e.key === "Enter") {
if (this.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.results;
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: '',
tagClose: ""
}
};
const html = fuzzyAldrin.wrap(candidateString, query, options);
return ;
}
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 !this.getResultCount() && !!query;
}
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 (
{results && (
)}
);
}
}
/* 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(
mapStateToProps,
{
selectSpecificLocation: actions.selectSpecificLocation,
setQuickOpenQuery: actions.setQuickOpenQuery,
highlightLineRange: actions.highlightLineRange,
closeQuickOpen: actions.closeQuickOpen
}
)(QuickOpenModal);