/* 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 . */
import React, { Component } from "react";
import PropTypes from "prop-types";
import { connect } from "../utils/connect";
import fuzzyAldrin from "fuzzaldrin-plus";
import { basename } from "../utils/path";
import { createLocation } from "../utils/location";
const { throttle } = require("devtools/shared/throttle");
import actions from "../actions";
import {
getDisplayedSourcesList,
getQuickOpenEnabled,
getQuickOpenQuery,
getQuickOpenType,
getSelectedSource,
getSelectedLocation,
getSettledSourceTextContent,
getSymbols,
getTabs,
getContext,
getBlackBoxRanges,
getProjectDirectoryRoot,
} from "../selectors";
import { memoizeLast } from "../utils/memoizeLast";
import { scrollList } from "../utils/result-list";
import { searchKeys } from "../constants";
import {
formatSymbols,
parseLineColumn,
formatShortcutResults,
formatSourceForList,
} from "../utils/quick-open";
import Modal from "./shared/Modal";
import SearchInput from "./shared/SearchInput";
import ResultList from "./shared/ResultList";
import "./QuickOpenModal.css";
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 {
// 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,
cx: PropTypes.object.isRequired,
displayedSources: PropTypes.array.isRequired,
blackBoxRanges: PropTypes.object.isRequired,
enabled: PropTypes.bool.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,
selectedSource: PropTypes.object,
setQuickOpenQuery: PropTypes.func.isRequired,
shortcutsModalEnabled: PropTypes.bool.isRequired,
symbols: PropTypes.object.isRequired,
symbolsLoading: PropTypes.bool.isRequired,
tabUrls: PropTypes.array.isRequired,
toggleShortcutsModal: PropTypes.func.isRequired,
projectDirectoryRoot: PropTypes.string,
};
}
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 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 => {
const index = query.indexOf(":");
return index !== -1 ? query.slice(0, index) : query;
};
formatSources = memoizeLast(
(displayedSources, tabUrls, 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 = tabUrls.includes(source.url);
return formatSourceForList(
source,
hasTabOpened,
isBlackBoxed,
projectDirectoryRoot
);
});
}
);
searchSources = query => {
const { displayedSources, tabUrls, blackBoxRanges, projectDirectoryRoot } =
this.props;
const sources = this.formatSources(
displayedSources,
tabUrls,
blackBoxRanges,
projectDirectoryRoot
);
const results =
query == "" ? sources : filter(sources, this.dropGoto(query));
return this.setResults(results);
};
searchSymbols = query => {
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 => {
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 { tabUrls, 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 (tabUrls.length) {
displayedSources = displayedSources.filter(
source => !!source.url && tabUrls.includes(source.url)
);
}
this.setResults(
this.formatSources(
displayedSources,
tabUrls,
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 { selectedSource, highlightLineRange, clearHighlightLineRange } =
this.props;
if (
selectedSource == null ||
!this.isSymbolSearch() ||
!this.isFunctionQuery()
) {
return;
}
if (item.location) {
highlightLineRange({
start: item.location.start.line,
end: item.location.end.line,
sourceId: selectedSource.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 { cx, selectSpecificLocation, selectedSource } = this.props;
if (location != null) {
selectSpecificLocation(
cx,
createLocation({
source: location.source || selectedSource,
line: location.line,
column: location.column,
})
);
this.closeModal();
}
};
onChange = e => {
const { selectedSource, selectedContentLoaded, setQuickOpenQuery } =
this.props;
setQuickOpenQuery(e.target.value);
const noSource = !selectedSource || !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 { 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);
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: '',
tagClose: "",
},
};
const html = fuzzyAldrin.wrap(candidateString, query, options);
return
;
}
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.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;
return (
{results && (
)}
);
}
}
/* istanbul ignore next: ignoring testing of redux connection stuff */
function mapStateToProps(state) {
const selectedSource = getSelectedSource(state);
const location = getSelectedLocation(state);
const displayedSources = getDisplayedSourcesList(state);
const tabs = getTabs(state);
const tabUrls = [...new Set(tabs.map(tab => tab.url))];
const symbols = getSymbols(state, location);
return {
cx: getContext(state),
enabled: getQuickOpenEnabled(state),
displayedSources,
blackBoxRanges: getBlackBoxRanges(state),
projectDirectoryRoot: getProjectDirectoryRoot(state),
selectedSource,
selectedContentLoaded: location
? !!getSettledSourceTextContent(state, location)
: undefined,
symbols: formatSymbols(symbols, maxResults),
symbolsLoading: !symbols,
query: getQuickOpenQuery(state),
searchType: getQuickOpenType(state),
tabUrls,
};
}
export default connect(mapStateToProps, {
selectSpecificLocation: actions.selectSpecificLocation,
setQuickOpenQuery: actions.setQuickOpenQuery,
highlightLineRange: actions.highlightLineRange,
clearHighlightLineRange: actions.clearHighlightLineRange,
closeQuickOpen: actions.closeQuickOpen,
})(QuickOpenModal);