QuickOpenModal.js (14476B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ 4 5 import React, { Component } from "devtools/client/shared/vendor/react"; 6 import { div } from "devtools/client/shared/vendor/react-dom-factories"; 7 import PropTypes from "devtools/client/shared/vendor/react-prop-types"; 8 import { connect } from "devtools/client/shared/vendor/react-redux"; 9 import { basename } from "../utils/path"; 10 import { createLocation } from "../utils/location"; 11 12 const fuzzyAldrin = require("resource://devtools/client/shared/vendor/fuzzaldrin-plus.js"); 13 const { throttle } = require("resource://devtools/shared/throttle.js"); 14 15 import actions from "../actions/index"; 16 import { 17 getDisplayedSourcesList, 18 getQuickOpenQuery, 19 getQuickOpenType, 20 getSelectedLocation, 21 getSettledSourceTextContent, 22 getOpenedSources, 23 getBlackBoxRanges, 24 getProjectDirectoryRoot, 25 } from "../selectors/index"; 26 import { memoizeLast } from "../utils/memoizeLast"; 27 import { searchKeys } from "../constants"; 28 import { 29 formatSymbol, 30 parseLineColumn, 31 formatShortcutResults, 32 formatSourceForList, 33 } from "../utils/quick-open"; 34 import Modal from "./shared/Modal"; 35 import SearchInput from "./shared/SearchInput"; 36 import ResultList from "./shared/ResultList"; 37 38 const maxResults = 100; 39 40 const SIZE_BIG = { size: "big" }; 41 const SIZE_DEFAULT = {}; 42 43 function filter(values, query, key = "value") { 44 const preparedQuery = fuzzyAldrin.prepareQuery(query); 45 46 return fuzzyAldrin.filter(values, query, { 47 key, 48 maxResults, 49 preparedQuery, 50 }); 51 } 52 53 export class QuickOpenModal extends Component { 54 // Put it on the class so it can be retrieved in tests 55 static UPDATE_RESULTS_THROTTLE = 100; 56 57 #willUnmountCalled = false; 58 59 constructor(props) { 60 super(props); 61 this.state = { results: null, selectedIndex: 0 }; 62 } 63 64 static get propTypes() { 65 return { 66 closeQuickOpen: PropTypes.func.isRequired, 67 displayedSources: PropTypes.array.isRequired, 68 blackBoxRanges: PropTypes.object.isRequired, 69 highlightLineRange: PropTypes.func.isRequired, 70 clearHighlightLineRange: PropTypes.func.isRequired, 71 query: PropTypes.string.isRequired, 72 searchType: PropTypes.oneOf([ 73 "functions", 74 "goto", 75 "gotoSource", 76 "other", 77 "shortcuts", 78 "sources", 79 "variables", 80 ]).isRequired, 81 selectSpecificLocation: PropTypes.func.isRequired, 82 selectedContentLoaded: PropTypes.bool, 83 selectedLocation: PropTypes.object, 84 setQuickOpenQuery: PropTypes.func.isRequired, 85 openedSources: PropTypes.array.isRequired, 86 toggleShortcutsModal: PropTypes.func.isRequired, 87 projectDirectoryRoot: PropTypes.string, 88 getFunctionSymbols: PropTypes.func.isRequired, 89 }; 90 } 91 92 setResults(results) { 93 if (results) { 94 results = results.slice(0, maxResults); 95 } 96 this.setState({ results }); 97 } 98 99 componentDidMount() { 100 const { query, shortcutsModalEnabled, toggleShortcutsModal } = this.props; 101 102 this.updateResults(query); 103 104 if (shortcutsModalEnabled) { 105 toggleShortcutsModal(); 106 } 107 } 108 109 componentDidUpdate(prevProps) { 110 const queryChanged = prevProps.query !== this.props.query; 111 112 if (queryChanged) { 113 this.updateResults(this.props.query); 114 } 115 } 116 117 componentWillUnmount() { 118 this.#willUnmountCalled = true; 119 } 120 121 closeModal = () => { 122 this.props.closeQuickOpen(); 123 }; 124 125 dropGoto = query => { 126 const index = query.indexOf(":"); 127 return index !== -1 ? query.slice(0, index) : query; 128 }; 129 130 formatSources = memoizeLast( 131 (displayedSources, openedSources, blackBoxRanges, projectDirectoryRoot) => { 132 // Note that we should format all displayed sources, 133 // the actual filtering will only be done late from `searchSources()` 134 return displayedSources.map(source => { 135 const isBlackBoxed = !!blackBoxRanges[source.url]; 136 const hasTabOpened = openedSources.includes(source); 137 return formatSourceForList( 138 source, 139 hasTabOpened, 140 isBlackBoxed, 141 projectDirectoryRoot 142 ); 143 }); 144 } 145 ); 146 147 searchSources = query => { 148 const { 149 displayedSources, 150 openedSources, 151 blackBoxRanges, 152 projectDirectoryRoot, 153 } = this.props; 154 155 const sources = this.formatSources( 156 displayedSources, 157 openedSources, 158 blackBoxRanges, 159 projectDirectoryRoot 160 ); 161 const results = 162 query == "" ? sources : filter(sources, this.dropGoto(query)); 163 return this.setResults(results); 164 }; 165 166 searchSymbols = async query => { 167 const { getFunctionSymbols, selectedLocation } = this.props; 168 if (!selectedLocation) { 169 return this.setResults([]); 170 } 171 let results = await getFunctionSymbols(selectedLocation, maxResults); 172 173 if (query === "@" || query === "#") { 174 results = results.map(formatSymbol); 175 return this.setResults(results); 176 } 177 results = filter(results, query.slice(1), "name"); 178 results = results.map(formatSymbol); 179 return this.setResults(results); 180 }; 181 182 searchShortcuts = query => { 183 const results = formatShortcutResults(); 184 if (query == "?") { 185 this.setResults(results); 186 } else { 187 this.setResults(filter(results, query.slice(1))); 188 } 189 }; 190 191 /** 192 * This method is called when we just opened the modal and the query input is empty 193 */ 194 showTopSources = () => { 195 const { openedSources, blackBoxRanges, projectDirectoryRoot } = this.props; 196 let { displayedSources } = this.props; 197 198 // If there is some tabs opened, only show tab's sources. 199 // Otherwise, we display all visible sources (per SourceTree definition), 200 // setResults will restrict the number of results to a maximum limit. 201 if (openedSources.length) { 202 displayedSources = displayedSources.filter( 203 source => !!source.url && openedSources.includes(source) 204 ); 205 } 206 207 this.setResults( 208 this.formatSources( 209 displayedSources, 210 openedSources, 211 blackBoxRanges, 212 projectDirectoryRoot 213 ) 214 ); 215 }; 216 217 updateResults = throttle(async query => { 218 try { 219 if (this.isGotoQuery()) { 220 return; 221 } 222 223 if (query == "" && !this.isShortcutQuery()) { 224 this.showTopSources(); 225 return; 226 } 227 228 if (this.isSymbolSearch()) { 229 await this.searchSymbols(query); 230 return; 231 } 232 233 if (this.isShortcutQuery()) { 234 this.searchShortcuts(query); 235 return; 236 } 237 238 this.searchSources(query); 239 } catch (e) { 240 // Due to throttling this might get scheduled after the component and the 241 // toolbox are destroyed. 242 if (this.#willUnmountCalled) { 243 console.warn("Throttled QuickOpen.updateResults failed", e); 244 } else { 245 throw e; 246 } 247 } 248 }, QuickOpenModal.UPDATE_RESULTS_THROTTLE); 249 250 setModifier = item => { 251 if (["@", "#", ":"].includes(item.id)) { 252 this.props.setQuickOpenQuery(item.id); 253 } 254 }; 255 256 selectResultItem = (e, item) => { 257 if (item == null) { 258 return; 259 } 260 261 if (this.isShortcutQuery()) { 262 this.setModifier(item); 263 return; 264 } 265 266 if (this.isGotoSourceQuery()) { 267 const location = parseLineColumn(this.props.query); 268 this.gotoLocation({ ...location, source: item.source }); 269 return; 270 } 271 272 if (this.isSymbolSearch()) { 273 this.gotoLocation({ 274 line: 275 item.location && item.location.start ? item.location.start.line : 0, 276 }); 277 return; 278 } 279 280 this.gotoLocation({ source: item.source, line: 0 }); 281 }; 282 283 onSelectResultItem = item => { 284 const { selectedLocation, highlightLineRange, clearHighlightLineRange } = 285 this.props; 286 if ( 287 selectedLocation == null || 288 !this.isSymbolSearch() || 289 !this.isFunctionQuery() 290 ) { 291 return; 292 } 293 294 if (item.location) { 295 highlightLineRange({ 296 start: item.location.start.line, 297 end: item.location.end.line, 298 sourceId: selectedLocation.source.id, 299 }); 300 } else { 301 clearHighlightLineRange(); 302 } 303 }; 304 305 traverseResults = e => { 306 const direction = e.key === "ArrowUp" ? -1 : 1; 307 const { selectedIndex, results } = this.state; 308 const resultCount = this.getResultCount(); 309 const index = selectedIndex + direction; 310 const nextIndex = (index + resultCount) % resultCount || 0; 311 312 this.setState({ selectedIndex: nextIndex }); 313 314 if (results != null) { 315 this.onSelectResultItem(results[nextIndex]); 316 } 317 }; 318 319 gotoLocation = location => { 320 const { selectSpecificLocation, selectedLocation } = this.props; 321 322 if (location != null) { 323 const sourceLocation = createLocation({ 324 source: location.source || selectedLocation?.source, 325 line: location.line, 326 column: location.column || 0, 327 }); 328 selectSpecificLocation(sourceLocation); 329 this.closeModal(); 330 } 331 }; 332 333 onChange = e => { 334 const { selectedLocation, selectedContentLoaded, setQuickOpenQuery } = 335 this.props; 336 setQuickOpenQuery(e.target.value); 337 const noSource = !selectedLocation || !selectedContentLoaded; 338 if ((noSource && this.isSymbolSearch()) || this.isGotoQuery()) { 339 return; 340 } 341 342 // Wait for the next tick so that reducer updates are complete. 343 const targetValue = e.target.value; 344 setTimeout(() => this.updateResults(targetValue), 0); 345 }; 346 347 onKeyDown = e => { 348 const { query } = this.props; 349 const { results, selectedIndex } = this.state; 350 const isGoToQuery = this.isGotoQuery(); 351 352 if (!results && !isGoToQuery) { 353 return; 354 } 355 356 if (e.key === "Enter") { 357 if (isGoToQuery) { 358 const location = parseLineColumn(query); 359 this.gotoLocation(location); 360 return; 361 } 362 363 if (results) { 364 this.selectResultItem(e, results[selectedIndex]); 365 return; 366 } 367 } 368 369 if (e.key === "Tab") { 370 this.closeModal(); 371 return; 372 } 373 374 if (["ArrowUp", "ArrowDown"].includes(e.key)) { 375 e.preventDefault(); 376 this.traverseResults(e); 377 } 378 }; 379 380 getResultCount = () => { 381 const { results } = this.state; 382 return results && results.length ? results.length : 0; 383 }; 384 385 // Query helpers 386 isFunctionQuery = () => this.props.searchType === "functions"; 387 isSymbolSearch = () => this.isFunctionQuery(); 388 isGotoQuery = () => this.props.searchType === "goto"; 389 isGotoSourceQuery = () => this.props.searchType === "gotoSource"; 390 isShortcutQuery = () => this.props.searchType === "shortcuts"; 391 isSourcesQuery = () => this.props.searchType === "sources"; 392 isSourceSearch = () => this.isSourcesQuery() || this.isGotoSourceQuery(); 393 394 /* eslint-disable react/no-danger */ 395 renderHighlight(candidateString, query) { 396 const options = { 397 wrap: { 398 tagOpen: '<mark class="highlight">', 399 tagClose: "</mark>", 400 }, 401 }; 402 const html = fuzzyAldrin.wrap(candidateString, query, options); 403 return div({ 404 dangerouslySetInnerHTML: { 405 __html: html, 406 }, 407 }); 408 } 409 410 highlightMatching = (query, results) => { 411 let newQuery = query; 412 if (newQuery === "") { 413 return results; 414 } 415 newQuery = query.replace(/[@:#?]/gi, " "); 416 417 return results.map(result => { 418 if (typeof result.title == "string") { 419 return { 420 ...result, 421 title: this.renderHighlight( 422 result.title, 423 basename(newQuery), 424 "title" 425 ), 426 }; 427 } 428 return result; 429 }); 430 }; 431 432 shouldShowErrorEmoji() { 433 const { query } = this.props; 434 if (this.isGotoQuery()) { 435 return !/^:\d*$/.test(query); 436 } 437 return !!query && !this.getResultCount(); 438 } 439 440 getSummaryMessage() { 441 let summaryMsg = ""; 442 if (this.isGotoQuery()) { 443 summaryMsg = L10N.getStr("shortcuts.gotoLine"); 444 } else if (this.isFunctionQuery() && !this.state.results) { 445 summaryMsg = L10N.getStr("loadingText"); 446 } 447 return summaryMsg; 448 } 449 450 render() { 451 const { query } = this.props; 452 const { selectedIndex, results } = this.state; 453 454 const items = this.highlightMatching(query, results || []); 455 const expanded = !!items && !!items.length; 456 return React.createElement( 457 Modal, 458 { 459 handleClose: this.closeModal, 460 }, 461 React.createElement(SearchInput, { 462 query, 463 hasPrefix: true, 464 count: this.getResultCount(), 465 placeholder: L10N.getStr("sourceSearch.search2"), 466 summaryMsg: this.getSummaryMessage(), 467 showErrorEmoji: this.shouldShowErrorEmoji(), 468 isLoading: false, 469 onChange: this.onChange, 470 onKeyDown: this.onKeyDown, 471 handleClose: this.closeModal, 472 expanded, 473 showClose: false, 474 searchKey: searchKeys.QUICKOPEN_SEARCH, 475 showExcludePatterns: false, 476 showSearchModifiers: false, 477 selectedItemId: 478 expanded && items[selectedIndex] ? items[selectedIndex].id : "", 479 ...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT), 480 }), 481 results && 482 React.createElement(ResultList, { 483 key: "results", 484 items, 485 selected: selectedIndex, 486 selectItem: this.selectResultItem, 487 ref: "resultList", 488 expanded, 489 ...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT), 490 }) 491 ); 492 } 493 } 494 495 /* istanbul ignore next: ignoring testing of redux connection stuff */ 496 function mapStateToProps(state) { 497 const selectedLocation = getSelectedLocation(state); 498 const displayedSources = getDisplayedSourcesList(state); 499 const openedSources = getOpenedSources(state); 500 501 return { 502 displayedSources, 503 blackBoxRanges: getBlackBoxRanges(state), 504 projectDirectoryRoot: getProjectDirectoryRoot(state), 505 selectedLocation, 506 selectedContentLoaded: selectedLocation 507 ? !!getSettledSourceTextContent(state, selectedLocation) 508 : undefined, 509 query: getQuickOpenQuery(state), 510 searchType: getQuickOpenType(state), 511 openedSources, 512 }; 513 } 514 515 export default connect(mapStateToProps, { 516 selectSpecificLocation: actions.selectSpecificLocation, 517 setQuickOpenQuery: actions.setQuickOpenQuery, 518 highlightLineRange: actions.highlightLineRange, 519 clearHighlightLineRange: actions.clearHighlightLineRange, 520 closeQuickOpen: actions.closeQuickOpen, 521 getFunctionSymbols: actions.getFunctionSymbols, 522 })(QuickOpenModal);