/* 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 "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) {
const options = {
wrap: {
tagOpen: '',
tagClose: "",
},
};
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,
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,
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,
selected: selectedIndex,
selectItem: this.selectResultItem,
ref: "resultList",
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);