ProjectSearch.js (12771B)
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 { 7 button, 8 div, 9 span, 10 } from "devtools/client/shared/vendor/react-dom-factories"; 11 import PropTypes from "devtools/client/shared/vendor/react-prop-types"; 12 import { connect } from "devtools/client/shared/vendor/react-redux"; 13 import actions from "../../actions/index"; 14 15 import { getEditor } from "../../utils/editor/index"; 16 import { searchKeys } from "../../constants"; 17 18 import { getRelativePath } from "../../utils/sources-tree/utils"; 19 import { 20 getProjectSearchQuery, 21 getNavigateCounter, 22 } from "../../selectors/index"; 23 24 import SearchInput from "../shared/SearchInput"; 25 import DebuggerImage from "../shared/DebuggerImage"; 26 27 const { PluralForm } = require("resource://devtools/shared/plural-form.js"); 28 const classnames = require("resource://devtools/client/shared/classnames.js"); 29 const Tree = require("resource://devtools/client/shared/components/Tree.js"); 30 const { debounce } = require("resource://devtools/shared/debounce.js"); 31 const { throttle } = require("resource://devtools/shared/throttle.js"); 32 33 const { 34 HTMLTooltip, 35 } = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); 36 37 export const statusType = { 38 initial: "INITIAL", 39 fetching: "FETCHING", 40 cancelled: "CANCELLED", 41 done: "DONE", 42 error: "ERROR", 43 }; 44 45 function getFilePath(item, index) { 46 return item.type === "RESULT" 47 ? `${item.location.source.id}-${index || "$"}` 48 : `${item.location.source.id}-${item.location.line}-${ 49 item.location.column 50 }-${index || "$"}`; 51 } 52 53 export class ProjectSearch extends Component { 54 constructor(props) { 55 super(props); 56 57 this.state = { 58 // We may restore a previous state when changing tabs in the primary panes, 59 // or when restoring primary panes from collapse. 60 query: this.props.query || "", 61 62 focusedItem: null, 63 expanded: new Set(), 64 results: [], 65 navigateCounter: null, 66 status: statusType.done, 67 }; 68 // Use throttle for updating results in order to prevent delaying showing result until the end of the search 69 this.onUpdatedResults = throttle(this.onUpdatedResults.bind(this), 100); 70 // Use debounce for input processing in order to wait for the end of user input edition before triggerring the search 71 this.doSearch = debounce(this.doSearch.bind(this), 100); 72 this.doSearch(); 73 } 74 75 static get propTypes() { 76 return { 77 doSearchForHighlight: PropTypes.func.isRequired, 78 query: PropTypes.string.isRequired, 79 searchSources: PropTypes.func.isRequired, 80 selectSpecificLocationOrSameUrl: PropTypes.func.isRequired, 81 }; 82 } 83 84 async doSearch() { 85 // Cancel any previous async ongoing search 86 if (this.searchAbortController) { 87 this.searchAbortController.abort(); 88 } 89 90 if (!this.state.query) { 91 this.setState({ status: statusType.done }); 92 return; 93 } 94 95 this.setState({ 96 status: statusType.fetching, 97 results: [], 98 navigateCounter: this.props.navigateCounter, 99 }); 100 101 // Setup an AbortController whose main goal is to be able to cancel the asynchronous 102 // operation done by the `searchSources` action. 103 // This allows allows the React Component to receive partial updates 104 // to render results as they are available. 105 this.searchAbortController = new AbortController(); 106 107 await this.props.searchSources( 108 this.state.query, 109 this.onUpdatedResults, 110 this.searchAbortController.signal 111 ); 112 } 113 114 onUpdatedResults(results, done, signal) { 115 // debounce may delay the execution after this search has been cancelled 116 if (signal.aborted) { 117 return; 118 } 119 120 this.setState({ 121 results, 122 status: done ? statusType.done : statusType.fetching, 123 }); 124 } 125 126 selectMatchItem = async matchItem => { 127 const foundMatchingSource = 128 await this.props.selectSpecificLocationOrSameUrl(matchItem.location); 129 // When we reload, or if the source's target has been destroyed, 130 // we may no longer have the source available in the reducer. 131 // In such case `selectSpecificLocationOrSameUrl` will return false. 132 if (!foundMatchingSource) { 133 // When going over results via the key arrows and Enter, we may display many tooltips at once. 134 if (this.tooltip) { 135 this.tooltip.hide(); 136 } 137 // Go down to line-number otherwise HTMLTooltip's call to getBoundingClientRect would return (0, 0) position for the tooltip 138 const element = document.querySelector( 139 ".project-text-search .tree-node.focused .result .line-number" 140 ); 141 const tooltip = new HTMLTooltip(element.ownerDocument, { 142 className: "unavailable-source", 143 type: "arrow", 144 }); 145 tooltip.panel.textContent = L10N.getStr( 146 "projectTextSearch.sourceNoLongerAvailable" 147 ); 148 tooltip.setContentSize({ height: "auto" }); 149 tooltip.show(element); 150 this.tooltip = tooltip; 151 return; 152 } 153 this.props.doSearchForHighlight(this.state.query, getEditor()); 154 }; 155 156 highlightMatches = lineMatch => { 157 const { value, matchIndex, match } = lineMatch; 158 const len = match.length; 159 return span( 160 { 161 className: "line-value", 162 }, 163 span( 164 { 165 className: "line-match", 166 key: 0, 167 }, 168 value.slice(0, matchIndex) 169 ), 170 span( 171 { 172 className: "query-match", 173 key: 1, 174 }, 175 value.substr(matchIndex, len) 176 ), 177 span( 178 { 179 className: "line-match", 180 key: 2, 181 }, 182 value.slice(matchIndex + len, value.length) 183 ) 184 ); 185 }; 186 187 getResultCount = () => 188 this.state.results.reduce((count, file) => count + file.matches.length, 0); 189 190 onKeyDown = e => { 191 if (e.key === "Escape") { 192 return; 193 } 194 195 e.stopPropagation(); 196 197 this.setState({ focusedItem: null }); 198 this.doSearch(); 199 }; 200 201 onHistoryScroll = query => { 202 this.setState({ query }); 203 this.doSearch(); 204 }; 205 206 // This can be called by Tree when manually selecting node via arrow keys and Enter. 207 onActivate = item => { 208 if (item && item.type === "MATCH") { 209 this.selectMatchItem(item); 210 } 211 }; 212 213 onFocus = item => { 214 if (this.state.focusedItem !== item) { 215 this.setState({ 216 focusedItem: item, 217 }); 218 } 219 }; 220 221 inputOnChange = e => { 222 const inputValue = e.target.value; 223 this.setState({ query: inputValue }); 224 this.doSearch(); 225 }; 226 227 renderFile = (file, focused, expanded) => { 228 const matchesLength = file.matches.length; 229 const matches = ` (${matchesLength} match${matchesLength > 1 ? "es" : ""})`; 230 return div( 231 { 232 className: classnames("file-result", { 233 focused, 234 }), 235 key: file.location.source.id, 236 }, 237 React.createElement(DebuggerImage, { 238 name: "arrow", 239 className: classnames({ 240 expanded, 241 }), 242 }), 243 React.createElement(DebuggerImage, { 244 name: "file", 245 }), 246 span( 247 { 248 className: "file-path", 249 }, 250 file.location.source.url 251 ? getRelativePath(file.location.source.url) 252 : file.location.source.shortName 253 ), 254 span( 255 { 256 className: "matches-summary", 257 }, 258 matches 259 ) 260 ); 261 }; 262 263 renderMatch = (match, focused) => { 264 return div( 265 { 266 className: classnames("result", { 267 focused, 268 }), 269 onClick: () => this.selectMatchItem(match), 270 }, 271 span( 272 { 273 className: "line-number", 274 key: match.location.line, 275 }, 276 match.location.line 277 ), 278 this.highlightMatches(match) 279 ); 280 }; 281 282 renderItem = (item, depth, focused, _, expanded) => { 283 if (item.type === "RESULT") { 284 return this.renderFile(item, focused, expanded); 285 } 286 return this.renderMatch(item, focused); 287 }; 288 289 renderRefreshButton() { 290 if (!this.state.query) { 291 return null; 292 } 293 294 // Highlight the refresh button when the current search results 295 // are based on the previous document. doSearch will save the "navigate counter" 296 // into state, while props will report the current "navigate counter". 297 // The "navigate counter" is incremented each time we navigate to a new page. 298 const highlight = 299 this.state.navigateCounter != null && 300 this.state.navigateCounter != this.props.navigateCounter; 301 return button( 302 { 303 className: classnames("refresh-btn devtools-button", { 304 highlight, 305 }), 306 title: highlight 307 ? L10N.getStr("projectTextSearch.refreshButtonTooltipOnNavigation") 308 : L10N.getStr("projectTextSearch.refreshButtonTooltip"), 309 onClick: this.doSearch, 310 }, 311 React.createElement(DebuggerImage, { 312 name: "refresh", 313 }) 314 ); 315 } 316 317 renderResultsToolbar() { 318 if (!this.state.query) { 319 return null; 320 } 321 return div( 322 { className: "project-search-results-toolbar" }, 323 span({ className: "results-count" }, this.renderSummary()), 324 this.renderRefreshButton() 325 ); 326 } 327 328 renderResults() { 329 const { status, results } = this.state; 330 if (!this.state.query) { 331 return null; 332 } 333 if (results.length) { 334 return React.createElement(Tree, { 335 getRoots: () => results, 336 getChildren: file => file.matches || [], 337 autoExpandAll: true, 338 autoExpandDepth: 1, 339 autoExpandNodeChildrenLimit: 100, 340 getParent: () => null, 341 getPath: getFilePath, 342 renderItem: this.renderItem, 343 focused: this.state.focusedItem, 344 onFocus: this.onFocus, 345 onActivate: this.onActivate, 346 isExpanded: item => { 347 return this.state.expanded.has(item); 348 }, 349 onExpand: item => { 350 const { expanded } = this.state; 351 expanded.add(item); 352 this.setState({ 353 expanded, 354 }); 355 }, 356 onCollapse: item => { 357 const { expanded } = this.state; 358 expanded.delete(item); 359 this.setState({ 360 expanded, 361 }); 362 }, 363 preventBlur: true, 364 getKey: getFilePath, 365 }); 366 } 367 const msg = 368 status === statusType.fetching 369 ? L10N.getStr("loadingText") 370 : L10N.getStr("projectTextSearch.noResults"); 371 return div( 372 { 373 className: "no-result-msg", 374 }, 375 msg 376 ); 377 } 378 379 renderSummary = () => { 380 if (this.state.query === "") { 381 return ""; 382 } 383 const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary2"); 384 const count = this.getResultCount(); 385 if (count === 0) { 386 return ""; 387 } 388 return PluralForm.get(count, resultsSummaryString).replace("#1", count); 389 }; 390 391 shouldShowErrorEmoji() { 392 return !this.getResultCount() && this.state.status === statusType.done; 393 } 394 395 renderInput() { 396 const { status } = this.state; 397 return React.createElement(SearchInput, { 398 query: this.state.query, 399 count: this.getResultCount(), 400 placeholder: L10N.getStr("projectTextSearch.placeholder"), 401 size: "small", 402 showErrorEmoji: this.shouldShowErrorEmoji(), 403 isLoading: status === statusType.fetching, 404 onChange: this.inputOnChange, 405 onKeyDown: this.onKeyDown, 406 onHistoryScroll: this.onHistoryScroll, 407 showClose: false, 408 showExcludePatterns: true, 409 excludePatternsLabel: L10N.getStr( 410 "projectTextSearch.excludePatterns.label" 411 ), 412 excludePatternsPlaceholder: L10N.getStr( 413 "projectTextSearch.excludePatterns.placeholder" 414 ), 415 ref: "searchInput", 416 showSearchModifiers: true, 417 searchKey: searchKeys.PROJECT_SEARCH, 418 onToggleSearchModifier: this.doSearch, 419 }); 420 } 421 422 render() { 423 return div( 424 { 425 className: "search-container", 426 }, 427 div( 428 { 429 className: "project-text-search", 430 }, 431 div( 432 { 433 className: "header", 434 }, 435 this.renderInput() 436 ), 437 this.renderResultsToolbar(), 438 this.renderResults() 439 ) 440 ); 441 } 442 } 443 444 ProjectSearch.contextTypes = { 445 shortcuts: PropTypes.object, 446 }; 447 448 const mapStateToProps = state => ({ 449 query: getProjectSearchQuery(state), 450 navigateCounter: getNavigateCounter(state), 451 }); 452 453 export default connect(mapStateToProps, { 454 searchSources: actions.searchSources, 455 selectSpecificLocationOrSameUrl: actions.selectSpecificLocationOrSameUrl, 456 doSearchForHighlight: actions.doSearchForHighlight, 457 })(ProjectSearch);