SearchInFileBar.js (11716B)
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 PropTypes from "devtools/client/shared/vendor/react-prop-types"; 6 import React, { Component } from "devtools/client/shared/vendor/react"; 7 import { div } from "devtools/client/shared/vendor/react-dom-factories"; 8 import { connect } from "devtools/client/shared/vendor/react-redux"; 9 import actions from "../../actions/index"; 10 import { 11 getActiveSearch, 12 getSelectedSource, 13 getIsCurrentThreadPaused, 14 getSelectedSourceTextContent, 15 getSearchOptions, 16 } from "../../selectors/index"; 17 18 import { searchKeys } from "../../constants"; 19 import { scrollList } from "../../utils/result-list"; 20 import { createLocation } from "../../utils/location"; 21 22 import SearchInput from "../shared/SearchInput"; 23 24 const { PluralForm } = require("resource://devtools/shared/plural-form.js"); 25 const { debounce } = require("resource://devtools/shared/debounce.js"); 26 import { 27 clearSearch, 28 find, 29 findNext, 30 findPrev, 31 } from "../../utils/editor/index"; 32 import { isFulfilled } from "../../utils/async-value"; 33 34 function getSearchShortcut() { 35 return L10N.getStr("sourceSearch.search.key2"); 36 } 37 38 class SearchInFileBar extends Component { 39 constructor(props) { 40 super(props); 41 this.state = { 42 query: "", 43 selectedResultIndex: 0, 44 results: { 45 matches: [], 46 matchIndex: -1, 47 count: 0, 48 index: -1, 49 }, 50 inputFocused: false, 51 }; 52 } 53 54 static get propTypes() { 55 return { 56 closeFileSearch: PropTypes.func.isRequired, 57 editor: PropTypes.object, 58 modifiers: PropTypes.object.isRequired, 59 searchInFileEnabled: PropTypes.bool.isRequired, 60 selectedSourceTextContent: PropTypes.object, 61 selectedSource: PropTypes.object.isRequired, 62 setActiveSearch: PropTypes.func.isRequired, 63 querySearchWorker: PropTypes.func.isRequired, 64 selectLocation: PropTypes.func.isRequired, 65 isPaused: PropTypes.bool.isRequired, 66 }; 67 } 68 69 componentWillUnmount() { 70 const { shortcuts } = this.context; 71 72 shortcuts.off(getSearchShortcut(), this.toggleSearch); 73 shortcuts.off("Escape", this.onEscape); 74 75 this.doSearch.cancel(); 76 } 77 78 // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 79 UNSAFE_componentWillReceiveProps(nextProps) { 80 const { query } = this.state; 81 // Trigger a search to update the search results ... 82 if ( 83 // if there is a search query and ... 84 (query && 85 // the file search bar is toggled open or ... 86 ((!this.props.searchInFileEnabled && nextProps.searchInFileEnabled) || 87 // a new source is selected. 88 this.props.selectedSource.id !== nextProps.selectedSource.id)) || 89 // the source content changes 90 this.props.selectedSourceTextContent !== 91 nextProps.selectedSourceTextContent 92 ) { 93 // Do not scroll to the search location, if we just switched to a new source 94 // and debugger is already paused on a selected line. 95 this.doSearch(query, !nextProps.isPaused); 96 } 97 } 98 99 componentDidMount() { 100 // overwrite this.doSearch with debounced version to 101 // reduce frequency of queries 102 this.doSearch = debounce(this.doSearch, 100); 103 const { shortcuts } = this.context; 104 105 shortcuts.on(getSearchShortcut(), this.toggleSearch); 106 shortcuts.on("Escape", this.onEscape); 107 } 108 109 componentDidUpdate() { 110 if (this.refs.resultList && this.refs.resultList.refs) { 111 scrollList(this.refs.resultList.refs, this.state.selectedResultIndex); 112 } 113 } 114 115 onEscape = e => { 116 this.closeSearch(e); 117 }; 118 119 clearSearch = () => { 120 const { editor } = this.props; 121 if (!editor) { 122 return; 123 } 124 editor.clearSearchMatches(); 125 editor.removePositionContentMarker("active-selection-marker"); 126 }; 127 128 closeSearch = e => { 129 const { closeFileSearch, editor, searchInFileEnabled } = this.props; 130 this.clearSearch(); 131 if (editor && searchInFileEnabled) { 132 closeFileSearch(); 133 e.stopPropagation(); 134 e.preventDefault(); 135 } 136 this.setState({ inputFocused: false }); 137 }; 138 139 toggleSearch = e => { 140 e.stopPropagation(); 141 e.preventDefault(); 142 const { editor, searchInFileEnabled, setActiveSearch } = this.props; 143 144 // Set inputFocused to false, so that search query is highlighted whenever search shortcut is used, even if the input already has focus. 145 this.setState({ inputFocused: false }); 146 147 if (!searchInFileEnabled) { 148 setActiveSearch("file"); 149 } 150 151 if (searchInFileEnabled && editor) { 152 const selectedText = editor.getSelectedText(); 153 const query = selectedText || this.state.query; 154 155 if (query !== "") { 156 this.setState({ query, inputFocused: true }); 157 this.doSearch(query); 158 } else { 159 this.setState({ query: "", inputFocused: true }); 160 } 161 } 162 }; 163 164 doSearch = async (query, shouldScroll = true) => { 165 const { editor, modifiers, selectedSourceTextContent } = this.props; 166 if ( 167 !editor || 168 !selectedSourceTextContent || 169 !isFulfilled(selectedSourceTextContent) || 170 !modifiers 171 ) { 172 return; 173 } 174 const selectedContent = selectedSourceTextContent.value; 175 176 const ctx = { editor, cm: editor.codeMirror }; 177 178 if (!query) { 179 clearSearch(ctx); 180 return; 181 } 182 183 let text; 184 if (selectedContent.type === "wasm") { 185 text = editor.renderWasmText(selectedContent).join("\n"); 186 } else { 187 text = selectedContent.value; 188 } 189 190 const matches = await this.props.querySearchWorker(query, text, modifiers); 191 const results = find(ctx, query, true, modifiers, { 192 shouldScroll, 193 }); 194 this.setSearchResults(results, matches, shouldScroll); 195 }; 196 197 traverseResults = (e, reverse = false) => { 198 e.stopPropagation(); 199 e.preventDefault(); 200 const { editor } = this.props; 201 202 if (!editor) { 203 return; 204 } 205 206 const ctx = { editor, cm: editor.codeMirror }; 207 208 const { modifiers } = this.props; 209 const { query } = this.state; 210 const { matches } = this.state.results; 211 212 if (query === "" && !this.props.searchInFileEnabled) { 213 this.props.setActiveSearch("file"); 214 } 215 216 if (modifiers) { 217 const findArgs = [ctx, query, true, modifiers]; 218 const results = reverse ? findPrev(...findArgs) : findNext(...findArgs); 219 this.setSearchResults(results, matches, true); 220 } 221 }; 222 223 /** 224 * Update the state with the results and matches from the search. 225 * This will also scroll to result's location in CodeMirror. 226 * 227 * @param {object} results 228 * @param {Array} matches 229 * @returns 230 */ 231 setSearchResults(results, matches, shouldScroll) { 232 if (!results) { 233 this.setState({ 234 results: { 235 matches, 236 matchIndex: 0, 237 count: matches.length, 238 index: -1, 239 }, 240 }); 241 return; 242 } 243 const { ch, line } = results; 244 let matchContent = ""; 245 const matchIndex = matches.findIndex(elm => { 246 if (elm.line === line && elm.ch === ch) { 247 matchContent = elm.match; 248 return true; 249 } 250 return false; 251 }); 252 253 // Only change the selected location if we should scroll to it, 254 // otherwise we are most likely updating the search results while being paused 255 // and don't want to change the selected location from the current paused location 256 if (shouldScroll) { 257 this.setCursorLocation(line, ch, matchContent); 258 } 259 this.setState({ 260 results: { 261 matches, 262 matchIndex, 263 count: matches.length, 264 index: ch, 265 }, 266 }); 267 } 268 269 /** 270 * Ensure showing the search result in CodeMirror editor, 271 * and setting the cursor at the end of the matched string. 272 * 273 * @param {number} line 274 * @param {number} ch 275 * @param {string} matchContent 276 */ 277 setCursorLocation = (line, ch, matchContent) => { 278 this.props.selectLocation( 279 createLocation({ 280 source: this.props.selectedSource, 281 line: line + 1, 282 column: ch + matchContent.length, 283 }), 284 { 285 // Reset the context, so that we don't switch to original 286 // while moving the cursor within a bundle 287 keepContext: false, 288 289 // Avoid highlighting the selected line 290 highlight: false, 291 292 // We should ensure showing the search result by scrolling it 293 // into the viewport. 294 // We won't be scrolling when receiving redux updates and we are paused. 295 scroll: true, 296 } 297 ); 298 }; 299 300 // Handlers 301 onChange = e => { 302 this.setState({ query: e.target.value }); 303 304 return this.doSearch(e.target.value); 305 }; 306 307 onFocus = () => { 308 this.setState({ inputFocused: true }); 309 }; 310 311 onBlur = () => { 312 this.setState({ inputFocused: false }); 313 }; 314 315 onKeyDown = e => { 316 if (e.key !== "Enter" && e.key !== "F3") { 317 return; 318 } 319 320 e.preventDefault(); 321 this.traverseResults(e, e.shiftKey); 322 }; 323 324 onHistoryScroll = query => { 325 this.setState({ query }); 326 this.doSearch(query); 327 }; 328 329 // Renderers 330 buildSummaryMsg() { 331 const { 332 query, 333 results: { matchIndex, count, index }, 334 } = this.state; 335 336 if (query.trim() == "") { 337 return ""; 338 } 339 340 if (count == 0) { 341 return L10N.getStr("editor.noResultsFound"); 342 } 343 344 if (index == -1) { 345 const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary2"); 346 return PluralForm.get(count, resultsSummaryString).replace("#1", count); 347 } 348 349 const searchResultsString = L10N.getStr("editor.searchResults1"); 350 return PluralForm.get(count, searchResultsString) 351 .replace("#1", count) 352 .replace("%d", matchIndex + 1); 353 } 354 355 shouldShowErrorEmoji() { 356 const { 357 query, 358 results: { count }, 359 } = this.state; 360 return !!query && !count; 361 } 362 363 render() { 364 const { searchInFileEnabled } = this.props; 365 const { 366 results: { count }, 367 } = this.state; 368 369 if (!searchInFileEnabled) { 370 return div(null); 371 } 372 return div( 373 { 374 className: "search-bar", 375 }, 376 React.createElement(SearchInput, { 377 query: this.state.query, 378 count, 379 placeholder: L10N.getStr("sourceSearch.search.placeholder2"), 380 summaryMsg: this.buildSummaryMsg(), 381 isLoading: false, 382 onChange: this.onChange, 383 onFocus: this.onFocus, 384 onBlur: this.onBlur, 385 showErrorEmoji: this.shouldShowErrorEmoji(), 386 onKeyDown: this.onKeyDown, 387 onHistoryScroll: this.onHistoryScroll, 388 handleNext: e => this.traverseResults(e, false), 389 handlePrev: e => this.traverseResults(e, true), 390 shouldFocus: this.state.inputFocused, 391 showClose: true, 392 showExcludePatterns: false, 393 handleClose: this.closeSearch, 394 showSearchModifiers: true, 395 searchKey: searchKeys.FILE_SEARCH, 396 onToggleSearchModifier: () => this.doSearch(this.state.query), 397 }) 398 ); 399 } 400 } 401 402 SearchInFileBar.contextTypes = { 403 shortcuts: PropTypes.object, 404 }; 405 406 const mapStateToProps = state => { 407 return { 408 searchInFileEnabled: getActiveSearch(state) === "file", 409 selectedSource: getSelectedSource(state), 410 isPaused: getIsCurrentThreadPaused(state), 411 selectedSourceTextContent: getSelectedSourceTextContent(state), 412 modifiers: getSearchOptions(state, "file-search"), 413 }; 414 }; 415 416 export default connect(mapStateToProps, { 417 setFileSearchQuery: actions.setFileSearchQuery, 418 setActiveSearch: actions.setActiveSearch, 419 closeFileSearch: actions.closeFileSearch, 420 querySearchWorker: actions.querySearchWorker, 421 selectLocation: actions.selectLocation, 422 })(SearchInFileBar);