├── .coffeelintignore ├── .github ├── no-response.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .pairs ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE.md ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── coffeelint.json ├── docs └── events.md ├── keymaps └── find-and-replace.cson ├── lib ├── buffer-search.js ├── default-file-icons.js ├── escape-helper.coffee ├── find-options.coffee ├── find-view.js ├── find.coffee ├── get-icon-services.js ├── history.coffee ├── project-find-view.js ├── project │ ├── list-view.js │ ├── result-row-view.js │ ├── result-row.js │ ├── results-model.js │ ├── results-pane.js │ ├── results-view.js │ └── util.coffee ├── reporter-proxy.js └── select-next.coffee ├── menus └── find-and-replace.cson ├── package-lock.json ├── package.json ├── spec ├── async-spec-helpers.js ├── buffer-search-spec.js ├── find-spec.js ├── find-view-spec.js ├── fixtures │ ├── one-long-line.coffee │ ├── project │ │ ├── long-match.js │ │ ├── one-long-line.coffee │ │ ├── sample.coffee │ │ └── sample.js │ ├── sample.coffee │ └── sample.js ├── item │ ├── deferred-editor-item.js │ ├── embedded-editor-item.js │ └── unrecognized-item.js ├── project-find-view-spec.js ├── result-row-spec.js ├── results-model-spec.js ├── results-view-spec.js ├── select-next-spec.js └── setup-spec.js └── styles └── find-and-replace.less /.coffeelintignore: -------------------------------------------------------------------------------- 1 | spec/fixtures 2 | -------------------------------------------------------------------------------- /.github/no-response.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-no-response - https://github.com/probot/no-response 2 | 3 | # Number of days of inactivity before an issue is closed for lack of response 4 | daysUntilClose: 28 5 | 6 | # Label requiring a response 7 | responseRequiredLabel: more-information-needed 8 | 9 | # Comment to post when closing an issue for lack of response. Set to `false` to disable. 10 | closeComment: > 11 | This issue has been automatically closed because there has been no response 12 | to our request for more information from the original author. With only the 13 | information that is currently in the issue, we don't have enough information 14 | to take action. Please reach out if you have or find the answers we need so 15 | that we can investigate further. 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CI: true 7 | 8 | jobs: 9 | Test: 10 | strategy: 11 | matrix: 12 | os: [macos-latest] 13 | channel: [stable, beta] 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | - uses: actions/checkout@v1 17 | - uses: UziTech/action-setup-atom@v2 18 | with: 19 | version: ${{ matrix.channel }} 20 | - name: Install dependencies 21 | run: apm install 22 | - name: Run tests 23 | run: atom --test spec 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .pairs 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /.pairs: -------------------------------------------------------------------------------- 1 | pairs: 2 | ns: Nathan Sobo; nathan 3 | cj: Corey Johnson; cj 4 | dg: David Graham; dgraham 5 | ks: Kevin Sawicki; kevin 6 | jc: Jerry Cheung; jerry 7 | bl: Brian Lopez; brian 8 | jp: Justin Palmer; justin 9 | gt: Garen Torikian; garen 10 | mc: Matt Colyer; mcolyer 11 | bo: Ben Ogle; benogle 12 | jr: Jason Rudolph; jasonrudolph 13 | jl: Jessica Lord; jlord 14 | email: 15 | domain: github.com 16 | #global: true 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | See the [Atom contributing guide](https://github.com/atom/atom/blob/master/CONTRIBUTING.md) 2 | 3 | To run tests on core atom packages like this one, see: https://flight-manual.atom.io/hacking-atom/sections/writing-specs/#running-specs 4 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ### Prerequisites 10 | 11 | * [ ] Put an X between the brackets on this line if you have done all of the following: 12 | * Reproduced the problem in Safe Mode: http://flight-manual.atom.io/hacking-atom/sections/debugging/#using-safe-mode 13 | * Followed all applicable steps in the debugging guide: http://flight-manual.atom.io/hacking-atom/sections/debugging/ 14 | * Checked the FAQs on the message board for common solutions: https://discuss.atom.io/c/faq 15 | * Checked that your issue isn't already filed: https://github.com/issues?utf8=✓&q=is%3Aissue+user%3Aatom 16 | * Checked that there is not already an Atom package that provides the described functionality: https://atom.io/packages 17 | 18 | ### Description 19 | 20 | [Description of the issue] 21 | 22 | ### Steps to Reproduce 23 | 24 | 1. [First Step] 25 | 2. [Second Step] 26 | 3. [and so on...] 27 | 28 | **Expected behavior:** [What you expect to happen] 29 | 30 | **Actual behavior:** [What actually happens] 31 | 32 | **Reproduces how often:** [What percentage of the time does it reproduce?] 33 | 34 | ### Versions 35 | 36 | You can get this information from copy and pasting the output of `atom --version` and `apm --version` from the command line. Also, please include the OS and what version of the OS you're running. 37 | 38 | ### Additional Information 39 | 40 | Any additional information, configuration or data that might be necessary to reproduce the issue. 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 GitHub Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Requirements 2 | 3 | * Filling out the template is required. Any pull request that does not include enough information to be reviewed in a timely manner may be closed at the maintainers' discretion. 4 | * All new code requires tests to ensure against regressions 5 | 6 | ### Description of the Change 7 | 8 | 13 | 14 | ### Alternate Designs 15 | 16 | 17 | 18 | ### Benefits 19 | 20 | 21 | 22 | ### Possible Drawbacks 23 | 24 | 25 | 26 | ### Applicable Issues 27 | 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ##### Atom and all repositories under Atom will be archived on December 15, 2022. Learn more in our [official announcement](https://github.blog/2022-06-08-sunsetting-atom/) 2 | # Find and Replace package 3 | [![CI](https://github.com/atom/find-and-replace/actions/workflows/ci.yml/badge.svg)](https://github.com/atom/find-and-replace/actions/workflows/ci.yml) 4 | 5 | Find and replace in the current buffer or across the entire project in Atom. 6 | 7 | ## Find in buffer 8 | 9 | Using the shortcut cmd-f (Mac) or ctrl-f (Windows and Linux). 10 | ![screen shot 2013-11-26 at 12 25 22 pm](https://f.cloud.github.com/assets/69169/1625938/a859fa70-56d9-11e3-8b2a-ac37c5033159.png) 11 | 12 | ## Find in project 13 | 14 | Using the shortcut cmd-shift-f (Mac) or ctrl-shift-f (Windows and Linux). 15 | ![screen shot 2013-11-26 at 12 26 02 pm](https://f.cloud.github.com/assets/69169/1625945/b216d7b8-56d9-11e3-8b14-6afc33467be9.png) 16 | 17 | ## Provided Service 18 | 19 | If you need access the marker layer containing result markers for a given editor, use the `find-and-replace@0.0.1` service. The service exposes one method, `resultsMarkerLayerForTextEditor`, which takes a `TextEditor` and returns a `TextEditorMarkerLayer` that you can interact with. Keep in mind that any work you do in synchronous event handlers on this layer will impact the performance of find and replace. 20 | -------------------------------------------------------------------------------- /coffeelint.json: -------------------------------------------------------------------------------- 1 | { 2 | "max_line_length": { 3 | "level": "ignore" 4 | }, 5 | "no_empty_param_list": { 6 | "level": "error" 7 | }, 8 | "arrow_spacing": { 9 | "level": "error" 10 | }, 11 | "no_interpolation_in_single_quotes": { 12 | "level": "error" 13 | }, 14 | "no_debugger": { 15 | "level": "error" 16 | }, 17 | "prefer_english_operator": { 18 | "level": "error" 19 | }, 20 | "colon_assignment_spacing": { 21 | "spacing": { 22 | "left": 0, 23 | "right": 1 24 | }, 25 | "level": "error" 26 | }, 27 | "braces_spacing": { 28 | "spaces": 0, 29 | "level": "error" 30 | }, 31 | "spacing_after_comma": { 32 | "level": "error" 33 | }, 34 | "no_stand_alone_at": { 35 | "level": "error" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /docs/events.md: -------------------------------------------------------------------------------- 1 | # Events specification 2 | 3 | This document specifies all the data (along with the format) which gets sent from the Find and Replace package to the GitHub analytics pipeline. This document follows the same format and nomenclature as the [Atom Core Events spec](https://github.com/atom/metrics/blob/master/docs/events.md). 4 | 5 | ## Counters 6 | 7 | Currently Find and Replace does not log any counter events. 8 | 9 | ## Timing events 10 | 11 | #### Time to search on a project 12 | 13 | * **eventType**: `find-and-replace-v1` 14 | * **metadata** 15 | 16 | | field | value | 17 | |-------|-------| 18 | | `ec` | `time-to-search` 19 | | `ev` | Number of found results 20 | | `el` | Search system in use (`ripgrep` or `standard`) 21 | 22 | ## Standard events 23 | 24 | Currently Find and Replace does not log any standard events. -------------------------------------------------------------------------------- /keymaps/find-and-replace.cson: -------------------------------------------------------------------------------- 1 | '.platform-darwin': 2 | 'cmd-F': 'project-find:show' 3 | 'cmd-f': 'find-and-replace:show' 4 | 'cmd-alt-f': 'find-and-replace:show-replace' 5 | 6 | '.platform-win32, .platform-linux': 7 | 'ctrl-F': 'project-find:show' 8 | 'ctrl-f': 'find-and-replace:show' 9 | 10 | '.platform-darwin atom-text-editor': 11 | 'cmd-g': 'find-and-replace:find-next' 12 | 'cmd-G': 'find-and-replace:find-previous' 13 | 'cmd-f3': 'find-and-replace:find-next-selected' 14 | 'cmd-shift-f3': 'find-and-replace:find-previous-selected' 15 | 'cmd-ctrl-g': 'find-and-replace:select-all' 16 | 'cmd-d': 'find-and-replace:select-next' 17 | 'cmd-alt-e': 'find-and-replace:replace-next' 18 | 'cmd-e': 'find-and-replace:use-selection-as-find-pattern' 19 | 'cmd-shift-e': 'find-and-replace:use-selection-as-replace-pattern' 20 | 'cmd-u': 'find-and-replace:select-undo' 21 | 'cmd-k cmd-d': 'find-and-replace:select-skip' 22 | 23 | '.platform-win32 atom-text-editor, .platform-linux atom-text-editor': 24 | 'f3': 'find-and-replace:find-next' 25 | 'shift-f3': 'find-and-replace:find-previous' 26 | 'ctrl-f3': 'find-and-replace:find-next-selected' 27 | 'ctrl-shift-f3': 'find-and-replace:find-previous-selected' 28 | 'alt-f3': 'find-and-replace:select-all' 29 | 'ctrl-d': 'find-and-replace:select-next' 30 | 'ctrl-e': 'find-and-replace:use-selection-as-find-pattern' 31 | 'ctrl-shift-e': 'find-and-replace:use-selection-as-replace-pattern' 32 | 'ctrl-u': 'find-and-replace:select-undo' 33 | 'ctrl-k ctrl-d': 'find-and-replace:select-skip' 34 | 35 | '.platform-darwin .find-and-replace': 36 | 'shift-enter': 'find-and-replace:show-previous' 37 | 'cmd-enter': 'find-and-replace:confirm' 38 | 'alt-enter': 'find-and-replace:find-all' 39 | 'cmd-alt-/': 'find-and-replace:toggle-regex-option' 40 | 'cmd-alt-c': 'find-and-replace:toggle-case-option' 41 | 'cmd-alt-s': 'find-and-replace:toggle-selection-option' 42 | 'cmd-alt-w': 'find-and-replace:toggle-whole-word-option' 43 | 44 | '.platform-win32 .find-and-replace, .platform-linux .find-and-replace': 45 | 'shift-enter': 'find-and-replace:show-previous' 46 | 'ctrl-enter': 'find-and-replace:confirm' 47 | 'alt-enter': 'find-and-replace:find-all' 48 | 'ctrl-alt-/': 'find-and-replace:toggle-regex-option' 49 | 'ctrl-shift-c': 'find-and-replace:toggle-case-option' 50 | 51 | '.platform-darwin .project-find': 52 | 'cmd-enter': 'project-find:confirm' 53 | 'cmd-alt-/': 'project-find:toggle-regex-option' 54 | 'cmd-alt-c': 'project-find:toggle-case-option' 55 | 'cmd-alt-w': 'project-find:toggle-whole-word-option' 56 | 57 | '.platform-win32 .project-find, .platform-linux .project-find': 58 | 'ctrl-enter': 'project-find:confirm' 59 | 'ctrl-alt-/': 'project-find:toggle-regex-option' 60 | 'ctrl-shift-c': 'project-find:toggle-case-option' 61 | 62 | '.find-and-replace, .project-find, .project-find .results-view': 63 | 'tab': 'find-and-replace:focus-next' 64 | 'shift-tab': 'find-and-replace:focus-previous' 65 | 66 | '.platform-darwin .find-and-replace .replace-container atom-text-editor': 67 | 'cmd-enter': 'find-and-replace:replace-all' 68 | '.platform-darwin .project-find .replace-container atom-text-editor': 69 | 'cmd-enter': 'project-find:replace-all' 70 | 71 | '.platform-win32 .find-and-replace .replace-container atom-text-editor': 72 | 'ctrl-enter': 'find-and-replace:replace-all' 73 | '.platform-win32 .project-find .replace-container atom-text-editor': 74 | 'ctrl-enter': 'project-find:replace-all' 75 | 76 | '.platform-linux .find-and-replace .replace-container atom-text-editor': 77 | 'ctrl-enter': 'find-and-replace:replace-all' 78 | '.platform-linux .project-find .replace-container atom-text-editor': 79 | 'ctrl-enter': 'project-find:replace-all' 80 | 81 | '.results-view': 82 | 'home': 'core:move-to-top' 83 | 'ctrl-home': 'core:move-to-top' 84 | 'end': 'core:move-to-bottom' 85 | 'ctrl-end': 'core:move-to-bottom' 86 | -------------------------------------------------------------------------------- /lib/buffer-search.js: -------------------------------------------------------------------------------- 1 | const { Point, Range, Emitter, CompositeDisposable, TextBuffer } = require('atom'); 2 | const FindOptions = require('./find-options'); 3 | const escapeHelper = require('./escape-helper'); 4 | const Util = require('./project/util'); 5 | 6 | const ResultsMarkerLayersByEditor = new WeakMap; 7 | 8 | module.exports = 9 | class BufferSearch { 10 | constructor(findOptions) { 11 | this.findOptions = findOptions; 12 | this.emitter = new Emitter; 13 | this.subscriptions = null; 14 | this.markers = []; 15 | this.editor = null; 16 | } 17 | 18 | onDidUpdate(callback) { 19 | return this.emitter.on('did-update', callback); 20 | } 21 | 22 | onDidError(callback) { 23 | return this.emitter.on('did-error', callback); 24 | } 25 | 26 | onDidChangeCurrentResult(callback) { 27 | return this.emitter.on('did-change-current-result', callback); 28 | } 29 | 30 | setEditor(editor) { 31 | this.editor = editor; 32 | if (this.subscriptions) this.subscriptions.dispose(); 33 | if (this.editor) { 34 | this.subscriptions = new CompositeDisposable; 35 | this.subscriptions.add(this.editor.onDidStopChanging(this.bufferStoppedChanging.bind(this))); 36 | this.subscriptions.add(this.editor.onDidAddSelection(this.setCurrentMarkerFromSelection.bind(this))); 37 | this.subscriptions.add(this.editor.onDidChangeSelectionRange(this.setCurrentMarkerFromSelection.bind(this))); 38 | this.resultsMarkerLayer = this.resultsMarkerLayerForTextEditor(this.editor); 39 | if (this.resultsLayerDecoration) this.resultsLayerDecoration.destroy(); 40 | this.resultsLayerDecoration = this.editor.decorateMarkerLayer(this.resultsMarkerLayer, {type: 'highlight', class: 'find-result'}); 41 | } 42 | this.recreateMarkers(); 43 | } 44 | 45 | getEditor() { return this.editor; } 46 | 47 | setFindOptions(newParams) { return this.findOptions.set(newParams); } 48 | 49 | getFindOptions() { return this.findOptions; } 50 | 51 | resultsMarkerLayerForTextEditor(editor) { 52 | let layer = ResultsMarkerLayersByEditor.get(editor) 53 | if (!layer) { 54 | layer = editor.addMarkerLayer({maintainHistory: false}); 55 | ResultsMarkerLayersByEditor.set(editor, layer); 56 | } 57 | return layer; 58 | } 59 | 60 | patternMatchesEmptyString(findPattern) { 61 | const findOptions = new FindOptions(this.findOptions.serialize()); 62 | findOptions.set({findPattern}); 63 | try { 64 | return findOptions.getFindPatternRegex().test(''); 65 | } catch (e) { 66 | this.emitter.emit('did-error', e); 67 | return false; 68 | } 69 | } 70 | 71 | search(findPattern, otherOptions) { 72 | let options = {findPattern}; 73 | Object.assign(options, otherOptions); 74 | 75 | const changedParams = this.findOptions.set(options); 76 | if (!this.editor || 77 | changedParams.findPattern != null || 78 | changedParams.useRegex != null || 79 | changedParams.wholeWord != null || 80 | changedParams.caseSensitive != null || 81 | changedParams.inCurrentSelection != null || 82 | (this.findOptions.inCurrentSelection === true 83 | && !selectionsEqual(this.editor.getSelectedBufferRanges(), this.selectedRanges))) { 84 | this.recreateMarkers(); 85 | } 86 | } 87 | 88 | replace(markers, replacePattern) { 89 | if (!markers || markers.length === 0) return; 90 | 91 | this.findOptions.set({replacePattern}); 92 | const preserveCaseOnReplace = atom.config.get('find-and-replace.preserveCaseOnReplace') 93 | 94 | this.editor.transact(() => { 95 | let findRegex = null 96 | 97 | if (this.findOptions.useRegex) { 98 | findRegex = this.getFindPatternRegex(); 99 | replacePattern = escapeHelper.unescapeEscapeSequence(replacePattern); 100 | } 101 | 102 | for (let i = 0, n = markers.length; i < n; i++) { 103 | const marker = markers[i] 104 | const bufferRange = marker.getBufferRange(); 105 | const replacedText = this.editor.getTextInBufferRange(bufferRange) 106 | let replacementText = findRegex ? replacedText.replace(findRegex, replacePattern) : replacePattern; 107 | replacementText = preserveCaseOnReplace ? Util.preserveCase(replacementText, replacedText): replacementText 108 | this.editor.setTextInBufferRange(bufferRange, replacementText); 109 | 110 | marker.destroy(); 111 | this.markers.splice(this.markers.indexOf(marker), 1); 112 | } 113 | }); 114 | 115 | return this.emitter.emit('did-update', this.markers.slice()); 116 | } 117 | 118 | destroy() { 119 | if (this.subscriptions) this.subscriptions.dispose(); 120 | } 121 | 122 | /* 123 | Section: Private 124 | */ 125 | 126 | recreateMarkers() { 127 | if (this.resultsMarkerLayer) { 128 | this.resultsMarkerLayer.clear() 129 | } 130 | 131 | this.markers.length = 0; 132 | const markers = this.createMarkers(Point.ZERO, Point.INFINITY); 133 | if (markers) { 134 | this.markers = markers; 135 | return this.emitter.emit("did-update", this.markers.slice()); 136 | } 137 | } 138 | 139 | createMarkers(start, end) { 140 | let newMarkers = []; 141 | if (this.findOptions.findPattern && this.editor) { 142 | this.selectedRanges = this.editor.getSelectedBufferRanges() 143 | 144 | let searchRanges = [] 145 | if (this.findOptions.inCurrentSelection) { 146 | searchRanges.push(...this.selectedRanges.filter(range => !range.isEmpty())) 147 | } 148 | if (searchRanges.length === 0) { 149 | searchRanges.push(Range(start, end)) 150 | } 151 | 152 | const buffer = this.editor.getBuffer() 153 | const regex = this.getFindPatternRegex(buffer.hasAstral && buffer.hasAstral()) 154 | if (regex) { 155 | try { 156 | for (const range of searchRanges) { 157 | const bufferMarkers = this.editor.getBuffer().findAndMarkAllInRangeSync( 158 | this.resultsMarkerLayer.bufferMarkerLayer, 159 | regex, 160 | range, 161 | {invalidate: 'inside'} 162 | ); 163 | for (const bufferMarker of bufferMarkers) { 164 | newMarkers.push(this.resultsMarkerLayer.getMarker(bufferMarker.id)) 165 | } 166 | } 167 | } catch (error) { 168 | this.emitter.emit('did-error', error); 169 | return false; 170 | } 171 | } else { 172 | return false; 173 | } 174 | } 175 | return newMarkers; 176 | } 177 | 178 | bufferStoppedChanging({changes}) { 179 | let marker; 180 | let scanEnd = Point.ZERO; 181 | let markerIndex = 0; 182 | 183 | for (let change of changes) { 184 | const changeStart = change.start; 185 | const changeEnd = change.start.traverse(change.newExtent); 186 | if (changeEnd.isLessThan(scanEnd)) continue; 187 | 188 | let precedingMarkerIndex = -1; 189 | while (marker = this.markers[markerIndex]) { 190 | if (marker.isValid()) { 191 | if (marker.getBufferRange().end.isGreaterThan(changeStart)) { break; } 192 | precedingMarkerIndex = markerIndex; 193 | } else { 194 | this.markers[markerIndex] = this.recreateMarker(marker); 195 | } 196 | markerIndex++; 197 | } 198 | 199 | let followingMarkerIndex = -1; 200 | while (marker = this.markers[markerIndex]) { 201 | if (marker.isValid()) { 202 | followingMarkerIndex = markerIndex; 203 | if (marker.getBufferRange().start.isGreaterThanOrEqual(changeEnd)) { break; } 204 | } else { 205 | this.markers[markerIndex] = this.recreateMarker(marker); 206 | } 207 | markerIndex++; 208 | } 209 | 210 | let spliceStart, scanStart 211 | if (precedingMarkerIndex >= 0) { 212 | spliceStart = precedingMarkerIndex; 213 | scanStart = this.markers[precedingMarkerIndex].getBufferRange().start; 214 | } else { 215 | spliceStart = 0; 216 | scanStart = Point.ZERO; 217 | } 218 | 219 | let spliceEnd 220 | if (followingMarkerIndex >= 0) { 221 | spliceEnd = followingMarkerIndex; 222 | scanEnd = this.markers[followingMarkerIndex].getBufferRange().end; 223 | } else { 224 | spliceEnd = Infinity; 225 | scanEnd = Point.INFINITY; 226 | } 227 | 228 | const newMarkers = this.createMarkers(scanStart, scanEnd) || []; 229 | const oldMarkers = this.markers.splice(spliceStart, (spliceEnd - spliceStart) + 1, ...newMarkers); 230 | for (let oldMarker of oldMarkers) { 231 | oldMarker.destroy(); 232 | } 233 | markerIndex += newMarkers.length - oldMarkers.length; 234 | } 235 | 236 | while (marker = this.markers[++markerIndex]) { 237 | if (!marker.isValid()) { 238 | this.markers[markerIndex] = this.recreateMarker(marker); 239 | } 240 | } 241 | 242 | this.emitter.emit('did-update', this.markers.slice()); 243 | this.currentResultMarker = null; 244 | this.setCurrentMarkerFromSelection(); 245 | } 246 | 247 | setCurrentMarkerFromSelection() { 248 | const marker = this.findMarker(this.editor.getSelectedBufferRange()); 249 | 250 | if (marker === this.currentResultMarker) return; 251 | 252 | if (this.currentResultMarker) { 253 | this.resultsLayerDecoration.setPropertiesForMarker(this.currentResultMarker, null); 254 | this.currentResultMarker = null; 255 | } 256 | 257 | if (marker && !marker.isDestroyed()) { 258 | this.resultsLayerDecoration.setPropertiesForMarker(marker, {type: 'highlight', class: 'current-result'}); 259 | this.currentResultMarker = marker; 260 | } 261 | 262 | this.emitter.emit('did-change-current-result', this.currentResultMarker); 263 | } 264 | 265 | findMarker(range) { 266 | if (this.resultsMarkerLayer) { 267 | return this.resultsMarkerLayer.findMarkers({ 268 | startBufferPosition: range.start, 269 | endBufferPosition: range.end 270 | })[0]; 271 | } 272 | } 273 | 274 | recreateMarker(marker) { 275 | const range = marker.getBufferRange() 276 | marker.destroy(); 277 | return this.createMarker(range); 278 | } 279 | 280 | createMarker(range) { 281 | return this.resultsMarkerLayer.markBufferRange(range, {invalidate: 'inside'}); 282 | } 283 | 284 | getFindPatternRegex(forceUnicode) { 285 | try { 286 | return this.findOptions.getFindPatternRegex(forceUnicode); 287 | } catch (e) { 288 | this.emitter.emit('did-error', e); 289 | return null; 290 | } 291 | } 292 | }; 293 | 294 | function selectionsEqual(selectionsA, selectionsB) { 295 | if (selectionsA.length === selectionsB.length) { 296 | for (let i = 0; i < selectionsA.length; i++) { 297 | if (!selectionsA[i].isEqual(selectionsB[i])) { 298 | return false 299 | } 300 | } 301 | return true 302 | } else { 303 | return false 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /lib/default-file-icons.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-plus') 2 | const path = require('path') 3 | 4 | class DefaultFileIcons { 5 | iconClassForPath (filePath) { 6 | const extension = path.extname(filePath) 7 | 8 | if (fs.isSymbolicLinkSync(filePath)) { 9 | return 'icon-file-symlink-file' 10 | } else if (fs.isReadmePath(filePath)) { 11 | return 'icon-book' 12 | } else if (fs.isCompressedExtension(extension)) { 13 | return 'icon-file-zip' 14 | } else if (fs.isImageExtension(extension)) { 15 | return 'icon-file-media' 16 | } else if (fs.isPdfExtension(extension)) { 17 | return 'icon-file-pdf' 18 | } else if (fs.isBinaryExtension(extension)) { 19 | return 'icon-file-binary' 20 | } else { 21 | return 'icon-file-text' 22 | } 23 | } 24 | } 25 | 26 | module.exports = new DefaultFileIcons() 27 | -------------------------------------------------------------------------------- /lib/escape-helper.coffee: -------------------------------------------------------------------------------- 1 | module.exports = 2 | unescapeEscapeSequence: (string) -> 3 | string.replace /\\(.)/gm, (match, char) -> 4 | if char is 't' 5 | '\t' 6 | else if char is 'n' 7 | '\n' 8 | else if char is 'r' 9 | '\r' 10 | else if char is '\\' 11 | '\\' 12 | else 13 | match 14 | -------------------------------------------------------------------------------- /lib/find-options.coffee: -------------------------------------------------------------------------------- 1 | _ = require 'underscore-plus' 2 | {Emitter} = require 'atom' 3 | 4 | Params = [ 5 | 'findPattern' 6 | 'replacePattern' 7 | 'pathsPattern' 8 | 'useRegex' 9 | 'wholeWord' 10 | 'caseSensitive' 11 | 'inCurrentSelection' 12 | 'leadingContextLineCount' 13 | 'trailingContextLineCount' 14 | ] 15 | 16 | module.exports = 17 | class FindOptions 18 | constructor: (state={}) -> 19 | @emitter = new Emitter 20 | 21 | @findPattern = '' 22 | @replacePattern = state.replacePattern ? '' 23 | @pathsPattern = state.pathsPattern ? '' 24 | @useRegex = state.useRegex ? atom.config.get('find-and-replace.useRegex') ? false 25 | @caseSensitive = state.caseSensitive ? atom.config.get('find-and-replace.caseSensitive') ? false 26 | @wholeWord = state.wholeWord ? atom.config.get('find-and-replace.wholeWord') ? false 27 | @inCurrentSelection = state.inCurrentSelection ? atom.config.get('find-and-replace.inCurrentSelection') ? false 28 | @leadingContextLineCount = state.leadingContextLineCount ? atom.config.get('find-and-replace.leadingContextLineCount') ? 0 29 | @trailingContextLineCount = state.trailingContextLineCount ? atom.config.get('find-and-replace.trailingContextLineCount') ? 0 30 | 31 | onDidChange: (callback) -> 32 | @emitter.on('did-change', callback) 33 | 34 | onDidChangeUseRegex: (callback) -> 35 | @emitter.on('did-change-useRegex', callback) 36 | 37 | onDidChangeReplacePattern: (callback) -> 38 | @emitter.on('did-change-replacePattern', callback) 39 | 40 | serialize: -> 41 | result = {} 42 | for param in Params 43 | result[param] = this[param] 44 | result 45 | 46 | set: (newParams={}) -> 47 | changedParams = {} 48 | for key in Params 49 | if newParams[key]? and newParams[key] isnt this[key] 50 | changedParams ?= {} 51 | this[key] = changedParams[key] = newParams[key] 52 | 53 | if Object.keys(changedParams).length 54 | for param, val of changedParams 55 | @emitter.emit("did-change-#{param}") 56 | @emitter.emit('did-change', changedParams) 57 | return changedParams 58 | 59 | getFindPatternRegex: (forceUnicode = false) -> 60 | for i in [0..@findPattern.length] 61 | if @findPattern.charCodeAt(i) > 128 62 | forceUnicode = true 63 | break 64 | 65 | flags = 'gm' 66 | flags += 'i' unless @caseSensitive 67 | flags += 'u' if forceUnicode 68 | 69 | if @useRegex 70 | expression = @findPattern 71 | else 72 | expression = escapeRegExp(@findPattern) 73 | 74 | expression = "\\b#{expression}\\b" if @wholeWord 75 | 76 | new RegExp(expression, flags) 77 | 78 | # This is different from _.escapeRegExp, which escapes dashes. Escaped dashes 79 | # are not allowed outside of character classes in RegExps with the `u` flag. 80 | # 81 | # See atom/find-and-replace#1022 82 | escapeRegExp = (string) -> 83 | string.replace(/[\/\\^$*+?.()|[\]{}]/g, '\\$&') 84 | -------------------------------------------------------------------------------- /lib/find.coffee: -------------------------------------------------------------------------------- 1 | {CompositeDisposable, Disposable, TextBuffer} = require 'atom' 2 | 3 | SelectNext = require './select-next' 4 | {History, HistoryCycler} = require './history' 5 | FindOptions = require './find-options' 6 | BufferSearch = require './buffer-search' 7 | getIconServices = require './get-icon-services' 8 | FindView = require './find-view' 9 | ProjectFindView = require './project-find-view' 10 | ResultsModel = require './project/results-model' 11 | ResultsPaneView = require './project/results-pane' 12 | ReporterProxy = require './reporter-proxy' 13 | 14 | metricsReporter = new ReporterProxy() 15 | 16 | module.exports = 17 | activate: ({findOptions, findHistory, replaceHistory, pathsHistory}={}) -> 18 | # Convert old config setting for backward compatibility. 19 | if atom.config.get('find-and-replace.openProjectFindResultsInRightPane') 20 | atom.config.set('find-and-replace.projectSearchResultsPaneSplitDirection', 'right') 21 | atom.config.unset('find-and-replace.openProjectFindResultsInRightPane') 22 | 23 | atom.workspace.addOpener (filePath) -> 24 | new ResultsPaneView() if filePath.indexOf(ResultsPaneView.URI) isnt -1 25 | 26 | @subscriptions = new CompositeDisposable 27 | @currentItemSub = new Disposable 28 | @findHistory = new History(findHistory) 29 | @replaceHistory = new History(replaceHistory) 30 | @pathsHistory = new History(pathsHistory) 31 | 32 | @findOptions = new FindOptions(findOptions) 33 | @findModel = new BufferSearch(@findOptions) 34 | @resultsModel = new ResultsModel(@findOptions, metricsReporter) 35 | 36 | @subscriptions.add atom.workspace.getCenter().observeActivePaneItem (paneItem) => 37 | @subscriptions.delete @currentItemSub 38 | @currentItemSub.dispose() 39 | 40 | if atom.workspace.isTextEditor(paneItem) 41 | @findModel.setEditor(paneItem) 42 | else if paneItem?.observeEmbeddedTextEditor? 43 | @currentItemSub = paneItem.observeEmbeddedTextEditor (editor) => 44 | if atom.workspace.getCenter().getActivePaneItem() is paneItem 45 | @findModel.setEditor(editor) 46 | @subscriptions.add @currentItemSub 47 | else if paneItem?.getEmbeddedTextEditor? 48 | @findModel.setEditor(paneItem.getEmbeddedTextEditor()) 49 | else 50 | @findModel.setEditor(null) 51 | 52 | @subscriptions.add atom.commands.add '.find-and-replace, .project-find', 'window:focus-next-pane', -> 53 | atom.views.getView(atom.workspace).focus() 54 | 55 | @subscriptions.add atom.commands.add 'atom-workspace', 'project-find:show', => 56 | @createViews() 57 | showPanel @projectFindPanel, @findPanel, => @projectFindView.focusFindElement() 58 | 59 | @subscriptions.add atom.commands.add 'atom-workspace', 'project-find:toggle', => 60 | @createViews() 61 | togglePanel @projectFindPanel, @findPanel, => @projectFindView.focusFindElement() 62 | 63 | @subscriptions.add atom.commands.add 'atom-workspace', 'project-find:show-in-current-directory', ({target}) => 64 | @createViews() 65 | @findPanel.hide() 66 | @projectFindPanel.show() 67 | @projectFindView.focusFindElement() 68 | @projectFindView.findInCurrentlySelectedDirectory(target) 69 | 70 | @subscriptions.add atom.commands.add 'atom-workspace', 'find-and-replace:use-selection-as-find-pattern', => 71 | return if @projectFindPanel?.isVisible() or @findPanel?.isVisible() 72 | @createViews() 73 | 74 | @subscriptions.add atom.commands.add 'atom-workspace', 'find-and-replace:use-selection-as-replace-pattern', => 75 | return if @projectFindPanel?.isVisible() or @findPanel?.isVisible() 76 | @createViews() 77 | 78 | @subscriptions.add atom.commands.add 'atom-workspace', 'find-and-replace:toggle', => 79 | @createViews() 80 | togglePanel @findPanel, @projectFindPanel, => @findView.focusFindEditor() 81 | 82 | @subscriptions.add atom.commands.add 'atom-workspace', 'find-and-replace:show', => 83 | @createViews() 84 | showPanel @findPanel, @projectFindPanel, => @findView.focusFindEditor() 85 | 86 | @subscriptions.add atom.commands.add 'atom-workspace', 'find-and-replace:show-replace', => 87 | @createViews() 88 | showPanel @findPanel, @projectFindPanel, => @findView.focusReplaceEditor() 89 | 90 | @subscriptions.add atom.commands.add 'atom-workspace', 'find-and-replace:clear-history', => 91 | @findHistory.clear() 92 | @replaceHistory.clear() 93 | 94 | # Handling cancel in the workspace + code editors 95 | handleEditorCancel = ({target}) => 96 | isMiniEditor = target.tagName is 'ATOM-TEXT-EDITOR' and target.hasAttribute('mini') 97 | unless isMiniEditor 98 | @findPanel?.hide() 99 | @projectFindPanel?.hide() 100 | 101 | @subscriptions.add atom.commands.add 'atom-workspace', 102 | 'core:cancel': handleEditorCancel 103 | 'core:close': handleEditorCancel 104 | 105 | selectNextObjectForEditorElement = (editorElement) => 106 | @selectNextObjects ?= new WeakMap() 107 | editor = editorElement.getModel() 108 | selectNext = @selectNextObjects.get(editor) 109 | unless selectNext? 110 | selectNext = new SelectNext(editor) 111 | @selectNextObjects.set(editor, selectNext) 112 | selectNext 113 | 114 | showPanel = (panelToShow, panelToHide, postShowAction) -> 115 | panelToHide.hide() 116 | panelToShow.show() 117 | postShowAction?() 118 | 119 | togglePanel = (panelToToggle, panelToHide, postToggleAction) -> 120 | panelToHide.hide() 121 | 122 | if panelToToggle.isVisible() 123 | panelToToggle.hide() 124 | else 125 | panelToToggle.show() 126 | postToggleAction?() 127 | 128 | @subscriptions.add atom.commands.add '.editor:not(.mini)', 129 | 'find-and-replace:select-next': (event) -> 130 | selectNextObjectForEditorElement(this).findAndSelectNext() 131 | 'find-and-replace:select-all': (event) -> 132 | selectNextObjectForEditorElement(this).findAndSelectAll() 133 | 'find-and-replace:select-undo': (event) -> 134 | selectNextObjectForEditorElement(this).undoLastSelection() 135 | 'find-and-replace:select-skip': (event) -> 136 | selectNextObjectForEditorElement(this).skipCurrentSelection() 137 | 138 | consumeMetricsReporter: (service) -> 139 | metricsReporter.setReporter(service) 140 | new Disposable -> 141 | metricsReporter.unsetReporter() 142 | 143 | consumeElementIcons: (service) -> 144 | getIconServices().setElementIcons service 145 | new Disposable -> 146 | getIconServices().resetElementIcons() 147 | 148 | consumeFileIcons: (service) -> 149 | getIconServices().setFileIcons service 150 | new Disposable -> 151 | getIconServices().resetFileIcons() 152 | 153 | toggleAutocompletions: (value) -> 154 | if not @findView? 155 | return 156 | if value 157 | @autocompleteSubscriptions = new CompositeDisposable 158 | disposable = @autocompleteWatchEditor?(@findView.findEditor, ['default']) 159 | if disposable? 160 | @autocompleteSubscriptions.add(disposable) 161 | else 162 | @autocompleteSubscriptions?.dispose() 163 | 164 | consumeAutocompleteWatchEditor: (watchEditor) -> 165 | @autocompleteWatchEditor = watchEditor 166 | atom.config.observe( 167 | 'find-and-replace.autocompleteSearches', 168 | (value) => @toggleAutocompletions(value)) 169 | new Disposable => 170 | @autocompleteSubscriptions?.dispose() 171 | @autocompleteWatchEditor = null 172 | 173 | provideService: -> 174 | resultsMarkerLayerForTextEditor: @findModel.resultsMarkerLayerForTextEditor.bind(@findModel) 175 | 176 | createViews: -> 177 | return if @findView? 178 | 179 | findBuffer = new TextBuffer 180 | replaceBuffer = new TextBuffer 181 | pathsBuffer = new TextBuffer 182 | 183 | findHistoryCycler = new HistoryCycler(findBuffer, @findHistory) 184 | replaceHistoryCycler = new HistoryCycler(replaceBuffer, @replaceHistory) 185 | pathsHistoryCycler = new HistoryCycler(pathsBuffer, @pathsHistory) 186 | 187 | options = {findBuffer, replaceBuffer, pathsBuffer, findHistoryCycler, replaceHistoryCycler, pathsHistoryCycler} 188 | 189 | @findView = new FindView(@findModel, options) 190 | 191 | @projectFindView = new ProjectFindView(@resultsModel, options) 192 | 193 | @findPanel = atom.workspace.addBottomPanel(item: @findView, visible: false, className: 'tool-panel panel-bottom') 194 | @projectFindPanel = atom.workspace.addBottomPanel(item: @projectFindView, visible: false, className: 'tool-panel panel-bottom') 195 | 196 | @findView.setPanel(@findPanel) 197 | @projectFindView.setPanel(@projectFindPanel) 198 | 199 | # HACK: Soooo, we need to get the model to the pane view whenever it is 200 | # created. Creation could come from the opener below, or, more problematic, 201 | # from a deserialize call when splitting panes. For now, all pane views will 202 | # use this same model. This needs to be improved! I dont know the best way 203 | # to deal with this: 204 | # 1. How should serialization work in the case of a shared model. 205 | # 2. Or maybe we create the model each time a new pane is created? Then 206 | # ProjectFindView needs to know about each model so it can invoke a search. 207 | # And on each new model, it will run the search again. 208 | # 209 | # See https://github.com/atom/find-and-replace/issues/63 210 | #ResultsPaneView.model = @resultsModel 211 | # This makes projectFindView accesible in ResultsPaneView so that resultsModel 212 | # can be properly set for ResultsPaneView instances and ProjectFindView instance 213 | # as different pane views don't necessarily use same models anymore 214 | # but most recent pane view and projectFindView do 215 | ResultsPaneView.projectFindView = @projectFindView 216 | 217 | @toggleAutocompletions atom.config.get('find-and-replace.autocompleteSearches') 218 | 219 | deactivate: -> 220 | @findPanel?.destroy() 221 | @findPanel = null 222 | @findView?.destroy() 223 | @findView = null 224 | @findModel?.destroy() 225 | @findModel = null 226 | 227 | @projectFindPanel?.destroy() 228 | @projectFindPanel = null 229 | @projectFindView?.destroy() 230 | @projectFindView = null 231 | 232 | ResultsPaneView.model = null 233 | 234 | @autocompleteSubscriptions?.dispose() 235 | @autocompleteManagerService = null 236 | @subscriptions?.dispose() 237 | @subscriptions = null 238 | 239 | serialize: -> 240 | findOptions: @findOptions.serialize() 241 | findHistory: @findHistory.serialize() 242 | replaceHistory: @replaceHistory.serialize() 243 | pathsHistory: @pathsHistory.serialize() 244 | -------------------------------------------------------------------------------- /lib/get-icon-services.js: -------------------------------------------------------------------------------- 1 | const DefaultFileIcons = require('./default-file-icons') 2 | const {Emitter, Disposable, CompositeDisposable} = require('atom') 3 | 4 | let iconServices 5 | module.exports = function () { 6 | if (!iconServices) iconServices = new IconServices() 7 | return iconServices 8 | } 9 | 10 | class IconServices { 11 | constructor () { 12 | this.emitter = new Emitter() 13 | this.elementIcons = null 14 | this.elementIconDisposables = new CompositeDisposable() 15 | this.fileIcons = DefaultFileIcons 16 | } 17 | 18 | onDidChange (callback) { 19 | return this.emitter.on('did-change', callback) 20 | } 21 | 22 | resetElementIcons () { 23 | this.setElementIcons(null) 24 | } 25 | 26 | resetFileIcons () { 27 | this.setFileIcons(DefaultFileIcons) 28 | } 29 | 30 | setElementIcons (service) { 31 | if (service !== this.elementIcons) { 32 | if (this.elementIconDisposables != null) { 33 | this.elementIconDisposables.dispose() 34 | } 35 | if (service) { this.elementIconDisposables = new CompositeDisposable() } 36 | this.elementIcons = service 37 | return this.emitter.emit('did-change') 38 | } 39 | } 40 | 41 | setFileIcons (service) { 42 | if (service !== this.fileIcons) { 43 | this.fileIcons = service 44 | return this.emitter.emit('did-change') 45 | } 46 | } 47 | 48 | updateIcon (view, filePath) { 49 | if (this.elementIcons) { 50 | if (view.refs && view.refs.icon instanceof Element) { 51 | if (view.iconDisposable) { 52 | view.iconDisposable.dispose() 53 | this.elementIconDisposables.remove(view.iconDisposable) 54 | } 55 | view.iconDisposable = this.elementIcons(view.refs.icon, filePath) 56 | this.elementIconDisposables.add(view.iconDisposable) 57 | } 58 | } else { 59 | let iconClass = this.fileIcons.iconClassForPath(filePath, 'find-and-replace') || '' 60 | if (Array.isArray(iconClass)) { 61 | iconClass = iconClass.join(' ') 62 | } 63 | view.refs.icon.className = iconClass + ' icon' 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/history.coffee: -------------------------------------------------------------------------------- 1 | _ = require 'underscore-plus' 2 | {Emitter} = require 'atom' 3 | 4 | HISTORY_MAX = 25 5 | 6 | class History 7 | constructor: (@items=[]) -> 8 | @emitter = new Emitter 9 | @length = @items.length 10 | 11 | onDidAddItem: (callback) -> 12 | @emitter.on 'did-add-item', callback 13 | 14 | serialize: -> 15 | @items[-HISTORY_MAX..] 16 | 17 | getLast: -> 18 | _.last(@items) 19 | 20 | getAtIndex: (index) -> 21 | @items[index] 22 | 23 | add: (text) -> 24 | @items.push(text) 25 | @length = @items.length 26 | @emitter.emit 'did-add-item', text 27 | 28 | clear: -> 29 | @items = [] 30 | @length = 0 31 | 32 | # Adds the ability to cycle through history 33 | class HistoryCycler 34 | 35 | # * `buffer` an {Editor} instance to attach the cycler to 36 | # * `history` a {History} object 37 | constructor: (@buffer, @history) -> 38 | @index = @history.length 39 | @history.onDidAddItem (text) => 40 | @buffer.setText(text) if text isnt @buffer.getText() 41 | 42 | addEditorElement: (editorElement) -> 43 | atom.commands.add editorElement, 44 | 'core:move-up': => @previous() 45 | 'core:move-down': => @next() 46 | 47 | previous: -> 48 | if @history.length is 0 or (@atLastItem() and @buffer.getText() isnt @history.getLast()) 49 | @scratch = @buffer.getText() 50 | else if @index > 0 51 | @index-- 52 | 53 | @buffer.setText @history.getAtIndex(@index) ? '' 54 | 55 | next: -> 56 | if @index < @history.length - 1 57 | @index++ 58 | item = @history.getAtIndex(@index) 59 | else if @scratch 60 | item = @scratch 61 | else 62 | item = '' 63 | 64 | @buffer.setText item 65 | 66 | atLastItem: -> 67 | @index is @history.length - 1 68 | 69 | store: -> 70 | text = @buffer.getText() 71 | return if not text or text is @history.getLast() 72 | @scratch = null 73 | @history.add(text) 74 | @index = @history.length - 1 75 | 76 | module.exports = {History, HistoryCycler} 77 | -------------------------------------------------------------------------------- /lib/project-find-view.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-plus'); 2 | const path = require('path'); 3 | const _ = require('underscore-plus'); 4 | const { TextEditor, Disposable, CompositeDisposable } = require('atom'); 5 | const etch = require('etch'); 6 | const Util = require('./project/util'); 7 | const ResultsModel = require('./project/results-model'); 8 | const ResultsPaneView = require('./project/results-pane'); 9 | const $ = etch.dom; 10 | 11 | module.exports = 12 | class ProjectFindView { 13 | constructor(model, {findBuffer, replaceBuffer, pathsBuffer, findHistoryCycler, replaceHistoryCycler, pathsHistoryCycler}) { 14 | this.model = model 15 | this.findBuffer = findBuffer 16 | this.replaceBuffer = replaceBuffer 17 | this.pathsBuffer = pathsBuffer 18 | this.findHistoryCycler = findHistoryCycler; 19 | this.replaceHistoryCycler = replaceHistoryCycler; 20 | this.pathsHistoryCycler = pathsHistoryCycler; 21 | this.subscriptions = new CompositeDisposable() 22 | this.modelSupbscriptions = new CompositeDisposable() 23 | 24 | etch.initialize(this) 25 | 26 | this.handleEvents(); 27 | 28 | this.findHistoryCycler.addEditorElement(this.findEditor.element); 29 | this.replaceHistoryCycler.addEditorElement(this.replaceEditor.element); 30 | this.pathsHistoryCycler.addEditorElement(this.pathsEditor.element); 31 | 32 | this.onlyRunIfChanged = true; 33 | 34 | this.clearMessages(); 35 | this.updateOptionViews(); 36 | this.updateSyntaxHighlighting(); 37 | } 38 | 39 | update() {} 40 | 41 | render() { 42 | return ( 43 | $.div({tabIndex: -1, className: 'project-find padded'}, 44 | $.header({className: 'header'}, 45 | $.span({ref: 'closeButton', className: 'header-item close-button pull-right'}, 46 | $.i({className: "icon icon-x clickable"}) 47 | ), 48 | $.span({ref: 'descriptionLabel', className: 'header-item description'}), 49 | $.span({className: 'header-item options-label pull-right'}, 50 | $.span({}, 'Finding with Options: '), 51 | $.span({ref: 'optionsLabel', className: 'options'}), 52 | $.span({className: 'btn-group btn-toggle btn-group-options'}, 53 | $.button({ref: 'regexOptionButton', className: 'btn option-regex'}, 54 | $.svg({className: "icon", innerHTML: ``}) 55 | ), 56 | $.button({ref: 'caseOptionButton', className: 'btn option-case-sensitive'}, 57 | $.svg({className: "icon", innerHTML: ``}) 58 | ), 59 | $.button({ref: 'wholeWordOptionButton', className: 'btn option-whole-word'}, 60 | $.svg({className: "icon", innerHTML:``}) 61 | ) 62 | ) 63 | ) 64 | ), 65 | 66 | $.section({ref: 'replacmentInfoBlock', className: 'input-block'}, 67 | $.progress({ref: 'replacementProgress', className: 'inline-block'}), 68 | $.span({ref: 'replacmentInfo', className: 'inline-block'}, 'Replaced 2 files of 10 files') 69 | ), 70 | 71 | $.section({className: 'input-block find-container'}, 72 | $.div({className: 'input-block-item input-block-item--flex editor-container'}, 73 | etch.dom(TextEditor, { 74 | ref: 'findEditor', 75 | mini: true, 76 | placeholderText: 'Find in project', 77 | buffer: this.findBuffer 78 | }) 79 | ), 80 | $.div({className: 'input-block-item'}, 81 | $.div({className: 'btn-group btn-group-find'}, 82 | $.button({ref: 'findAllButton', className: 'btn'}, 'Find All') 83 | ) 84 | ) 85 | ), 86 | 87 | $.section({className: 'input-block replace-container'}, 88 | $.div({className: 'input-block-item input-block-item--flex editor-container'}, 89 | etch.dom(TextEditor, { 90 | ref: 'replaceEditor', 91 | mini: true, 92 | placeholderText: 'Replace in project', 93 | buffer: this.replaceBuffer 94 | }) 95 | ), 96 | $.div({className: 'input-block-item'}, 97 | $.div({className: 'btn-group btn-group-replace-all'}, 98 | $.button({ref: 'replaceAllButton', className: 'btn disabled'}, 'Replace All') 99 | ) 100 | ) 101 | ), 102 | 103 | $.section({className: 'input-block paths-container'}, 104 | $.div({className: 'input-block-item editor-container'}, 105 | etch.dom(TextEditor, { 106 | ref: 'pathsEditor', 107 | mini: true, 108 | placeholderText: 'File/directory pattern: For example `src` to search in the "src" directory; `*.js` to search all JavaScript files; `!src` to exclude the "src" directory; `!*.json` to exclude all JSON files', 109 | buffer: this.pathsBuffer 110 | }) 111 | ) 112 | ) 113 | ) 114 | ); 115 | } 116 | 117 | get findEditor() { return this.refs.findEditor } 118 | get replaceEditor() { return this.refs.replaceEditor } 119 | get pathsEditor() { return this.refs.pathsEditor } 120 | 121 | destroy() { 122 | if (this.subscriptions) this.subscriptions.dispose(); 123 | if (this.tooltipSubscriptions) this.tooltipSubscriptions.dispose(); 124 | if (this.modelSupbscriptions) this.modelSupbscriptions.dispose(); 125 | this.model = null; 126 | } 127 | 128 | setPanel(panel) { 129 | this.panel = panel; 130 | this.subscriptions.add(this.panel.onDidChangeVisible(visible => { 131 | if (visible) { 132 | this.didShow(); 133 | } else { 134 | this.didHide(); 135 | } 136 | })); 137 | } 138 | 139 | didShow() { 140 | atom.views.getView(atom.workspace).classList.add('find-visible'); 141 | if (this.tooltipSubscriptions != null) { return; } 142 | 143 | this.updateReplaceAllButtonEnablement(); 144 | this.tooltipSubscriptions = new CompositeDisposable( 145 | atom.tooltips.add(this.refs.closeButton, { 146 | title: 'Close Panel Esc', 147 | html: true 148 | }), 149 | 150 | atom.tooltips.add(this.refs.regexOptionButton, { 151 | title: "Use Regex", 152 | keyBindingCommand: 'project-find:toggle-regex-option', 153 | keyBindingTarget: this.findEditor.element 154 | }), 155 | 156 | atom.tooltips.add(this.refs.caseOptionButton, { 157 | title: "Match Case", 158 | keyBindingCommand: 'project-find:toggle-case-option', 159 | keyBindingTarget: this.findEditor.element 160 | }), 161 | 162 | atom.tooltips.add(this.refs.wholeWordOptionButton, { 163 | title: "Whole Word", 164 | keyBindingCommand: 'project-find:toggle-whole-word-option', 165 | keyBindingTarget: this.findEditor.element 166 | }), 167 | 168 | atom.tooltips.add(this.refs.findAllButton, { 169 | title: "Find All", 170 | keyBindingCommand: 'find-and-replace:search', 171 | keyBindingTarget: this.findEditor.element 172 | }) 173 | ); 174 | } 175 | 176 | didHide() { 177 | this.hideAllTooltips(); 178 | let workspaceElement = atom.views.getView(atom.workspace); 179 | workspaceElement.focus(); 180 | workspaceElement.classList.remove('find-visible'); 181 | } 182 | 183 | hideAllTooltips() { 184 | this.tooltipSubscriptions.dispose(); 185 | this.tooltipSubscriptions = null; 186 | } 187 | 188 | handleEvents() { 189 | this.subscriptions.add(atom.commands.add('atom-workspace', { 190 | 'find-and-replace:use-selection-as-find-pattern': () => this.setSelectionAsFindPattern(), 191 | 'find-and-replace:use-selection-as-replace-pattern': () => this.setSelectionAsReplacePattern() 192 | })); 193 | 194 | this.subscriptions.add(atom.commands.add(this.element, { 195 | 'find-and-replace:focus-next': () => this.focusNextElement(1), 196 | 'find-and-replace:focus-previous': () => this.focusNextElement(-1), 197 | 'core:confirm': () => this.confirm(), 198 | 'core:close': () => this.panel && this.panel.hide(), 199 | 'core:cancel': () => this.panel && this.panel.hide(), 200 | 'project-find:confirm': () => this.confirm(), 201 | 'project-find:toggle-regex-option': () => this.toggleRegexOption(), 202 | 'project-find:toggle-case-option': () => this.toggleCaseOption(), 203 | 'project-find:toggle-whole-word-option': () => this.toggleWholeWordOption(), 204 | 'project-find:replace-all': () => this.replaceAll() 205 | })); 206 | 207 | let updateInterfaceForSearching = () => { 208 | this.setInfoMessage('Searching...'); 209 | }; 210 | 211 | let updateInterfaceForResults = results => { 212 | if (results.matchCount === 0 && results.findPattern === '') { 213 | this.clearMessages(); 214 | } else { 215 | this.generateResultsMessage(results); 216 | } 217 | this.updateReplaceAllButtonEnablement(results); 218 | }; 219 | 220 | const resetInterface = () => { 221 | this.clearMessages(); 222 | this.updateReplaceAllButtonEnablement(null); 223 | }; 224 | this.handleEvents.resetInterface = resetInterface; 225 | 226 | let afterSearch = () => { 227 | if (atom.config.get('find-and-replace.closeFindPanelAfterSearch')) { 228 | this.panel && this.panel.hide(); 229 | } 230 | } 231 | 232 | let searchFinished = results => { 233 | afterSearch(); 234 | updateInterfaceForResults(results); 235 | }; 236 | 237 | const addModelHandlers = () => { 238 | this.modelSupbscriptions.add(this.model.onDidClear(resetInterface)); 239 | this.modelSupbscriptions.add(this.model.onDidClearReplacementState(updateInterfaceForResults)); 240 | this.modelSupbscriptions.add(this.model.onDidStartSearching(updateInterfaceForSearching)); 241 | this.modelSupbscriptions.add(this.model.onDidNoopSearch(afterSearch)); 242 | this.modelSupbscriptions.add(this.model.onDidFinishSearching(searchFinished)); 243 | this.modelSupbscriptions.add(this.model.getFindOptions().onDidChange(this.updateOptionViews.bind(this))); 244 | this.modelSupbscriptions.add(this.model.getFindOptions().onDidChangeUseRegex(this.updateSyntaxHighlighting.bind(this))); 245 | } 246 | this.handleEvents.addModelHandlers=addModelHandlers; 247 | addModelHandlers(); 248 | 249 | this.element.addEventListener('focus', () => this.findEditor.element.focus()); 250 | this.refs.closeButton.addEventListener('click', () => this.panel && this.panel.hide()); 251 | this.refs.regexOptionButton.addEventListener('click', () => this.toggleRegexOption()); 252 | this.refs.caseOptionButton.addEventListener('click', () => this.toggleCaseOption()); 253 | this.refs.wholeWordOptionButton.addEventListener('click', () => this.toggleWholeWordOption()); 254 | this.refs.replaceAllButton.addEventListener('click', () => this.replaceAll()); 255 | this.refs.findAllButton.addEventListener('click', () => this.search()); 256 | 257 | const focusCallback = () => this.onlyRunIfChanged = false; 258 | window.addEventListener('focus', focusCallback); 259 | this.subscriptions.add(new Disposable(() => window.removeEventListener('focus', focusCallback))) 260 | 261 | this.findEditor.getBuffer().onDidChange(() => { 262 | this.updateReplaceAllButtonEnablement(this.model.getResultsSummary()); 263 | }); 264 | this.handleEventsForReplace(); 265 | } 266 | 267 | handleEventsForReplace() { 268 | this.replaceEditor.getBuffer().onDidChange(() => this.model.clearReplacementState()); 269 | this.replaceEditor.onDidStopChanging(() => this.model.getFindOptions().set({replacePattern: this.replaceEditor.getText()})); 270 | this.replacementsMade = 0; 271 | const addReplaceModelHandlers = () => { 272 | this.modelSupbscriptions.add(this.model.onDidStartReplacing(promise => { 273 | this.replacementsMade = 0; 274 | this.refs.replacmentInfoBlock.style.display = ''; 275 | this.refs.replacementProgress.removeAttribute('value'); 276 | })); 277 | 278 | this.modelSupbscriptions.add(this.model.onDidReplacePath(result => { 279 | this.replacementsMade++; 280 | this.refs.replacementProgress.value = this.replacementsMade / this.model.getPathCount(); 281 | this.refs.replacmentInfo.textContent = `Replaced ${this.replacementsMade} of ${_.pluralize(this.model.getPathCount(), 'file')}`; 282 | })); 283 | 284 | this.modelSupbscriptions.add(this.model.onDidFinishReplacing(result => this.onFinishedReplacing(result))); 285 | } 286 | this.handleEventsForReplace.addReplaceModelHandlers=addReplaceModelHandlers; 287 | addReplaceModelHandlers(); 288 | } 289 | 290 | focusNextElement(direction) { 291 | const elements = [ 292 | this.findEditor.element, 293 | this.replaceEditor.element, 294 | this.pathsEditor.element 295 | ]; 296 | 297 | let focusedIndex = elements.findIndex(el => el.hasFocus()) + direction; 298 | if (focusedIndex >= elements.length) focusedIndex = 0; 299 | if (focusedIndex < 0) focusedIndex = elements.length - 1; 300 | 301 | elements[focusedIndex].focus(); 302 | elements[focusedIndex].getModel().selectAll(); 303 | } 304 | 305 | focusFindElement() { 306 | const activeEditor = atom.workspace.getCenter().getActiveTextEditor(); 307 | let selectedText = activeEditor && activeEditor.getSelectedText() 308 | if (selectedText && selectedText.indexOf('\n') < 0) { 309 | if (this.model.getFindOptions().useRegex) { 310 | selectedText = Util.escapeRegex(selectedText); 311 | } 312 | this.findEditor.setText(selectedText); 313 | } 314 | this.findEditor.getElement().focus(); 315 | this.findEditor.selectAll(); 316 | } 317 | 318 | confirm() { 319 | if (this.findEditor.getText().length === 0) { 320 | this.model.clear(); 321 | return; 322 | } 323 | 324 | this.findHistoryCycler.store(); 325 | this.replaceHistoryCycler.store(); 326 | this.pathsHistoryCycler.store(); 327 | 328 | let searchPromise = this.search({onlyRunIfChanged: this.onlyRunIfChanged}); 329 | this.onlyRunIfChanged = true; 330 | return searchPromise; 331 | } 332 | 333 | async search(options) { 334 | // We always want to set the options passed in, even if we dont end up doing the search 335 | if (options == null) { options = {}; } 336 | this.model.getFindOptions().set(options); 337 | 338 | let findPattern = this.findEditor.getText(); 339 | let pathsPattern = this.pathsEditor.getText(); 340 | let replacePattern = this.replaceEditor.getText(); 341 | 342 | let {onlyRunIfActive, onlyRunIfChanged} = options; 343 | if ((onlyRunIfActive && !this.model.active) || !findPattern) return Promise.resolve(); 344 | 345 | await this.showResultPane() 346 | 347 | try { 348 | return await this.model.search(findPattern, pathsPattern, replacePattern, options); 349 | } catch (e) { 350 | this.setErrorMessage(e.message); 351 | } 352 | } 353 | 354 | replaceAll() { 355 | if (!this.model.matchCount) { 356 | atom.beep(); 357 | return; 358 | } 359 | 360 | const findPattern = this.model.getLastFindPattern(); 361 | const currentPattern = this.findEditor.getText(); 362 | if (findPattern && findPattern !== currentPattern) { 363 | atom.confirm({ 364 | message: `The searched pattern '${findPattern}' was changed to '${currentPattern}'`, 365 | detailedMessage: `Please run the search with the new pattern '${currentPattern}' before running a replace-all`, 366 | buttons: ['OK'] 367 | }); 368 | return; 369 | } 370 | 371 | return this.showResultPane().then(() => { 372 | const pathsPattern = this.pathsEditor.getText(); 373 | const replacePattern = this.replaceEditor.getText(); 374 | 375 | const message = `This will replace '${findPattern}' with '${replacePattern}' ${_.pluralize(this.model.matchCount, 'time')} in ${_.pluralize(this.model.pathCount, 'file')}`; 376 | const buttonChosen = atom.confirm({ 377 | message: 'Are you sure you want to replace all?', 378 | detailedMessage: message, 379 | buttons: ['OK', 'Cancel'] 380 | }); 381 | 382 | if (buttonChosen === 0) { 383 | this.clearMessages(); 384 | return this.model.replace(pathsPattern, replacePattern, this.model.getPaths()); 385 | } 386 | }); 387 | } 388 | 389 | directoryPathForElement(element) { 390 | const directoryElement = element.closest('.directory'); 391 | if (directoryElement) { 392 | const pathElement = directoryElement.querySelector('[data-path]') 393 | return pathElement && pathElement.dataset.path; 394 | } else { 395 | const activeEditor = atom.workspace.getCenter().getActiveTextEditor(); 396 | if (activeEditor) { 397 | const editorPath = activeEditor.getPath() 398 | if (editorPath) { 399 | return path.dirname(editorPath); 400 | } 401 | } 402 | } 403 | } 404 | 405 | findInCurrentlySelectedDirectory(selectedElement) { 406 | const absolutePath = this.directoryPathForElement(selectedElement); 407 | if (absolutePath) { 408 | let [rootPath, relativePath] = atom.project.relativizePath(absolutePath); 409 | if (rootPath && atom.project.getDirectories().length > 1) { 410 | relativePath = path.join(path.basename(rootPath), relativePath); 411 | } 412 | this.pathsEditor.setText(relativePath); 413 | this.findEditor.getElement().focus(); 414 | this.findEditor.selectAll(); 415 | } 416 | } 417 | 418 | showResultPane() { 419 | let options = {searchAllPanes: true}; 420 | let openDirection = atom.config.get('find-and-replace.projectSearchResultsPaneSplitDirection'); 421 | if (openDirection !== 'none') { options.split = openDirection; } 422 | return atom.workspace.open(ResultsPaneView.URI, options); 423 | } 424 | 425 | onFinishedReplacing(results) { 426 | if (!results.replacedPathCount) atom.beep(); 427 | this.refs.replacmentInfoBlock.style.display = 'none'; 428 | } 429 | 430 | generateResultsMessage(results) { 431 | let message = Util.getSearchResultsMessage(results); 432 | if (results.replacedPathCount != null) { message = Util.getReplacementResultsMessage(results); } 433 | this.setInfoMessage(message); 434 | } 435 | 436 | clearMessages() { 437 | this.element.classList.remove('has-results', 'has-no-results'); 438 | this.setInfoMessage('Find in Project'); 439 | this.refs.replacmentInfoBlock.style.display = 'none'; 440 | } 441 | 442 | setInfoMessage(infoMessage) { 443 | this.refs.descriptionLabel.innerHTML = infoMessage; 444 | this.refs.descriptionLabel.classList.remove('text-error'); 445 | } 446 | 447 | setErrorMessage(errorMessage) { 448 | this.refs.descriptionLabel.innerHTML = errorMessage; 449 | this.refs.descriptionLabel.classList.add('text-error'); 450 | } 451 | 452 | updateReplaceAllButtonEnablement(results) { 453 | const canReplace = results && 454 | results.matchCount && 455 | results.findPattern == this.findEditor.getText(); 456 | if (canReplace && !this.refs.replaceAllButton.classList.contains('disabled')) return; 457 | 458 | if (this.replaceTooltipSubscriptions) this.replaceTooltipSubscriptions.dispose(); 459 | this.replaceTooltipSubscriptions = new CompositeDisposable; 460 | 461 | if (canReplace) { 462 | this.refs.replaceAllButton.classList.remove('disabled'); 463 | this.replaceTooltipSubscriptions.add(atom.tooltips.add(this.refs.replaceAllButton, { 464 | title: "Replace All", 465 | keyBindingCommand: 'project-find:replace-all', 466 | keyBindingTarget: this.replaceEditor.element 467 | })); 468 | } else { 469 | this.refs.replaceAllButton.classList.add('disabled'); 470 | this.replaceTooltipSubscriptions.add(atom.tooltips.add(this.refs.replaceAllButton, { 471 | title: "Replace All [run a search to enable]"} 472 | )); 473 | } 474 | } 475 | 476 | setSelectionAsFindPattern() { 477 | const editor = atom.workspace.getCenter().getActivePaneItem(); 478 | if (editor && editor.getSelectedText) { 479 | let pattern = editor.getSelectedText() || editor.getWordUnderCursor(); 480 | if (this.model.getFindOptions().useRegex) { 481 | pattern = Util.escapeRegex(pattern); 482 | } 483 | if (pattern) { 484 | this.findEditor.setText(pattern); 485 | this.findEditor.getElement().focus(); 486 | this.findEditor.selectAll(); 487 | } 488 | } 489 | } 490 | 491 | setSelectionAsReplacePattern() { 492 | const editor = atom.workspace.getCenter().getActivePaneItem(); 493 | if (editor && editor.getSelectedText) { 494 | let pattern = editor.getSelectedText() || editor.getWordUnderCursor(); 495 | if (this.model.getFindOptions().useRegex) { 496 | pattern = Util.escapeRegex(pattern); 497 | } 498 | if (pattern) { 499 | this.replaceEditor.setText(pattern); 500 | this.replaceEditor.getElement().focus(); 501 | this.replaceEditor.selectAll(); 502 | } 503 | } 504 | } 505 | 506 | updateOptionViews() { 507 | this.updateOptionButtons(); 508 | this.updateOptionsLabel(); 509 | } 510 | 511 | updateSyntaxHighlighting() { 512 | if (this.model.getFindOptions().useRegex) { 513 | this.findEditor.setGrammar(atom.grammars.grammarForScopeName('source.js.regexp')); 514 | return this.replaceEditor.setGrammar(atom.grammars.grammarForScopeName('source.js.regexp.replacement')); 515 | } else { 516 | this.findEditor.setGrammar(atom.grammars.nullGrammar); 517 | return this.replaceEditor.setGrammar(atom.grammars.nullGrammar); 518 | } 519 | } 520 | 521 | updateOptionsLabel() { 522 | const label = []; 523 | 524 | if (this.model.getFindOptions().useRegex) { 525 | label.push('Regex'); 526 | } 527 | 528 | if (this.model.getFindOptions().caseSensitive) { 529 | label.push('Case Sensitive'); 530 | } else { 531 | label.push('Case Insensitive'); 532 | } 533 | 534 | if (this.model.getFindOptions().wholeWord) { 535 | label.push('Whole Word'); 536 | } 537 | 538 | this.refs.optionsLabel.textContent = label.join(', '); 539 | } 540 | 541 | updateOptionButtons() { 542 | this.setOptionButtonState(this.refs.regexOptionButton, this.model.getFindOptions().useRegex); 543 | this.setOptionButtonState(this.refs.caseOptionButton, this.model.getFindOptions().caseSensitive); 544 | this.setOptionButtonState(this.refs.wholeWordOptionButton, this.model.getFindOptions().wholeWord); 545 | } 546 | 547 | setOptionButtonState(optionButton, selected) { 548 | if (selected) { 549 | optionButton.classList.add('selected'); 550 | } else { 551 | optionButton.classList.remove('selected'); 552 | } 553 | } 554 | 555 | toggleRegexOption() { 556 | this.search({onlyRunIfActive: true, useRegex: !this.model.getFindOptions().useRegex}); 557 | } 558 | 559 | toggleCaseOption() { 560 | this.search({onlyRunIfActive: true, caseSensitive: !this.model.getFindOptions().caseSensitive}); 561 | } 562 | 563 | toggleWholeWordOption() { 564 | this.search({onlyRunIfActive: true, wholeWord: !this.model.getFindOptions().wholeWord}); 565 | } 566 | }; 567 | -------------------------------------------------------------------------------- /lib/project/list-view.js: -------------------------------------------------------------------------------- 1 | const etch = require('etch'); 2 | const $ = etch.dom; 3 | 4 | module.exports = class ListView { 5 | constructor({items, heightForItem, itemComponent, className}) { 6 | this.items = items; 7 | this.heightForItem = heightForItem; 8 | this.itemComponent = itemComponent; 9 | this.className = className; 10 | this.previousScrollTop = 0 11 | this.previousClientHeight = 0 12 | etch.initialize(this); 13 | 14 | const resizeObserver = new ResizeObserver(() => etch.update(this)); 15 | resizeObserver.observe(this.element); 16 | this.element.addEventListener('scroll', () => etch.update(this)); 17 | } 18 | 19 | update({items, heightForItem, itemComponent, className} = {}) { 20 | if (items) this.items = items; 21 | if (heightForItem) this.heightForItem = heightForItem; 22 | if (itemComponent) this.itemComponent = itemComponent; 23 | if (className) this.className = className; 24 | return etch.update(this) 25 | } 26 | 27 | render() { 28 | const children = []; 29 | let itemTopPosition = 0; 30 | 31 | if (this.element) { 32 | let {scrollTop, clientHeight} = this.element; 33 | if (clientHeight > 0) { 34 | this.previousScrollTop = scrollTop 35 | this.previousClientHeight = clientHeight 36 | } else { 37 | scrollTop = this.previousScrollTop 38 | clientHeight = this.previousClientHeight 39 | } 40 | 41 | const scrollBottom = scrollTop + clientHeight; 42 | 43 | let i = 0; 44 | 45 | for (; i < this.items.length; i++) { 46 | let itemBottomPosition = itemTopPosition + this.heightForItem(this.items[i], i); 47 | if (itemBottomPosition > scrollTop) break; 48 | itemTopPosition = itemBottomPosition; 49 | } 50 | 51 | for (; i < this.items.length; i++) { 52 | const item = this.items[i]; 53 | const itemHeight = this.heightForItem(this.items[i], i); 54 | children.push( 55 | $.div( 56 | { 57 | style: { 58 | position: 'absolute', 59 | height: `${itemHeight}px`, 60 | width: '100%', 61 | top: `${itemTopPosition}px` 62 | }, 63 | key: i 64 | }, 65 | etch.dom(this.itemComponent, { 66 | item: item, 67 | top: Math.max(0, scrollTop - itemTopPosition), 68 | bottom: Math.min(itemHeight, scrollBottom - itemTopPosition) 69 | }) 70 | ) 71 | ); 72 | 73 | itemTopPosition += itemHeight; 74 | if (itemTopPosition >= scrollBottom) { 75 | i++ 76 | break; 77 | } 78 | } 79 | for (; i < this.items.length; i++) { 80 | itemTopPosition += this.heightForItem(this.items[i], i); 81 | } 82 | } 83 | 84 | return $.div( 85 | { 86 | className: 'results-view-container', 87 | style: { 88 | position: 'relative', 89 | height: '100%', 90 | overflow: 'auto', 91 | } 92 | }, 93 | $.ol( 94 | { 95 | ref: 'list', 96 | className: this.className, 97 | style: {height: `${itemTopPosition}px`} 98 | }, 99 | ...children 100 | ) 101 | ); 102 | } 103 | }; 104 | -------------------------------------------------------------------------------- /lib/project/result-row-view.js: -------------------------------------------------------------------------------- 1 | const getIconServices = require('../get-icon-services'); 2 | const { Range } = require('atom'); 3 | const { 4 | LeadingContextRow, 5 | TrailingContextRow, 6 | ResultPathRow, 7 | MatchRow, 8 | ResultRowGroup 9 | } = require('./result-row'); 10 | const {showIf} = require('./util'); 11 | 12 | const _ = require('underscore-plus'); 13 | const path = require('path'); 14 | const assert = require('assert'); 15 | const etch = require('etch'); 16 | const $ = etch.dom; 17 | 18 | class ResultPathRowView { 19 | constructor({groupData, isSelected}) { 20 | const props = {groupData, isSelected}; 21 | this.props = Object.assign({}, props); 22 | 23 | etch.initialize(this); 24 | getIconServices().updateIcon(this, groupData.filePath); 25 | } 26 | 27 | destroy() { 28 | return etch.destroy(this) 29 | } 30 | 31 | update({groupData, isSelected}) { 32 | const props = {groupData, isSelected}; 33 | 34 | if (!_.isEqual(props, this.props)) { 35 | this.props = Object.assign({}, props); 36 | etch.update(this); 37 | } 38 | } 39 | 40 | writeAfterUpdate() { 41 | getIconServices().updateIcon(this, this.props.groupData.filePath); 42 | } 43 | 44 | render() { 45 | let relativePath = this.props.groupData.filePath; 46 | if (atom.project) { 47 | let rootPath; 48 | [rootPath, relativePath] = atom.project.relativizePath(this.props.groupData.filePath); 49 | if (rootPath && atom.project.getDirectories().length > 1) { 50 | relativePath = path.join(path.basename(rootPath), relativePath); 51 | } 52 | } 53 | const groupData = this.props.groupData; 54 | return ( 55 | $.li( 56 | { 57 | className: [ 58 | // This triggers the CSS displaying the "expand / collapse" arrows 59 | // See `styles/lists.less` in the atom-ui repository for details 60 | 'list-nested-item', 61 | groupData.isCollapsed ? 'collapsed' : '', 62 | this.props.isSelected ? 'selected' : '' 63 | ].join(' ').trim(), 64 | key: groupData.filePath 65 | }, 66 | $.div( 67 | { 68 | className: 'list-item path-row', 69 | dataset: { filePath: groupData.filePath } 70 | }, 71 | $.span({ 72 | dataset: {name: path.basename(groupData.filePath)}, 73 | ref: 'icon', 74 | className: 'icon' 75 | }), 76 | $.span({className: 'path-name bright'}, relativePath), 77 | $.span( 78 | {ref: 'description', className: 'path-match-number'}, 79 | `(${groupData.matchCount} match${groupData.matchCount === 1 ? '' : 'es'})` 80 | ) 81 | ) 82 | ) 83 | ) 84 | } 85 | }; 86 | 87 | class MatchRowView { 88 | constructor({rowData, groupData, isSelected, replacePattern, regex}) { 89 | const props = {rowData, groupData, isSelected, replacePattern, regex}; 90 | const previewData = {matches: rowData.matches, replacePattern, regex}; 91 | 92 | this.props = Object.assign({}, props); 93 | this.previewData = previewData; 94 | this.previewNode = this.generatePreviewNode(previewData); 95 | 96 | etch.initialize(this); 97 | } 98 | 99 | update({rowData, groupData, isSelected, replacePattern, regex}) { 100 | const props = {rowData, groupData, isSelected, replacePattern, regex}; 101 | const previewData = {matches: rowData.matches, replacePattern, regex}; 102 | 103 | if (!_.isEqual(props, this.props)) { 104 | if (!_.isEqual(previewData, this.previewData)) { 105 | this.previewData = previewData; 106 | this.previewNode = this.generatePreviewNode(previewData); 107 | } 108 | this.props = Object.assign({}, props); 109 | etch.update(this); 110 | } 111 | } 112 | 113 | generatePreviewNode({matches, replacePattern, regex}) { 114 | const subnodes = []; 115 | 116 | let prevMatchEnd = matches[0].lineTextOffset; 117 | for (const match of matches) { 118 | const range = Range.fromObject(match.range); 119 | const prefixStart = Math.max(0, prevMatchEnd - match.lineTextOffset); 120 | const matchStart = range.start.column - match.lineTextOffset; 121 | 122 | // TODO - Handle case where (prevMatchEnd < match.lineTextOffset) 123 | // The solution probably needs Workspace.scan to be reworked to account 124 | // for multiple matches lines first 125 | 126 | const prefix = match.lineText.slice(prefixStart, matchStart); 127 | 128 | let replacementText = '' 129 | if (replacePattern && regex) { 130 | replacementText = match.matchText.replace(regex, replacePattern); 131 | } else if (replacePattern) { 132 | replacementText = replacePattern; 133 | } 134 | 135 | subnodes.push( 136 | $.span({}, prefix), 137 | $.span( 138 | { 139 | className: 140 | `match ${replacementText ? 'highlight-error' : 'highlight-info'}` 141 | }, 142 | match.matchText 143 | ), 144 | $.span( 145 | { 146 | className: 'replacement highlight-success', 147 | style: showIf(replacementText) 148 | }, 149 | replacementText 150 | ) 151 | ); 152 | prevMatchEnd = range.end.column; 153 | } 154 | 155 | const lastMatch = matches[matches.length - 1]; 156 | const suffix = lastMatch.lineText.slice( 157 | prevMatchEnd - lastMatch.lineTextOffset 158 | ); 159 | 160 | return $.span( 161 | {className: 'preview'}, 162 | ...subnodes, 163 | $.span({}, suffix) 164 | ); 165 | } 166 | 167 | render() { 168 | return ( 169 | $.li( 170 | { 171 | className: [ 172 | 'list-item', 173 | 'match-row', 174 | this.props.isSelected ? 'selected' : '', 175 | this.props.rowData.separator ? 'separator' : '' 176 | ].join(' ').trim(), 177 | dataset: { 178 | filePath: this.props.groupData.filePath, 179 | matchLineNumber: this.props.rowData.lineNumber, 180 | } 181 | }, 182 | $.span( 183 | {className: 'line-number text-subtle'}, 184 | this.props.rowData.lineNumber + 1 185 | ), 186 | this.previewNode 187 | ) 188 | ); 189 | } 190 | }; 191 | 192 | class ContextRowView { 193 | constructor({rowData, groupData, isSelected}) { 194 | const props = {rowData, groupData, isSelected}; 195 | this.props = Object.assign({}, props); 196 | 197 | etch.initialize(this); 198 | } 199 | 200 | destroy() { 201 | return etch.destroy(this) 202 | } 203 | 204 | update({rowData, groupData, isSelected}) { 205 | const props = {rowData, groupData, isSelected}; 206 | 207 | if (!_.isEqual(props, this.props)) { 208 | this.props = Object.assign({}, props); 209 | etch.update(this); 210 | } 211 | } 212 | 213 | render() { 214 | return ( 215 | $.li( 216 | { 217 | className: [ 218 | 'list-item', 219 | 'context-row', 220 | this.props.rowData.separator ? 'separator' : '' 221 | ].join(' ').trim(), 222 | dataset: { 223 | filePath: this.props.groupData.filePath, 224 | matchLineNumber: this.props.rowData.matchLineNumber 225 | }, 226 | }, 227 | $.span({className: 'line-number text-subtle'}, this.props.rowData.lineNumber + 1), 228 | $.span({className: 'preview'}, $.span({}, this.props.rowData.line)) 229 | ) 230 | ) 231 | } 232 | } 233 | 234 | function getRowViewType(row) { 235 | if (row instanceof ResultPathRow) { 236 | return ResultPathRowView; 237 | } 238 | if (row instanceof MatchRow) { 239 | return MatchRowView; 240 | } 241 | if (row instanceof LeadingContextRow) { 242 | return ContextRowView; 243 | } 244 | if (row instanceof TrailingContextRow) { 245 | return ContextRowView; 246 | } 247 | assert(false); 248 | } 249 | 250 | module.exports = 251 | class ResultRowView { 252 | constructor({item}) { 253 | const props = { 254 | rowData: Object.assign({}, item.row.data), 255 | groupData: Object.assign({}, item.row.group.data), 256 | isSelected: item.isSelected, 257 | replacePattern: item.replacePattern, 258 | regex: item.regex 259 | }; 260 | this.props = props; 261 | this.rowViewType = getRowViewType(item.row); 262 | 263 | etch.initialize(this); 264 | } 265 | 266 | destroy() { 267 | return etch.destroy(this); 268 | } 269 | 270 | update({item}) { 271 | const props = { 272 | rowData: Object.assign({}, item.row.data), 273 | groupData: Object.assign({}, item.row.group.data), 274 | isSelected: item.isSelected, 275 | replacePattern: item.replacePattern, 276 | regex: item.regex 277 | } 278 | this.props = props; 279 | this.rowViewType = getRowViewType(item.row); 280 | etch.update(this); 281 | } 282 | 283 | render() { 284 | return $(this.rowViewType, this.props); 285 | } 286 | }; 287 | -------------------------------------------------------------------------------- /lib/project/result-row.js: -------------------------------------------------------------------------------- 1 | const {Range} = require('atom'); 2 | 3 | class LeadingContextRow { 4 | constructor(rowGroup, line, separator, matchLineNumber, rowOffset) { 5 | this.group = rowGroup 6 | this.rowOffset = rowOffset 7 | // props 8 | this.data = { 9 | separator, 10 | line, 11 | lineNumber: matchLineNumber - rowOffset, 12 | matchLineNumber, 13 | } 14 | } 15 | } 16 | 17 | class TrailingContextRow { 18 | constructor(rowGroup, line, separator, matchLineNumber, rowOffset) { 19 | this.group = rowGroup 20 | this.rowOffset = rowOffset 21 | // props 22 | this.data = { 23 | separator, 24 | line, 25 | lineNumber: matchLineNumber + rowOffset, 26 | matchLineNumber, 27 | } 28 | } 29 | } 30 | 31 | class ResultPathRow { 32 | constructor(rowGroup) { 33 | this.group = rowGroup 34 | // props 35 | this.data = { 36 | separator: false, 37 | } 38 | } 39 | } 40 | 41 | class MatchRow { 42 | constructor(rowGroup, separator, lineNumber, matches) { 43 | this.group = rowGroup 44 | // props 45 | this.data = { 46 | separator, 47 | lineNumber, 48 | matchLineNumber: lineNumber, 49 | matches, 50 | } 51 | } 52 | } 53 | 54 | class ResultRowGroup { 55 | constructor(result, findOptions) { 56 | this.data = { isCollapsed: false } 57 | this.setResult(result) 58 | 59 | this.rows = [] 60 | this.collapsedRows = [] 61 | this.generateRows(findOptions) 62 | this.previousRowCount = this.rows.length 63 | } 64 | 65 | setResult(result) { 66 | this.result = result 67 | this.data = { 68 | filePath: result.filePath, 69 | matchCount: result.matches.length, 70 | isCollapsed: this.data.isCollapsed, 71 | } 72 | } 73 | 74 | generateRows(findOptions) { 75 | const { leadingContextLineCount, trailingContextLineCount } = findOptions 76 | this.leadingContextLineCount = leadingContextLineCount 77 | this.trailingContextLineCount = trailingContextLineCount 78 | let rowArrays = [ [new ResultPathRow(this)] ] 79 | 80 | // This loop accumulates the match lines and the context lines of the 81 | // result; the added complexity comes from the fact that there musn't be 82 | // context lines between adjacent match lines 83 | let prevMatch = null 84 | let prevMatchRow = null 85 | let prevLineNumber 86 | for (const match of this.result.matches) { 87 | const { leadingContextLines } = match 88 | const lineNumber = Range.fromObject(match.range).start.row 89 | 90 | let leadCount 91 | if (prevMatch) { 92 | const interval = Math.max(lineNumber - prevLineNumber - 1, 0) 93 | 94 | const trailCount = Math.min(trailingContextLineCount, interval) 95 | const { trailingContextLines } = prevMatch 96 | rowArrays.push( 97 | trailingContextLines.slice(0, trailCount).map((line, i) => ( 98 | new TrailingContextRow(this, line, false, prevLineNumber, i + 1) 99 | )) 100 | ) 101 | leadCount = Math.min(leadingContextLineCount, interval - trailCount) 102 | } else { 103 | leadCount = Math.min(leadingContextLineCount, leadingContextLines.length) 104 | } 105 | 106 | rowArrays.push( 107 | leadingContextLines.slice(leadingContextLines.length - leadCount).map((line, i) => ( 108 | new LeadingContextRow(this, line, false, lineNumber, leadCount - i) 109 | )) 110 | ) 111 | 112 | if (prevMatchRow && lineNumber === prevLineNumber) { 113 | prevMatchRow.data.matches.push(match) 114 | } else { 115 | prevMatchRow = new MatchRow(this, false, lineNumber, [match]) 116 | rowArrays.push([ prevMatchRow ]) 117 | } 118 | 119 | prevMatch = match 120 | prevLineNumber = lineNumber 121 | } 122 | 123 | const { trailingContextLines } = prevMatch 124 | rowArrays.push( 125 | trailingContextLines.slice(0, trailingContextLineCount).map((line, i) => ( 126 | new TrailingContextRow(this, line, false, prevLineNumber, i + 1) 127 | )) 128 | ) 129 | this.rows = [].concat(...rowArrays) 130 | this.collapsedRows = [ this.rows[0] ] 131 | 132 | let prevRow = null 133 | for (const row of this.rows) { 134 | row.data.separator = ( 135 | prevRow && 136 | row.data.lineNumber != null && prevRow.data.lineNumber != null && 137 | row.data.lineNumber > prevRow.data.lineNumber + 1 138 | ) ? true : false 139 | prevRow = row 140 | } 141 | } 142 | 143 | displayedRows() { 144 | return this.data.isCollapsed ? this.collapsedRows : this.rows 145 | } 146 | } 147 | 148 | module.exports = { 149 | LeadingContextRow, 150 | TrailingContextRow, 151 | ResultPathRow, 152 | MatchRow, 153 | ResultRowGroup 154 | } 155 | -------------------------------------------------------------------------------- /lib/project/results-model.js: -------------------------------------------------------------------------------- 1 | const _ = require('underscore-plus') 2 | const {Emitter, TextEditor, Range} = require('atom') 3 | const escapeHelper = require('../escape-helper') 4 | 5 | class Result { 6 | static create (result) { 7 | if (result && result.matches && result.matches.length) { 8 | const matches = [] 9 | 10 | for (const m of result.matches) { 11 | const range = Range.fromObject(m.range) 12 | const matchSplit = m.matchText.split('\n') 13 | const linesSplit = m.lineText.split('\n') 14 | 15 | // If the result spans across multiple lines, process each of 16 | // them separately by creating separate `matches` objects for 17 | // each line on the match. 18 | for (let row = range.start.row; row <= range.end.row; row++) { 19 | const lineText = linesSplit[row - range.start.row] 20 | const matchText = matchSplit[row - range.start.row] 21 | 22 | // When receiving multiline results from opened buffers, only 23 | // the first result line is provided on the `lineText` property. 24 | // This makes it impossible to properly render the part of the result 25 | // that's part of other lines. 26 | // In order to prevent an error we just need to ignore these parts. 27 | if (lineText === undefined || matchText === undefined) { 28 | continue 29 | } 30 | 31 | // Adapt the range column number based on which line we're at: 32 | // - the first line of a multiline result will always start at the range start 33 | // and will end at the end of the line. 34 | // - middle lines will start at 0 and end at the end of the line 35 | // - last line will start at 0 and end at the range end. 36 | const startColumn = row === range.start.row ? range.start.column : 0 37 | const endColumn = row === range.end.row ? range.end.column : lineText.length 38 | 39 | matches.push({ 40 | matchText, 41 | lineText, 42 | lineTextOffset: m.lineTextOffset, 43 | range: { 44 | start: { 45 | row, 46 | column: startColumn 47 | }, 48 | end: { 49 | row, 50 | column: endColumn 51 | } 52 | }, 53 | leadingContextLines: m.leadingContextLines, 54 | trailingContextLines: m.trailingContextLines 55 | }) 56 | } 57 | } 58 | 59 | return new Result({filePath: result.filePath, matches}) 60 | } else { 61 | return null 62 | } 63 | } 64 | 65 | constructor (result) { 66 | _.extend(this, result) 67 | } 68 | } 69 | 70 | module.exports = class ResultsModel { 71 | constructor (findOptions, metricsReporter) { 72 | this.metricsReporter = metricsReporter 73 | this.onContentsModified = this.onContentsModified.bind(this) 74 | this.findOptions = findOptions 75 | this.emitter = new Emitter() 76 | 77 | atom.workspace.getCenter().observeActivePaneItem(item => { 78 | if (item instanceof TextEditor && atom.project.contains(item.getPath())) { 79 | item.onDidStopChanging(() => this.onContentsModified(item)) 80 | } 81 | }) 82 | 83 | this.clear() 84 | } 85 | 86 | onDidClear (callback) { 87 | return this.emitter.on('did-clear', callback) 88 | } 89 | 90 | onDidClearSearchState (callback) { 91 | return this.emitter.on('did-clear-search-state', callback) 92 | } 93 | 94 | onDidClearReplacementState (callback) { 95 | return this.emitter.on('did-clear-replacement-state', callback) 96 | } 97 | 98 | onDidSearchPaths (callback) { 99 | return this.emitter.on('did-search-paths', callback) 100 | } 101 | 102 | onDidErrorForPath (callback) { 103 | return this.emitter.on('did-error-for-path', callback) 104 | } 105 | 106 | onDidNoopSearch (callback) { 107 | return this.emitter.on('did-noop-search', callback) 108 | } 109 | 110 | onDidStartSearching (callback) { 111 | return this.emitter.on('did-start-searching', callback) 112 | } 113 | 114 | onDidCancelSearching (callback) { 115 | return this.emitter.on('did-cancel-searching', callback) 116 | } 117 | 118 | onDidFinishSearching (callback) { 119 | return this.emitter.on('did-finish-searching', callback) 120 | } 121 | 122 | onDidStartReplacing (callback) { 123 | return this.emitter.on('did-start-replacing', callback) 124 | } 125 | 126 | onDidFinishReplacing (callback) { 127 | return this.emitter.on('did-finish-replacing', callback) 128 | } 129 | 130 | onDidSearchPath (callback) { 131 | return this.emitter.on('did-search-path', callback) 132 | } 133 | 134 | onDidReplacePath (callback) { 135 | return this.emitter.on('did-replace-path', callback) 136 | } 137 | 138 | onDidAddResult (callback) { 139 | return this.emitter.on('did-add-result', callback) 140 | } 141 | 142 | onDidSetResult (callback) { 143 | return this.emitter.on('did-set-result', callback) 144 | } 145 | 146 | onDidRemoveResult (callback) { 147 | return this.emitter.on('did-remove-result', callback) 148 | } 149 | 150 | clear () { 151 | this.clearSearchState() 152 | this.clearReplacementState() 153 | this.emitter.emit('did-clear', this.getResultsSummary()) 154 | } 155 | 156 | clearSearchState () { 157 | this.pathCount = 0 158 | this.matchCount = 0 159 | this.regex = null 160 | this.results = {} 161 | this.active = false 162 | this.searchErrors = null 163 | 164 | if (this.inProgressSearchPromise != null) { 165 | this.inProgressSearchPromise.cancel() 166 | this.inProgressSearchPromise = null 167 | } 168 | 169 | this.emitter.emit('did-clear-search-state', this.getResultsSummary()) 170 | } 171 | 172 | clearReplacementState () { 173 | this.replacePattern = null 174 | this.replacedPathCount = null 175 | this.replacementCount = null 176 | this.replacementErrors = null 177 | this.emitter.emit('did-clear-replacement-state', this.getResultsSummary()) 178 | } 179 | 180 | shouldRerunSearch (findPattern, pathsPattern, options = {}) { 181 | return ( 182 | !options.onlyRunIfChanged || 183 | findPattern == null || 184 | findPattern !== this.lastFindPattern || 185 | pathsPattern == null || 186 | pathsPattern !== this.lastPathsPattern 187 | ) 188 | } 189 | 190 | async search (findPattern, pathsPattern, replacePattern, options = {}) { 191 | if (!this.shouldRerunSearch(findPattern, pathsPattern, options)) { 192 | this.emitter.emit('did-noop-search') 193 | return Promise.resolve() 194 | } 195 | 196 | const {keepReplacementState} = options 197 | if (keepReplacementState) { 198 | this.clearSearchState() 199 | } else { 200 | this.clear() 201 | } 202 | 203 | this.lastFindPattern = findPattern 204 | this.lastPathsPattern = pathsPattern 205 | this.findOptions.set(_.extend({findPattern, replacePattern, pathsPattern}, options)) 206 | this.regex = this.findOptions.getFindPatternRegex() 207 | 208 | this.active = true 209 | const searchPaths = this.pathsArrayFromPathsPattern(pathsPattern) 210 | 211 | const onPathsSearched = numberOfPathsSearched => { 212 | this.emitter.emit('did-search-paths', numberOfPathsSearched) 213 | } 214 | 215 | const leadingContextLineCount = atom.config.get('find-and-replace.searchContextLineCountBefore') 216 | const trailingContextLineCount = atom.config.get('find-and-replace.searchContextLineCountAfter') 217 | 218 | const startTime = Date.now() 219 | const useRipgrep = atom.config.get('find-and-replace.useRipgrep') 220 | const enablePCRE2 = atom.config.get('find-and-replace.enablePCRE2') 221 | 222 | this.inProgressSearchPromise = atom.workspace.scan( 223 | this.regex, 224 | { 225 | paths: searchPaths, 226 | onPathsSearched, 227 | leadingContextLineCount, 228 | ripgrep: useRipgrep, 229 | PCRE2: enablePCRE2, 230 | trailingContextLineCount 231 | }, 232 | (result, error) => { 233 | if (result) { 234 | this.setResult(result.filePath, Result.create(result)) 235 | } else { 236 | if (this.searchErrors == null) { this.searchErrors = [] } 237 | this.searchErrors.push(error) 238 | this.emitter.emit('did-error-for-path', error) 239 | } 240 | }) 241 | 242 | this.emitter.emit('did-start-searching', this.inProgressSearchPromise) 243 | 244 | const message = await this.inProgressSearchPromise 245 | 246 | if (message === 'cancelled') { 247 | this.emitter.emit('did-cancel-searching') 248 | } else { 249 | const resultsSummary = this.getResultsSummary() 250 | 251 | this.metricsReporter.sendSearchEvent( 252 | Date.now() - startTime, 253 | resultsSummary.matchCount, 254 | useRipgrep ? 'ripgrep' : 'standard' 255 | ) 256 | this.inProgressSearchPromise = null 257 | this.emitter.emit('did-finish-searching', resultsSummary) 258 | } 259 | } 260 | 261 | replace (pathsPattern, replacePattern, replacementPaths) { 262 | if (!this.findOptions.findPattern || (this.regex == null)) { return } 263 | 264 | this.findOptions.set({replacePattern, pathsPattern}) 265 | 266 | if (this.findOptions.useRegex) { replacePattern = escapeHelper.unescapeEscapeSequence(replacePattern) } 267 | 268 | this.active = false // not active until the search is finished 269 | this.replacedPathCount = 0 270 | this.replacementCount = 0 271 | 272 | const promise = atom.workspace.replace(this.regex, replacePattern, replacementPaths, (result, error) => { 273 | if (result) { 274 | if (result.replacements) { 275 | this.replacedPathCount++ 276 | this.replacementCount += result.replacements 277 | } 278 | this.emitter.emit('did-replace-path', result) 279 | } else { 280 | if (this.replacementErrors == null) { this.replacementErrors = [] } 281 | this.replacementErrors.push(error) 282 | this.emitter.emit('did-error-for-path', error) 283 | } 284 | }) 285 | 286 | this.emitter.emit('did-start-replacing', promise) 287 | return promise.then(() => { 288 | this.emitter.emit('did-finish-replacing', this.getResultsSummary()) 289 | return this.search(this.findOptions.findPattern, this.findOptions.pathsPattern, 290 | this.findOptions.replacePattern, {keepReplacementState: true}) 291 | }).catch(e => console.error(e.stack)) 292 | } 293 | 294 | setActive (isActive) { 295 | if ((isActive && this.findOptions.findPattern) || !isActive) { 296 | this.active = isActive 297 | } 298 | } 299 | 300 | getActive () { return this.active } 301 | 302 | getFindOptions () { return this.findOptions } 303 | 304 | getLastFindPattern () { return this.lastFindPattern } 305 | 306 | getResultsSummary () { 307 | const findPattern = this.lastFindPattern != null ? this.lastFindPattern : this.findOptions.findPattern 308 | const { replacePattern } = this.findOptions 309 | return { 310 | findPattern, 311 | replacePattern, 312 | pathCount: this.pathCount, 313 | matchCount: this.matchCount, 314 | searchErrors: this.searchErrors, 315 | replacedPathCount: this.replacedPathCount, 316 | replacementCount: this.replacementCount, 317 | replacementErrors: this.replacementErrors 318 | } 319 | } 320 | 321 | getPathCount () { 322 | return this.pathCount 323 | } 324 | 325 | getMatchCount () { 326 | return this.matchCount 327 | } 328 | 329 | getPaths () { 330 | return Object.keys(this.results) 331 | } 332 | 333 | getResult (filePath) { 334 | return this.results[filePath] 335 | } 336 | 337 | setResult (filePath, result) { 338 | if (result == null) { 339 | return this.removeResult(filePath) 340 | } 341 | if (!this.results[filePath]) { 342 | return this.addResult(filePath, result) 343 | } 344 | 345 | this.matchCount += result.matches.length - this.results[filePath].matches.length 346 | 347 | this.results[filePath] = result 348 | this.emitter.emit('did-set-result', {filePath, result}) 349 | } 350 | 351 | addResult (filePath, result) { 352 | this.pathCount++ 353 | this.matchCount += result.matches.length 354 | 355 | this.results[filePath] = result 356 | this.emitter.emit('did-add-result', {filePath, result}) 357 | } 358 | 359 | removeResult (filePath) { 360 | if (!this.results[filePath]) { 361 | return 362 | } 363 | 364 | this.pathCount-- 365 | this.matchCount -= this.results[filePath].matches.length 366 | 367 | const result = this.results[filePath] 368 | delete this.results[filePath] 369 | this.emitter.emit('did-remove-result', {filePath, result}) 370 | } 371 | 372 | onContentsModified (editor) { 373 | if (!this.active || !this.regex || !editor.getPath()) { return } 374 | 375 | const matches = [] 376 | const leadingContextLineCount = atom.config.get('find-and-replace.searchContextLineCountBefore') 377 | const trailingContextLineCount = atom.config.get('find-and-replace.searchContextLineCountAfter') 378 | editor.scan(this.regex, 379 | {leadingContextLineCount, trailingContextLineCount}, 380 | (match) => matches.push(match) 381 | ) 382 | 383 | const result = Result.create({filePath: editor.getPath(), matches}) 384 | this.setResult(editor.getPath(), result) 385 | this.emitter.emit('did-finish-searching', this.getResultsSummary()) 386 | } 387 | 388 | pathsArrayFromPathsPattern (pathsPattern) { 389 | return pathsPattern.trim().split(',').map((inputPath) => inputPath.trim()) 390 | } 391 | } 392 | 393 | // Exported for tests 394 | module.exports.Result = Result 395 | -------------------------------------------------------------------------------- /lib/project/results-pane.js: -------------------------------------------------------------------------------- 1 | const _ = require('underscore-plus'); 2 | const {CompositeDisposable} = require('atom'); 3 | const ResultsView = require('./results-view'); 4 | const ResultsModel = require('./results-model'); 5 | const {showIf, getSearchResultsMessage, escapeHtml} = require('./util'); 6 | const etch = require('etch'); 7 | const $ = etch.dom; 8 | 9 | 10 | module.exports = 11 | class ResultsPaneView { 12 | constructor() { 13 | this.model = ResultsPaneView.projectFindView.model; 14 | this.model.setActive(true); 15 | this.isLoading = false; 16 | this.searchErrors = []; 17 | this.searchResults = null; 18 | this.searchingIsSlow = false; 19 | this.numberOfPathsSearched = 0; 20 | this.searchContextLineCountBefore = 0; 21 | this.searchContextLineCountAfter = 0; 22 | this.uri = ResultsPaneView.URI; 23 | 24 | etch.initialize(this); 25 | 26 | this.onFinishedSearching(this.model.getResultsSummary()); 27 | this.element.addEventListener('focus', this.focused.bind(this)); 28 | this.element.addEventListener('click', event => { 29 | switch (event.target) { 30 | case this.refs.collapseAll: 31 | this.collapseAllResults(); 32 | break; 33 | case this.refs.expandAll: 34 | this.expandAllResults(); 35 | break; 36 | case this.refs.decrementLeadingContextLines: 37 | this.decrementLeadingContextLines(); 38 | break; 39 | case this.refs.toggleLeadingContextLines: 40 | this.toggleLeadingContextLines(); 41 | break; 42 | case this.refs.incrementLeadingContextLines: 43 | this.incrementLeadingContextLines(); 44 | break; 45 | case this.refs.decrementTrailingContextLines: 46 | this.decrementTrailingContextLines(); 47 | break; 48 | case this.refs.toggleTrailingContextLines: 49 | this.toggleTrailingContextLines(); 50 | break; 51 | case this.refs.incrementTrailingContextLines: 52 | this.incrementTrailingContextLines(); 53 | case this.refs.dontOverrideTab: 54 | this.dontOverrideTab(); 55 | break; 56 | } 57 | }) 58 | 59 | this.subscriptions = new CompositeDisposable( 60 | this.model.onDidStartSearching(this.onSearch.bind(this)), 61 | this.model.onDidFinishSearching(this.onFinishedSearching.bind(this)), 62 | this.model.onDidClear(this.onCleared.bind(this)), 63 | this.model.onDidClearReplacementState(this.onReplacementStateCleared.bind(this)), 64 | this.model.onDidSearchPaths(this.onPathsSearched.bind(this)), 65 | this.model.onDidErrorForPath(error => this.appendError(error.message)), 66 | atom.config.observe('find-and-replace.searchContextLineCountBefore', this.searchContextLineCountChanged.bind(this)), 67 | atom.config.observe('find-and-replace.searchContextLineCountAfter', this.searchContextLineCountChanged.bind(this)) 68 | ); 69 | } 70 | 71 | update() {} 72 | 73 | destroy() { 74 | this.model.setActive(false); 75 | this.subscriptions.dispose(); 76 | if(this.separatePane) 77 | this.model = null; 78 | } 79 | 80 | render() { 81 | const matchCount = this.searchResults && this.searchResults.matchCount; 82 | 83 | return ( 84 | $.div( 85 | { 86 | tabIndex: -1, 87 | className: `preview-pane pane-item ${matchCount === 0 ? 'no-results' : ''}`, 88 | }, 89 | 90 | $.div({className: 'preview-header'}, 91 | $.span({ 92 | ref: 'previewCount', 93 | className: 'preview-count inline-block', 94 | innerHTML: this.isLoading 95 | ? 'Searching...' 96 | : (getSearchResultsMessage(this.searchResults) || 'Project search results') 97 | }), 98 | 99 | $.button( 100 | { 101 | ref: 'dontOverrideTab', 102 | style: {display: matchCount == 0 || this.isLoading ? 'none' : ''}, 103 | className: 'btn' 104 | }, "Don't override this tab"), 105 | 106 | $.div( 107 | { 108 | ref: 'previewControls', 109 | className: 'preview-controls', 110 | style: {display: matchCount > 0 ? '' : 'none'} 111 | }, 112 | 113 | this.searchContextLineCountBefore > 0 ? 114 | $.div({className: 'btn-group'}, 115 | $.button( 116 | { 117 | ref: 'decrementLeadingContextLines', 118 | className: 'btn' + (this.model.getFindOptions().leadingContextLineCount === 0 ? ' disabled' : '') 119 | }, '-'), 120 | $.button( 121 | { 122 | ref: 'toggleLeadingContextLines', 123 | className: 'btn' 124 | }, 125 | $.svg( 126 | { 127 | className: 'icon', 128 | innerHTML: '' 129 | } 130 | ) 131 | ), 132 | $.button( 133 | { 134 | ref: 'incrementLeadingContextLines', 135 | className: 'btn' + (this.model.getFindOptions().leadingContextLineCount >= this.searchContextLineCountBefore ? ' disabled' : '') 136 | }, '+') 137 | ) : null, 138 | 139 | this.searchContextLineCountAfter > 0 ? 140 | $.div({className: 'btn-group'}, 141 | $.button( 142 | { 143 | ref: 'decrementTrailingContextLines', 144 | className: 'btn' + (this.model.getFindOptions().trailingContextLineCount === 0 ? ' disabled' : '') 145 | }, '-'), 146 | $.button( 147 | { 148 | ref: 'toggleTrailingContextLines', 149 | className: 'btn' 150 | }, 151 | $.svg( 152 | { 153 | className: 'icon', 154 | innerHTML: '' 155 | } 156 | ) 157 | ), 158 | $.button( 159 | { 160 | ref: 'incrementTrailingContextLines', 161 | className: 'btn' + (this.model.getFindOptions().trailingContextLineCount >= this.searchContextLineCountAfter ? ' disabled' : '') 162 | }, '+') 163 | ) : null, 164 | 165 | $.div({className: 'btn-group'}, 166 | $.button({ref: 'collapseAll', className: 'btn'}, 'Collapse All'), 167 | $.button({ref: 'expandAll', className: 'btn'}, 'Expand All') 168 | ) 169 | ), 170 | 171 | $.div({className: 'inline-block', style: showIf(this.isLoading)}, 172 | $.div({className: 'loading loading-spinner-tiny inline-block'}), 173 | 174 | $.div( 175 | { 176 | className: 'inline-block', 177 | style: showIf(this.isLoading && this.searchingIsSlow) 178 | }, 179 | 180 | $.span({ref: 'searchedCount', className: 'searched-count'}, 181 | this.numberOfPathsSearched.toString() 182 | ), 183 | $.span({}, ' paths searched') 184 | ) 185 | ) 186 | ), 187 | 188 | $.ul( 189 | { 190 | ref: 'errorList', 191 | className: 'error-list list-group padded', 192 | style: showIf(this.searchErrors.length > 0) 193 | }, 194 | 195 | ...this.searchErrors.map(message => 196 | $.li({className: 'text-error'}, escapeHtml(message)) 197 | ) 198 | ), 199 | 200 | etch.dom(ResultsView, {ref: 'resultsView', model: this.model}), 201 | 202 | $.ul( 203 | { 204 | className: 'centered background-message no-results-overlay', 205 | style: showIf(matchCount === 0) 206 | }, 207 | $.li({}, 'No Results') 208 | ) 209 | ) 210 | ); 211 | } 212 | 213 | copy() { 214 | return new ResultsPaneView(); 215 | } 216 | 217 | getTitle() { 218 | return 'Project Find Results'; 219 | } 220 | 221 | getIconName() { 222 | return 'search'; 223 | } 224 | 225 | getURI() { 226 | return this.uri; 227 | } 228 | 229 | focused() { 230 | this.refs.resultsView.element.focus(); 231 | } 232 | 233 | appendError(message) { 234 | this.searchErrors.push(message) 235 | etch.update(this); 236 | } 237 | 238 | onSearch(searchPromise) { 239 | this.isLoading = true; 240 | this.searchingIsSlow = false; 241 | this.numberOfPathsSearched = 0; 242 | 243 | setTimeout(() => { 244 | this.searchingIsSlow = true; 245 | etch.update(this); 246 | }, 500); 247 | 248 | etch.update(this); 249 | 250 | let stopLoading = () => { 251 | this.isLoading = false; 252 | etch.update(this); 253 | }; 254 | return searchPromise.then(stopLoading, stopLoading); 255 | } 256 | 257 | onPathsSearched(numberOfPathsSearched) { 258 | this.numberOfPathsSearched = numberOfPathsSearched; 259 | etch.update(this); 260 | } 261 | 262 | onFinishedSearching(results) { 263 | this.searchResults = results; 264 | if (results.searchErrors || results.replacementErrors) { 265 | this.searchErrors = 266 | _.pluck(results.replacementErrors, 'message') 267 | .concat(_.pluck(results.searchErrors, 'message')); 268 | } else { 269 | this.searchErrors = []; 270 | } 271 | etch.update(this); 272 | } 273 | 274 | onReplacementStateCleared(results) { 275 | this.searchResults = results; 276 | this.searchErrors = []; 277 | etch.update(this); 278 | } 279 | 280 | onCleared() { 281 | this.isLoading = false; 282 | this.searchErrors = []; 283 | this.searchResults = {}; 284 | this.searchingIsSlow = false; 285 | this.numberOfPathsSearched = 0; 286 | etch.update(this); 287 | } 288 | 289 | collapseAllResults() { 290 | this.refs.resultsView.collapseAllResults(); 291 | this.refs.resultsView.element.focus(); 292 | } 293 | 294 | expandAllResults() { 295 | this.refs.resultsView.expandAllResults(); 296 | this.refs.resultsView.element.focus(); 297 | } 298 | 299 | decrementLeadingContextLines() { 300 | this.refs.resultsView.decrementLeadingContextLines(); 301 | etch.update(this); 302 | } 303 | 304 | toggleLeadingContextLines() { 305 | this.refs.resultsView.toggleLeadingContextLines(); 306 | etch.update(this); 307 | } 308 | 309 | incrementLeadingContextLines() { 310 | this.refs.resultsView.incrementLeadingContextLines(); 311 | etch.update(this); 312 | } 313 | 314 | decrementTrailingContextLines() { 315 | this.refs.resultsView.decrementTrailingContextLines(); 316 | etch.update(this); 317 | } 318 | 319 | toggleTrailingContextLines() { 320 | this.refs.resultsView.toggleTrailingContextLines(); 321 | etch.update(this); 322 | } 323 | 324 | incrementTrailingContextLines() { 325 | this.refs.resultsView.incrementTrailingContextLines(); 326 | etch.update(this); 327 | } 328 | 329 | searchContextLineCountChanged() { 330 | this.searchContextLineCountBefore = atom.config.get('find-and-replace.searchContextLineCountBefore'); 331 | this.searchContextLineCountAfter = atom.config.get('find-and-replace.searchContextLineCountAfter'); 332 | // update the visible line count in the find options to not exceed the maximum available lines 333 | let findOptionsChanged = false; 334 | if (this.searchContextLineCountBefore < this.model.getFindOptions().leadingContextLineCount) { 335 | this.model.getFindOptions().leadingContextLineCount = this.searchContextLineCountBefore; 336 | findOptionsChanged = true; 337 | } 338 | if (this.searchContextLineCountAfter < this.model.getFindOptions().trailingContextLineCount) { 339 | this.model.getFindOptions().trailingContextLineCount = this.searchContextLineCountAfter; 340 | findOptionsChanged = true; 341 | } 342 | etch.update(this); 343 | if (findOptionsChanged) { 344 | etch.update(this.refs.resultsView); 345 | } 346 | } 347 | 348 | dontOverrideTab(){ 349 | let view = ResultsPaneView.projectFindView; 350 | view.handleEvents.resetInterface(); 351 | view.model = new ResultsModel(view.model.findOptions,view.model.metricsReporter); 352 | this.uri = ResultsPaneView.URI + "/" + this.model.getLastFindPattern(); 353 | this.refs.dontOverrideTab.classList.add('disabled'); 354 | 355 | view.modelSupbscriptions.dispose(); 356 | view.handleEvents.addModelHandlers(); 357 | view.handleEventsForReplace.addReplaceModelHandlers(); 358 | this.separatePane=true; 359 | } 360 | } 361 | 362 | module.exports.URI = "atom://find-and-replace/project-results"; 363 | -------------------------------------------------------------------------------- /lib/project/results-view.js: -------------------------------------------------------------------------------- 1 | const { Range, CompositeDisposable, Disposable } = require('atom'); 2 | const ResultRowView = require('./result-row-view'); 3 | const { 4 | LeadingContextRow, 5 | TrailingContextRow, 6 | ResultPathRow, 7 | MatchRow, 8 | ResultRowGroup 9 | } = require('./result-row'); 10 | 11 | const ListView = require('./list-view'); 12 | const etch = require('etch'); 13 | const binarySearch = require('binary-search') 14 | 15 | const path = require('path'); 16 | const _ = require('underscore-plus'); 17 | const $ = etch.dom; 18 | 19 | const reverseDirections = { 20 | left: 'right', 21 | right: 'left', 22 | up: 'down', 23 | down: 'up' 24 | }; 25 | 26 | const filepathComp = (path1, path2) => path1.localeCompare(path2) 27 | 28 | module.exports = 29 | class ResultsView { 30 | constructor({model}) { 31 | this.model = model; 32 | this.pixelOverdraw = 100; 33 | 34 | this.resultRowGroups = Object.values(model.results).map(result => 35 | new ResultRowGroup(result, this.model.getFindOptions()) 36 | ) 37 | this.resultRowGroups.sort((group1, group2) => filepathComp( 38 | group1.result.filePath, group2.result.filePath 39 | )) 40 | this.rowGroupLengths = this.resultRowGroups.map(group => group.rows.length) 41 | 42 | this.resultRows = [].concat(...this.resultRowGroups.map(group => group.rows)) 43 | this.selectedRowIndex = this.resultRows.length ? 0 : -1 44 | 45 | this.fakeGroup = new ResultRowGroup({ 46 | filePath: 'fake-file-path', 47 | matches: [{ 48 | range: [[0, 1], [0, 2]], 49 | leadingContextLines: ['test-line-before'], 50 | trailingContextLines: ['test-line-after'], 51 | lineTextOffset: 1, 52 | lineText: 'fake-line-text', 53 | matchText: 'fake-match-text', 54 | }], 55 | }, 56 | { 57 | leadingContextLineCount: 1, 58 | trailingContextLineCount: 0 59 | }) 60 | 61 | etch.initialize(this); 62 | 63 | const resizeObserver = new ResizeObserver(this.invalidateItemHeights.bind(this)); 64 | resizeObserver.observe(this.element); 65 | this.element.addEventListener('mousedown', this.handleClick.bind(this)); 66 | 67 | this.subscriptions = new CompositeDisposable( 68 | atom.config.observe('editor.fontFamily', this.fontFamilyChanged.bind(this)), 69 | this.model.onDidAddResult(this.didAddResult.bind(this)), 70 | this.model.onDidSetResult(this.didSetResult.bind(this)), 71 | this.model.onDidRemoveResult(this.didRemoveResult.bind(this)), 72 | this.model.onDidClearSearchState(this.didClearSearchState.bind(this)), 73 | this.model.getFindOptions().onDidChangeReplacePattern(() => etch.update(this)), 74 | 75 | atom.commands.add(this.element, { 76 | 'core:move-up': this.moveUp.bind(this), 77 | 'core:move-down': this.moveDown.bind(this), 78 | 'core:move-left': this.collapseResult.bind(this), 79 | 'core:move-right': this.expandResult.bind(this), 80 | 'core:page-up': this.pageUp.bind(this), 81 | 'core:page-down': this.pageDown.bind(this), 82 | 'core:move-to-top': this.moveToTop.bind(this), 83 | 'core:move-to-bottom': this.moveToBottom.bind(this), 84 | 'core:confirm': this.confirmResult.bind(this), 85 | 'core:copy': this.copyResult.bind(this), 86 | 'find-and-replace:copy-path': this.copyPath.bind(this), 87 | 'find-and-replace:open-in-new-tab': this.openInNewTab.bind(this), 88 | }) 89 | ); 90 | } 91 | 92 | update() {} 93 | 94 | destroy() { 95 | this.subscriptions.dispose(); 96 | } 97 | 98 | getRowHeight(resultRow) { 99 | if (resultRow instanceof LeadingContextRow) { 100 | return this.contextRowHeight 101 | } else if (resultRow instanceof TrailingContextRow) { 102 | return this.contextRowHeight 103 | } else if (resultRow instanceof ResultPathRow) { 104 | return this.pathRowHeight 105 | } else if (resultRow instanceof MatchRow) { 106 | return this.matchRowHeight 107 | } 108 | } 109 | 110 | render () { 111 | this.maintainPreviousScrollPosition(); 112 | 113 | let regex = null, replacePattern = null; 114 | if (this.model.replacedPathCount == null) { 115 | regex = this.model.regex; 116 | replacePattern = this.model.getFindOptions().replacePattern; 117 | } 118 | 119 | return $.div( 120 | { 121 | className: 'results-view focusable-panel', 122 | tabIndex: '-1', 123 | style: this.previewStyle 124 | }, 125 | 126 | $.ol( 127 | { 128 | className: 'list-tree has-collapsable-children', 129 | style: {visibility: 'hidden', position: 'absolute', overflow: 'hidden', left: 0, top: 0, right: 0} 130 | }, 131 | $(ResultRowView, { 132 | ref: 'dummyResultPathRowView', 133 | item: { 134 | row: this.fakeGroup.rows[0], 135 | regex, replacePattern 136 | } 137 | }), 138 | $(ResultRowView, { 139 | ref: 'dummyContextRowView', 140 | item: { 141 | row: this.fakeGroup.rows[1], 142 | regex, replacePattern 143 | } 144 | }), 145 | $(ResultRowView, { 146 | ref: 'dummyMatchRowView', 147 | item: { 148 | row: this.fakeGroup.rows[2], 149 | regex, replacePattern 150 | } 151 | }) 152 | ), 153 | 154 | $(ListView, { 155 | ref: 'listView', 156 | className: 'list-tree has-collapsable-children', 157 | itemComponent: ResultRowView, 158 | heightForItem: item => this.getRowHeight(item.row), 159 | items: this.resultRows.map((row, i) => ({ 160 | row, 161 | isSelected: i === this.selectedRowIndex, 162 | regex, 163 | replacePattern 164 | })) 165 | }) 166 | ); 167 | } 168 | 169 | async invalidateItemHeights() { 170 | const { 171 | dummyResultPathRowView, 172 | dummyMatchRowView, 173 | dummyContextRowView, 174 | } = this.refs; 175 | 176 | const pathRowHeight = dummyResultPathRowView.element.offsetHeight 177 | const matchRowHeight = dummyMatchRowView.element.offsetHeight 178 | const contextRowHeight = dummyContextRowView.element.offsetHeight 179 | 180 | const clientHeight = this.refs.listView && this.refs.listView.element.clientHeight; 181 | 182 | if (matchRowHeight !== this.matchRowHeight || 183 | pathRowHeight !== this.pathRowHeight || 184 | contextRowHeight !== this.contextRowHeight || 185 | clientHeight !== this.clientHeight) { 186 | this.matchRowHeight = matchRowHeight; 187 | this.pathRowHeight = pathRowHeight; 188 | this.contextRowHeight = contextRowHeight; 189 | this.clientHeight = clientHeight; 190 | await etch.update(this); 191 | } 192 | 193 | etch.update(this); 194 | } 195 | 196 | // This method should be the only one allowed to modify this.resultRows 197 | spliceRows(start, deleteCount, rows) { 198 | this.resultRows.splice(start, deleteCount, ...rows) 199 | 200 | if (this.selectedRowIndex >= start + deleteCount) { 201 | this.selectedRowIndex += rows.length - deleteCount 202 | this.scrollToSelectedMatch() 203 | } else if (this.selectedRowIndex >= start + rows.length) { 204 | this.selectRow(start + rows.length - 1) 205 | } 206 | } 207 | 208 | invalidateRowGroup(firstRowIndex, groupIndex) { 209 | const { leadingContextLineCount, trailingContextLineCount } = this.model.getFindOptions() 210 | const rowGroup = this.resultRowGroups[groupIndex] 211 | 212 | if (!rowGroup.data.isCollapsed) { 213 | rowGroup.generateRows(this.model.getFindOptions()) 214 | } 215 | this.spliceRows( 216 | firstRowIndex, this.rowGroupLengths[groupIndex], 217 | rowGroup.displayedRows() 218 | ) 219 | this.rowGroupLengths[groupIndex] = rowGroup.displayedRows().length 220 | } 221 | 222 | getGroupCountBefore(filePath) { 223 | const res = binarySearch( 224 | this.resultRowGroups, filePath, 225 | (rowGroup, needle) => filepathComp(rowGroup.result.filePath, needle) 226 | ) 227 | return res < 0 ? -res - 1 : res 228 | } 229 | 230 | getRowCountBefore(groupIndex) { 231 | let rowCount = 0 232 | for (let i = 0; i < groupIndex; ++i) { 233 | rowCount += this.resultRowGroups[i].displayedRows().length 234 | } 235 | return rowCount 236 | } 237 | 238 | // These four methods are the only ones allowed to modify this.resultRowGroups 239 | didAddResult({result, filePath}) { 240 | const groupIndex = this.getGroupCountBefore(filePath) 241 | const rowGroup = new ResultRowGroup(result, this.model.getFindOptions()) 242 | 243 | this.resultRowGroups.splice(groupIndex, 0, rowGroup) 244 | this.rowGroupLengths.splice(groupIndex, 0, rowGroup.rows.length) 245 | 246 | const rowIndex = this.getRowCountBefore(groupIndex) 247 | this.spliceRows(rowIndex, 0, rowGroup.displayedRows()) 248 | 249 | if (this.selectedRowIndex === -1) { 250 | this.selectRow(0) 251 | } 252 | 253 | etch.update(this); 254 | } 255 | 256 | didSetResult({result, filePath}) { 257 | const groupIndex = this.getGroupCountBefore(filePath) 258 | const rowGroup = this.resultRowGroups[groupIndex] 259 | const rowIndex = this.getRowCountBefore(groupIndex) 260 | 261 | rowGroup.result = result 262 | this.invalidateRowGroup(rowIndex, groupIndex) 263 | 264 | etch.update(this); 265 | } 266 | 267 | didRemoveResult({filePath}) { 268 | const groupIndex = this.getGroupCountBefore(filePath) 269 | const rowGroup = this.resultRowGroups[groupIndex] 270 | const rowIndex = this.getRowCountBefore(groupIndex) 271 | 272 | this.spliceRows(rowIndex, rowGroup.displayedRows().length, []) 273 | this.resultRowGroups.splice(groupIndex, 1) 274 | this.rowGroupLengths.splice(groupIndex, 1) 275 | 276 | etch.update(this); 277 | } 278 | 279 | didClearSearchState() { 280 | this.selectedRowIndex = -1 281 | this.resultRowGroups = [] 282 | this.resultRows = [] 283 | etch.update(this); 284 | } 285 | 286 | handleClick(event) { 287 | const clickedItem = event.target.closest('.list-item'); 288 | 289 | if (!clickedItem) return; 290 | 291 | const groupIndex = this.getGroupCountBefore(clickedItem.dataset.filePath) 292 | const group = this.resultRowGroups[groupIndex] 293 | 294 | if (clickedItem.matches('.context-row, .match-row')) { 295 | // The third argument restricts the range to omit the path row 296 | const rowIndex = binarySearch( 297 | group.rows, clickedItem.dataset.matchLineNumber, 298 | ((row, lineNb) => row.data.lineNumber - lineNb), 299 | 1 300 | ) 301 | this.selectRow(this.getRowCountBefore(groupIndex) + rowIndex) 302 | } else { 303 | // If the user clicks on the left of a match, the match group is collapsed 304 | this.selectRow(this.getRowCountBefore(groupIndex)) 305 | } 306 | 307 | // Only apply confirmResult (open editor, collapse group) on left click 308 | if (!event.ctrlKey && event.button === 0 && event.which !== 3) { 309 | this.confirmResult({pending: event.detail === 1}); 310 | event.preventDefault(); 311 | } 312 | etch.update(this); 313 | } 314 | 315 | // This method should be the only one allowed to modify this.selectedRowIndex 316 | selectRow(i) { 317 | if (this.resultRows.length === 0) { 318 | this.selectedRowIndex = -1 319 | return etch.update(this) 320 | } 321 | 322 | if (i < 0) { 323 | this.selectedRowIndex = 0 324 | } else if (i >= this.resultRows.length) { 325 | this.selectedRowIndex = this.resultRows.length - 1 326 | } else { 327 | this.selectedRowIndex = i 328 | } 329 | 330 | const resultRow = this.resultRows[this.selectedRowIndex] 331 | 332 | if (resultRow instanceof LeadingContextRow) { 333 | this.selectedRowIndex += resultRow.rowOffset 334 | } else if (resultRow instanceof TrailingContextRow) { 335 | this.selectedRowIndex -= resultRow.rowOffset 336 | } 337 | 338 | if (i >= this.resultRows.length) { 339 | this.scrollToBottom() 340 | } else { 341 | this.scrollToSelectedMatch() 342 | } 343 | 344 | return etch.update(this) 345 | } 346 | 347 | selectFirstResult() { 348 | return this.selectRow(0) 349 | } 350 | 351 | moveToTop() { 352 | return this.selectRow(0) 353 | } 354 | 355 | moveToBottom() { 356 | return this.selectRow(this.resultRows.length) 357 | } 358 | 359 | pageUp() { 360 | if (this.refs.listView) { 361 | const {clientHeight} = this.refs.listView.element 362 | const position = this.positionOfSelectedResult() 363 | return this.selectResultAtPosition(position - clientHeight) 364 | } 365 | } 366 | 367 | pageDown() { 368 | if (this.refs.listView) { 369 | const {clientHeight} = this.refs.listView.element 370 | const position = this.positionOfSelectedResult() 371 | return this.selectResultAtPosition(position + clientHeight) 372 | } 373 | } 374 | 375 | positionOfSelectedResult() { 376 | let y = 0; 377 | 378 | for (let i = 0; i < this.selectedRowIndex; i++) { 379 | y += this.getRowHeight(this.resultRows[i]) 380 | } 381 | return y 382 | } 383 | 384 | selectResultAtPosition(position) { 385 | if (this.refs.listView && this.model.getPathCount() > 0) { 386 | const {clientHeight} = this.refs.listView.element 387 | 388 | let top = 0 389 | for (let i = 0; i < this.resultRows.length; i++) { 390 | const bottom = top + this.getRowHeight(this.resultRows[i]) 391 | if (bottom > position) { 392 | return this.selectRow(i) 393 | } 394 | top = bottom 395 | } 396 | } 397 | return this.selectRow(this.resultRows.length) 398 | } 399 | 400 | moveDown() { 401 | if (this.selectedRowIndex === -1) { 402 | return this.selectRow(0) 403 | } 404 | 405 | for (let i = this.selectedRowIndex + 1; i < this.resultRows.length; i++) { 406 | const row = this.resultRows[i] 407 | 408 | if (row instanceof ResultPathRow || row instanceof MatchRow) { 409 | return this.selectRow(i) 410 | } 411 | } 412 | return this.selectRow(this.resultRows.length) 413 | } 414 | 415 | moveUp() { 416 | if (this.selectedRowIndex === -1) { 417 | return this.selectRow(0) 418 | } 419 | 420 | for (let i = this.selectedRowIndex - 1; i >= 0; i--) { 421 | const row = this.resultRows[i] 422 | 423 | if (row instanceof ResultPathRow || row instanceof MatchRow) { 424 | return this.selectRow(i) 425 | } 426 | } 427 | return this.selectRow(0) 428 | } 429 | 430 | selectedRow() { 431 | return this.resultRows[this.selectedRowIndex] 432 | } 433 | 434 | expandResult() { 435 | if (this.selectedRowIndex === -1) { 436 | return 437 | } 438 | 439 | const rowGroup = this.selectedRow().group 440 | const groupIndex = this.resultRowGroups.indexOf(rowGroup) 441 | const rowIndex = this.getRowCountBefore(groupIndex) 442 | 443 | if (!rowGroup.data.isCollapsed) { 444 | if (this.selectedRowIndex === rowIndex) { 445 | this.selectRow(rowIndex + 1) 446 | } 447 | return 448 | } 449 | 450 | rowGroup.data.isCollapsed = false 451 | this.invalidateRowGroup(rowIndex, groupIndex) 452 | this.selectRow(rowIndex + 1) 453 | return etch.update(this); 454 | } 455 | 456 | collapseResult() { 457 | if (this.selectedRowIndex === -1) { 458 | return 459 | } 460 | 461 | const rowGroup = this.selectedRow().group 462 | 463 | if (rowGroup.data.isCollapsed) { 464 | return 465 | } 466 | 467 | const groupIndex = this.resultRowGroups.indexOf(rowGroup) 468 | const rowIndex = this.getRowCountBefore(groupIndex) 469 | 470 | rowGroup.data.isCollapsed = true 471 | this.invalidateRowGroup(rowIndex, groupIndex) 472 | return etch.update(this); 473 | } 474 | 475 | // This is the method called when clicking a result or pressing enter 476 | async confirmResult({pending} = {}) { 477 | if (this.selectedRowIndex === -1) { 478 | return 479 | } 480 | 481 | const selectedRow = this.selectedRow() 482 | 483 | if (selectedRow instanceof MatchRow) { 484 | this.currentScrollTop = this.getScrollTop(); 485 | const match = selectedRow.data.matches[0] 486 | const editor = await atom.workspace.open(selectedRow.group.result.filePath, { 487 | pending, 488 | searchAllPanes: true, 489 | split: reverseDirections[atom.config.get('find-and-replace.projectSearchResultsPaneSplitDirection')] 490 | }) 491 | editor.unfoldBufferRow(match.range.start.selectedRow) 492 | editor.setSelectedBufferRange(match.range, {flash: true}) 493 | editor.scrollToCursorPosition() 494 | } else if (selectedRow.group.data.isCollapsed) { 495 | this.expandResult() 496 | } else { 497 | this.collapseResult() 498 | } 499 | } 500 | 501 | copyResult() { 502 | if (this.selectedRowIndex === -1) { 503 | return 504 | } 505 | 506 | const selectedRow = this.selectedRow() 507 | if (selectedRow.data.matches) { 508 | // TODO - If row has multiple matches, copy them all, using the same 509 | // algorithm as `Selection.copy`; ideally, that algorithm should be 510 | // isolated for D.R.Y. purposes 511 | atom.clipboard.write(selectedRow.data.matches[0].lineText); 512 | } 513 | } 514 | 515 | copyPath() { 516 | if (this.selectedRowIndex === -1) { 517 | return 518 | } 519 | 520 | const {filePath} = this.selectedRow().group.result 521 | let [projectPath, relativePath] = atom.project.relativizePath(filePath); 522 | if (projectPath && atom.project.getDirectories().length > 1) { 523 | relativePath = path.join(path.basename(projectPath), relativePath); 524 | } 525 | atom.clipboard.write(relativePath); 526 | } 527 | 528 | async openInNewTab() { 529 | if (this.selectedRowIndex !== -1) { 530 | const result = this.selectedRow(); 531 | 532 | if (result) { 533 | let editor; 534 | const editors = atom.workspace.getTextEditors(); 535 | const filepath = result.group.result.filePath; 536 | const exists = _.some(editors, (editor) => editor.getPath() == filepath); 537 | 538 | if (!exists) { 539 | editor = await atom.workspace.open(filepath, { 540 | activatePane: false, 541 | activateItem: false 542 | }); 543 | } 544 | else { 545 | editor = await atom.workspace.open(filepath); 546 | } 547 | if (result.data.matches) { 548 | const match = result.data.matches[0]; 549 | 550 | if (match && editor) { 551 | editor.unfoldBufferRow(match.range.start.selectedRow); 552 | editor.setSelectedBufferRange(match.range, {flash: true}); 553 | editor.scrollToCursorPosition(); 554 | } 555 | } 556 | } 557 | } 558 | } 559 | 560 | expandAllResults() { 561 | let rowIndex = 0 562 | // Since the whole array is re-generated, this makes splices cheaper 563 | this.resultRows = [] 564 | for (let i = 0; i < this.resultRowGroups.length; i++) { 565 | const group = this.resultRowGroups[i] 566 | 567 | group.data.isCollapsed = false 568 | this.invalidateRowGroup(rowIndex, i) 569 | rowIndex += group.displayedRows().length 570 | } 571 | this.scrollToSelectedMatch(); 572 | return etch.update(this); 573 | } 574 | 575 | collapseAllResults() { 576 | let rowIndex = 0 577 | // Since the whole array is re-generated, this makes splices cheaper 578 | this.resultRows = [] 579 | for (let i = 0; i < this.resultRowGroups.length; i++) { 580 | const group = this.resultRowGroups[i] 581 | 582 | group.data.isCollapsed = true 583 | this.invalidateRowGroup(rowIndex, i) 584 | rowIndex += group.displayedRows().length 585 | } 586 | this.scrollToSelectedMatch(); 587 | return etch.update(this); 588 | } 589 | 590 | decrementLeadingContextLines() { 591 | if (this.model.getFindOptions().leadingContextLineCount > 0) { 592 | this.model.getFindOptions().leadingContextLineCount--; 593 | return this.contextLinesChanged(); 594 | } 595 | } 596 | 597 | toggleLeadingContextLines() { 598 | if (this.model.getFindOptions().leadingContextLineCount > 0) { 599 | this.model.getFindOptions().leadingContextLineCount = 0; 600 | return this.contextLinesChanged(); 601 | } else { 602 | const searchContextLineCountBefore = atom.config.get('find-and-replace.searchContextLineCountBefore'); 603 | if (this.model.getFindOptions().leadingContextLineCount < searchContextLineCountBefore) { 604 | this.model.getFindOptions().leadingContextLineCount = searchContextLineCountBefore; 605 | return this.contextLinesChanged(); 606 | } 607 | } 608 | } 609 | 610 | incrementLeadingContextLines() { 611 | const searchContextLineCountBefore = atom.config.get('find-and-replace.searchContextLineCountBefore'); 612 | if (this.model.getFindOptions().leadingContextLineCount < searchContextLineCountBefore) { 613 | this.model.getFindOptions().leadingContextLineCount++; 614 | return this.contextLinesChanged(); 615 | } 616 | } 617 | 618 | decrementTrailingContextLines() { 619 | if (this.model.getFindOptions().trailingContextLineCount > 0) { 620 | this.model.getFindOptions().trailingContextLineCount--; 621 | return this.contextLinesChanged(); 622 | } 623 | } 624 | 625 | toggleTrailingContextLines() { 626 | if (this.model.getFindOptions().trailingContextLineCount > 0) { 627 | this.model.getFindOptions().trailingContextLineCount = 0; 628 | return this.contextLinesChanged(); 629 | } else { 630 | const searchContextLineCountAfter = atom.config.get('find-and-replace.searchContextLineCountAfter'); 631 | if (this.model.getFindOptions().trailingContextLineCount < searchContextLineCountAfter) { 632 | this.model.getFindOptions().trailingContextLineCount = searchContextLineCountAfter; 633 | return this.contextLinesChanged(); 634 | } 635 | } 636 | } 637 | 638 | incrementTrailingContextLines() { 639 | const searchContextLineCountAfter = atom.config.get('find-and-replace.searchContextLineCountAfter'); 640 | if (this.model.getFindOptions().trailingContextLineCount < searchContextLineCountAfter) { 641 | this.model.getFindOptions().trailingContextLineCount++; 642 | return this.contextLinesChanged(); 643 | } 644 | } 645 | 646 | async contextLinesChanged() { 647 | let rowIndex = 0 648 | // Since the whole array is re-generated, this makes splices cheaper 649 | this.resultRows = [] 650 | for (let i = 0; i < this.resultRowGroups.length; i++) { 651 | const group = this.resultRowGroups[i] 652 | 653 | this.invalidateRowGroup(rowIndex, i) 654 | rowIndex += group.displayedRows().length 655 | } 656 | await etch.update(this); 657 | this.scrollToSelectedMatch(); 658 | } 659 | 660 | scrollToSelectedMatch() { 661 | if (this.selectedRowIndex === -1) { 662 | return 663 | } 664 | if (this.refs.listView) { 665 | const top = this.positionOfSelectedResult(); 666 | const bottom = top + this.getRowHeight(this.selectedRow()); 667 | 668 | if (bottom > this.getScrollTop() + this.refs.listView.element.clientHeight) { 669 | this.setScrollTop(bottom - this.refs.listView.element.clientHeight); 670 | } else if (top < this.getScrollTop()) { 671 | this.setScrollTop(top); 672 | } 673 | } 674 | } 675 | 676 | scrollToBottom() { 677 | this.setScrollTop(this.getScrollHeight()); 678 | } 679 | 680 | scrollToTop() { 681 | this.setScrollTop(0); 682 | } 683 | 684 | setScrollTop (scrollTop) { 685 | if (this.refs.listView) { 686 | this.refs.listView.element.scrollTop = scrollTop; 687 | this.refs.listView.element.dispatchEvent(new UIEvent('scroll')) 688 | } 689 | } 690 | 691 | getScrollTop () { 692 | return this.refs.listView ? this.refs.listView.element.scrollTop : 0; 693 | } 694 | 695 | getScrollHeight () { 696 | return this.refs.listView ? this.refs.listView.element.scrollHeight : 0; 697 | } 698 | 699 | maintainPreviousScrollPosition() { 700 | if(this.selectedRowIndex === -1 || !this.currentScrollTop) { 701 | return; 702 | } 703 | 704 | this.setScrollTop(this.currentScrollTop); 705 | } 706 | 707 | fontFamilyChanged(fontFamily) { 708 | this.previewStyle = {fontFamily}; 709 | etch.update(this); 710 | } 711 | }; 712 | -------------------------------------------------------------------------------- /lib/project/util.coffee: -------------------------------------------------------------------------------- 1 | _ = require 'underscore-plus' 2 | 3 | escapeNode = null 4 | 5 | escapeHtml = (str) -> 6 | escapeNode ?= document.createElement('div') 7 | escapeNode.innerText = str 8 | escapeNode.innerHTML 9 | 10 | escapeRegex = (str) -> 11 | str.replace /[.?*+^$[\]\\(){}|-]/g, (match) -> "\\" + match 12 | 13 | sanitizePattern = (pattern) -> 14 | pattern = escapeHtml(pattern) 15 | pattern.replace(/\n/g, '\\n').replace(/\t/g, '\\t') 16 | 17 | getReplacementResultsMessage = ({findPattern, replacePattern, replacedPathCount, replacementCount}) -> 18 | if replacedPathCount 19 | "Replaced #{sanitizePattern(findPattern)} with #{sanitizePattern(replacePattern)} #{_.pluralize(replacementCount, 'time')} in #{_.pluralize(replacedPathCount, 'file')}" 20 | else 21 | "Nothing replaced" 22 | 23 | getSearchResultsMessage = (results) -> 24 | if results?.findPattern? 25 | {findPattern, matchCount, pathCount, replacedPathCount} = results 26 | if matchCount 27 | "#{_.pluralize(matchCount, 'result')} found in #{_.pluralize(pathCount, 'file')} for #{sanitizePattern(findPattern)}" 28 | else 29 | "No #{if replacedPathCount? then 'more' else ''} results found for '#{sanitizePattern(findPattern)}'" 30 | else 31 | '' 32 | 33 | showIf = (condition) -> 34 | if condition 35 | null 36 | else 37 | {display: 'none'} 38 | 39 | capitalize = (str) -> str[0].toUpperCase() + str.toLowerCase().slice(1) 40 | titleize = (str) -> str.toLowerCase().replace(/(?:^|\s)\S/g, (capital) -> capital.toUpperCase()) 41 | 42 | preserveCase = (text, reference) -> 43 | # If replaced text is capitalized (strict) like a sentence, capitalize replacement 44 | if reference is capitalize(reference.toLowerCase()) 45 | capitalize(text) 46 | 47 | # If replaced text is titleized (i.e., each word start with an uppercase), titleize replacement 48 | else if reference is titleize(reference.toLowerCase()) 49 | titleize(text) 50 | 51 | # If replaced text is uppercase, uppercase replacement 52 | else if reference is reference.toUpperCase() 53 | text.toUpperCase() 54 | 55 | # If replaced text is lowercase, lowercase replacement 56 | else if reference is reference.toLowerCase() 57 | text.toLowerCase() 58 | else 59 | text 60 | 61 | 62 | module.exports = { 63 | escapeHtml, escapeRegex, sanitizePattern, getReplacementResultsMessage, 64 | getSearchResultsMessage, showIf, preserveCase 65 | } 66 | -------------------------------------------------------------------------------- /lib/reporter-proxy.js: -------------------------------------------------------------------------------- 1 | module.exports = class ReporterProxy { 2 | constructor () { 3 | this.reporter = null 4 | this.timingsQueue = [] 5 | 6 | this.eventType = 'find-and-replace-v1' 7 | } 8 | 9 | setReporter (reporter) { 10 | this.reporter = reporter 11 | let timingsEvent 12 | 13 | while ((timingsEvent = this.timingsQueue.shift())) { 14 | this.reporter.addTiming(this.eventType, timingsEvent.duration, timingsEvent.metadata) 15 | } 16 | } 17 | 18 | unsetReporter () { 19 | delete this.reporter 20 | } 21 | 22 | sendSearchEvent (duration, numResults, crawler) { 23 | const metadata = { 24 | ec: 'time-to-search', 25 | ev: numResults, 26 | el: crawler 27 | } 28 | 29 | this._addTiming(duration, metadata) 30 | } 31 | 32 | _addTiming (duration, metadata) { 33 | if (this.reporter) { 34 | this.reporter.addTiming(this.eventType, duration, metadata) 35 | } else { 36 | this.timingsQueue.push({duration, metadata}) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/select-next.coffee: -------------------------------------------------------------------------------- 1 | _ = require 'underscore-plus' 2 | {CompositeDisposable, Range} = require 'atom' 3 | 4 | # Find and select the next occurrence of the currently selected text. 5 | # 6 | # The word under the cursor will be selected if the selection is empty. 7 | module.exports = 8 | class SelectNext 9 | selectionRanges: null 10 | 11 | constructor: (@editor) -> 12 | @selectionRanges = [] 13 | 14 | findAndSelectNext: -> 15 | if @editor.getLastSelection().isEmpty() 16 | @selectWord() 17 | else 18 | @selectNextOccurrence() 19 | 20 | findAndSelectAll: -> 21 | @selectWord() if @editor.getLastSelection().isEmpty() 22 | @selectAllOccurrences() 23 | 24 | undoLastSelection: -> 25 | @updateSavedSelections() 26 | 27 | return if @selectionRanges.length < 1 28 | 29 | if @selectionRanges.length > 1 30 | @selectionRanges.pop() 31 | @editor.setSelectedBufferRanges @selectionRanges 32 | else 33 | @editor.clearSelections() 34 | 35 | @editor.scrollToCursorPosition() 36 | 37 | skipCurrentSelection: -> 38 | @updateSavedSelections() 39 | 40 | return if @selectionRanges.length < 1 41 | 42 | if @selectionRanges.length > 1 43 | lastSelection = @selectionRanges.pop() 44 | @editor.setSelectedBufferRanges @selectionRanges 45 | @selectNextOccurrence(start: lastSelection.end) 46 | else 47 | @selectNextOccurrence() 48 | @selectionRanges.shift() 49 | return if @selectionRanges.length < 1 50 | @editor.setSelectedBufferRanges @selectionRanges 51 | 52 | selectWord: -> 53 | @editor.selectWordsContainingCursors() 54 | lastSelection = @editor.getLastSelection() 55 | if @wordSelected = @isWordSelected(lastSelection) 56 | disposables = new CompositeDisposable 57 | clearWordSelected = => 58 | @wordSelected = null 59 | disposables.dispose() 60 | disposables.add lastSelection.onDidChangeRange clearWordSelected 61 | disposables.add lastSelection.onDidDestroy clearWordSelected 62 | 63 | selectAllOccurrences: -> 64 | range = [[0, 0], @editor.getEofBufferPosition()] 65 | @scanForNextOccurrence range, ({range, stop}) => 66 | @addSelection(range) 67 | 68 | selectNextOccurrence: (options={}) -> 69 | startingRange = options.start ? @editor.getSelectedBufferRange().end 70 | range = @findNextOccurrence([startingRange, @editor.getEofBufferPosition()]) 71 | range ?= @findNextOccurrence([[0, 0], @editor.getSelections()[0].getBufferRange().start]) 72 | @addSelection(range) if range? 73 | 74 | findNextOccurrence: (scanRange) -> 75 | foundRange = null 76 | @scanForNextOccurrence scanRange, ({range, stop}) -> 77 | foundRange = range 78 | stop() 79 | foundRange 80 | 81 | addSelection: (range) -> 82 | reversed = @editor.getLastSelection().isReversed() 83 | selection = @editor.addSelectionForBufferRange(range, {reversed}) 84 | @updateSavedSelections selection 85 | 86 | scanForNextOccurrence: (range, callback) -> 87 | selection = @editor.getLastSelection() 88 | text = _.escapeRegExp(selection.getText()) 89 | 90 | if @wordSelected 91 | nonWordCharacters = atom.config.get('editor.nonWordCharacters') 92 | text = "(^|[ \t#{_.escapeRegExp(nonWordCharacters)}]+)#{text}(?=$|[\\s#{_.escapeRegExp(nonWordCharacters)}]+)" 93 | 94 | @editor.scanInBufferRange new RegExp(text, 'g'), range, (result) -> 95 | if prefix = result.match[1] 96 | result.range = result.range.translate([0, prefix.length], [0, 0]) 97 | callback(result) 98 | 99 | updateSavedSelections: (selection=null) -> 100 | selections = @editor.getSelections() 101 | @selectionRanges = [] if selections.length < 3 102 | if @selectionRanges.length is 0 103 | @selectionRanges.push s.getBufferRange() for s in selections 104 | else if selection 105 | selectionRange = selection.getBufferRange() 106 | return if @selectionRanges.some (existingRange) -> existingRange.isEqual(selectionRange) 107 | @selectionRanges.push selectionRange 108 | 109 | isNonWordCharacter: (character) -> 110 | nonWordCharacters = atom.config.get('editor.nonWordCharacters') 111 | new RegExp("[ \t#{_.escapeRegExp(nonWordCharacters)}]").test(character) 112 | 113 | isNonWordCharacterToTheLeft: (selection) -> 114 | selectionStart = selection.getBufferRange().start 115 | range = Range.fromPointWithDelta(selectionStart, 0, -1) 116 | @isNonWordCharacter(@editor.getTextInBufferRange(range)) 117 | 118 | isNonWordCharacterToTheRight: (selection) -> 119 | selectionEnd = selection.getBufferRange().end 120 | range = Range.fromPointWithDelta(selectionEnd, 0, 1) 121 | @isNonWordCharacter(@editor.getTextInBufferRange(range)) 122 | 123 | isWordSelected: (selection) -> 124 | if selection.getBufferRange().isSingleLine() 125 | selectionRange = selection.getBufferRange() 126 | lineRange = @editor.bufferRangeForBufferRow(selectionRange.start.row) 127 | nonWordCharacterToTheLeft = _.isEqual(selectionRange.start, lineRange.start) or 128 | @isNonWordCharacterToTheLeft(selection) 129 | nonWordCharacterToTheRight = _.isEqual(selectionRange.end, lineRange.end) or 130 | @isNonWordCharacterToTheRight(selection) 131 | containsOnlyWordCharacters = not @isNonWordCharacter(selection.getText()) 132 | 133 | nonWordCharacterToTheLeft and nonWordCharacterToTheRight and containsOnlyWordCharacters 134 | else 135 | false 136 | -------------------------------------------------------------------------------- /menus/find-and-replace.cson: -------------------------------------------------------------------------------- 1 | 'menu': [ 2 | 'label': 'Find' 3 | 'submenu': [ 4 | { 'label': 'Find in Buffer', 'command': 'find-and-replace:show'} 5 | { 'label': 'Replace in Buffer', 'command': 'find-and-replace:show-replace'} 6 | { 'label': 'Select Next', 'command': 'find-and-replace:select-next'} 7 | { 'label': 'Select All', 'command': 'find-and-replace:select-all'} 8 | { 'label': 'Toggle Find in Buffer', 'command': 'find-and-replace:toggle'} 9 | { 'type': 'separator' } 10 | { 'label': 'Find in Project', 'command': 'project-find:show'} 11 | { 'label': 'Toggle Find in Project', 'command': 'project-find:toggle'} 12 | { 'type': 'separator' } 13 | { 'label': 'Find All', 'command': 'find-and-replace:find-all'} 14 | { 'label': 'Find Next', 'command': 'find-and-replace:find-next'} 15 | { 'label': 'Find Previous', 'command': 'find-and-replace:find-previous'} 16 | { 'label': 'Replace Next', 'command': 'find-and-replace:replace-next'} 17 | { 'label': 'Replace All', 'command': 'find-and-replace:replace-all'} 18 | { 'type': 'separator' } 19 | { 'label': 'Clear History', 'command': 'find-and-replace:clear-history'} 20 | { 'type': 'separator' } 21 | ] 22 | ] 23 | 24 | 'context-menu': 25 | '.tree-view li.directory': [ 26 | { 'label': 'Search in Folder', 'command': 'project-find:show-in-current-directory' } 27 | ] 28 | '.list-item.match-row': [ 29 | { 'label': 'Open in New Tab', 'command': 'find-and-replace:open-in-new-tab' } 30 | { 'label': 'Copy', 'command': 'core:copy' } 31 | { 'label': 'Copy Path', 'command': 'find-and-replace:copy-path' } 32 | ] 33 | '.list-item.path-row': [ 34 | { 'label': 'Open in New Tab', 'command': 'find-and-replace:open-in-new-tab' } 35 | { 'label': 'Copy Path', 'command': 'find-and-replace:copy-path' } 36 | ] 37 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "find-and-replace", 3 | "version": "0.219.8", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "async": { 8 | "version": "1.5.2", 9 | "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", 10 | "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" 11 | }, 12 | "balanced-match": { 13 | "version": "1.0.0", 14 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", 15 | "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" 16 | }, 17 | "binary-search": { 18 | "version": "1.3.4", 19 | "resolved": "https://registry.npmjs.org/binary-search/-/binary-search-1.3.4.tgz", 20 | "integrity": "sha512-dPxU/vZLnH0tEVjVPgi015oSwqu6oLfCeHywuFRhBE0yM0mYocvleTl8qsdM1YFhRzTRhM1+VzS8XLDVrHPopg==" 21 | }, 22 | "brace-expansion": { 23 | "version": "1.1.8", 24 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", 25 | "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", 26 | "requires": { 27 | "balanced-match": "^1.0.0", 28 | "concat-map": "0.0.1" 29 | } 30 | }, 31 | "coffee-script": { 32 | "version": "1.11.1", 33 | "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.11.1.tgz", 34 | "integrity": "sha1-vxxHrWREOg2V0S3ysUfMCk2q1uk=", 35 | "dev": true 36 | }, 37 | "coffeelint": { 38 | "version": "1.16.0", 39 | "resolved": "https://registry.npmjs.org/coffeelint/-/coffeelint-1.16.0.tgz", 40 | "integrity": "sha1-g9jtHa/eOmd95E57ihi+YHdh5tg=", 41 | "dev": true, 42 | "requires": { 43 | "coffee-script": "~1.11.0", 44 | "glob": "^7.0.6", 45 | "ignore": "^3.0.9", 46 | "optimist": "^0.6.1", 47 | "resolve": "^0.6.3", 48 | "strip-json-comments": "^1.0.2" 49 | } 50 | }, 51 | "concat-map": { 52 | "version": "0.0.1", 53 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 54 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" 55 | }, 56 | "dedent": { 57 | "version": "0.6.0", 58 | "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.6.0.tgz", 59 | "integrity": "sha1-Dm2o8M5Sg471zsXI+TlrDBtko8s=", 60 | "dev": true 61 | }, 62 | "etch": { 63 | "version": "0.9.3", 64 | "resolved": "https://registry.npmjs.org/etch/-/etch-0.9.3.tgz", 65 | "integrity": "sha1-2uxSmVv2E1A9a5K0H1Si6qEuMis=" 66 | }, 67 | "fs-plus": { 68 | "version": "3.1.1", 69 | "resolved": "https://registry.npmjs.org/fs-plus/-/fs-plus-3.1.1.tgz", 70 | "integrity": "sha512-Se2PJdOWXqos1qVTkvqqjb0CSnfBnwwD+pq+z4ksT+e97mEShod/hrNg0TRCCsXPbJzcIq+NuzQhigunMWMJUA==", 71 | "requires": { 72 | "async": "^1.5.2", 73 | "mkdirp": "^0.5.1", 74 | "rimraf": "^2.5.2", 75 | "underscore-plus": "1.x" 76 | } 77 | }, 78 | "fs.realpath": { 79 | "version": "1.0.0", 80 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 81 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" 82 | }, 83 | "glob": { 84 | "version": "7.1.2", 85 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", 86 | "integrity": "sha1-wZyd+aAocC1nhhI4SmVSQExjbRU=", 87 | "requires": { 88 | "fs.realpath": "^1.0.0", 89 | "inflight": "^1.0.4", 90 | "inherits": "2", 91 | "minimatch": "^3.0.4", 92 | "once": "^1.3.0", 93 | "path-is-absolute": "^1.0.0" 94 | } 95 | }, 96 | "ignore": { 97 | "version": "3.3.3", 98 | "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.3.tgz", 99 | "integrity": "sha1-QyNS5XrM2HqzEQ6C0/6g5HgSFW0=", 100 | "dev": true 101 | }, 102 | "inflight": { 103 | "version": "1.0.6", 104 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 105 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 106 | "requires": { 107 | "once": "^1.3.0", 108 | "wrappy": "1" 109 | } 110 | }, 111 | "inherits": { 112 | "version": "2.0.3", 113 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 114 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" 115 | }, 116 | "minimatch": { 117 | "version": "3.0.4", 118 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", 119 | "integrity": "sha1-UWbihkV/AzBgZL5Ul+jbsMPTIIM=", 120 | "requires": { 121 | "brace-expansion": "^1.1.7" 122 | } 123 | }, 124 | "minimist": { 125 | "version": "0.0.8", 126 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 127 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" 128 | }, 129 | "mkdirp": { 130 | "version": "0.5.1", 131 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 132 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 133 | "requires": { 134 | "minimist": "0.0.8" 135 | } 136 | }, 137 | "once": { 138 | "version": "1.4.0", 139 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 140 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 141 | "requires": { 142 | "wrappy": "1" 143 | } 144 | }, 145 | "optimist": { 146 | "version": "0.6.1", 147 | "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", 148 | "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", 149 | "dev": true, 150 | "requires": { 151 | "minimist": "~0.0.1", 152 | "wordwrap": "~0.0.2" 153 | } 154 | }, 155 | "os-tmpdir": { 156 | "version": "1.0.2", 157 | "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", 158 | "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" 159 | }, 160 | "path-is-absolute": { 161 | "version": "1.0.1", 162 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 163 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" 164 | }, 165 | "resolve": { 166 | "version": "0.6.3", 167 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-0.6.3.tgz", 168 | "integrity": "sha1-3ZV5gufnNt699TtYpN2RdUV13UY=", 169 | "dev": true 170 | }, 171 | "rimraf": { 172 | "version": "2.6.1", 173 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.1.tgz", 174 | "integrity": "sha1-wjOOxkPfeht/5cVPqG9XQopV8z0=", 175 | "requires": { 176 | "glob": "^7.0.5" 177 | } 178 | }, 179 | "strip-json-comments": { 180 | "version": "1.0.4", 181 | "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", 182 | "integrity": "sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E=", 183 | "dev": true 184 | }, 185 | "temp": { 186 | "version": "0.8.3", 187 | "resolved": "https://registry.npmjs.org/temp/-/temp-0.8.3.tgz", 188 | "integrity": "sha1-4Ma8TSa5AxJEEOT+2BEDAU38H1k=", 189 | "requires": { 190 | "os-tmpdir": "^1.0.0", 191 | "rimraf": "~2.2.6" 192 | }, 193 | "dependencies": { 194 | "rimraf": { 195 | "version": "2.2.8", 196 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz", 197 | "integrity": "sha1-5Dm+Kq7jJzIZUnMPmaiSnk/FBYI=" 198 | } 199 | } 200 | }, 201 | "underscore": { 202 | "version": "1.6.0", 203 | "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz", 204 | "integrity": "sha1-izixDKze9jM3uLJOT/htRa6lKag=" 205 | }, 206 | "underscore-plus": { 207 | "version": "1.6.6", 208 | "resolved": "https://registry.npmjs.org/underscore-plus/-/underscore-plus-1.6.6.tgz", 209 | "integrity": "sha1-ZezeG9xEGjXYnmUP1w3PE65Dmn0=", 210 | "requires": { 211 | "underscore": "~1.6.0" 212 | } 213 | }, 214 | "wordwrap": { 215 | "version": "0.0.3", 216 | "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", 217 | "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", 218 | "dev": true 219 | }, 220 | "wrappy": { 221 | "version": "1.0.2", 222 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 223 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "find-and-replace", 3 | "main": "./lib/find", 4 | "description": "Find and replace within buffers and across the project.", 5 | "version": "0.219.8", 6 | "license": "MIT", 7 | "activationCommands": { 8 | "atom-workspace": [ 9 | "project-find:show", 10 | "project-find:toggle", 11 | "project-find:show-in-current-directory", 12 | "find-and-replace:show", 13 | "find-and-replace:toggle", 14 | "find-and-replace:find-all", 15 | "find-and-replace:find-next", 16 | "find-and-replace:find-previous", 17 | "find-and-replace:find-next-selected", 18 | "find-and-replace:find-previous-selected", 19 | "find-and-replace:use-selection-as-find-pattern", 20 | "find-and-replace:use-selection-as-replace-pattern", 21 | "find-and-replace:show-replace", 22 | "find-and-replace:replace-next", 23 | "find-and-replace:replace-all", 24 | "find-and-replace:select-next", 25 | "find-and-replace:select-all", 26 | "find-and-replace:clear-history" 27 | ] 28 | }, 29 | "repository": "https://github.com/atom/find-and-replace", 30 | "engines": { 31 | "atom": "*" 32 | }, 33 | "dependencies": { 34 | "binary-search": "^1.3.3", 35 | "etch": "0.9.3", 36 | "fs-plus": "^3.0.0", 37 | "temp": "^0.8.3", 38 | "underscore-plus": "1.x" 39 | }, 40 | "devDependencies": { 41 | "coffeelint": "^1.9.7", 42 | "dedent": "^0.6.0" 43 | }, 44 | "consumedServices": { 45 | "atom.file-icons": { 46 | "versions": { 47 | "1.0.0": "consumeFileIcons" 48 | } 49 | }, 50 | "autocomplete.watchEditor": { 51 | "versions": { 52 | "1.0.0": "consumeAutocompleteWatchEditor" 53 | } 54 | }, 55 | "file-icons.element-icons": { 56 | "versions": { 57 | "1.0.0": "consumeElementIcons" 58 | } 59 | }, 60 | "metrics-reporter": { 61 | "versions": { 62 | "^1.1.0": "consumeMetricsReporter" 63 | } 64 | } 65 | }, 66 | "providedServices": { 67 | "find-and-replace": { 68 | "description": "Atom's bundled find-and-replace package", 69 | "versions": { 70 | "0.0.1": "provideService" 71 | } 72 | } 73 | }, 74 | "configSchema": { 75 | "focusEditorAfterSearch": { 76 | "type": "boolean", 77 | "default": false, 78 | "description": "Focus the editor and select the next match when a file search is executed. If no matches are found, the editor will not be focused." 79 | }, 80 | "projectSearchResultsPaneSplitDirection": { 81 | "type": "string", 82 | "default": "none", 83 | "enum": [ 84 | "none", 85 | "right", 86 | "down" 87 | ], 88 | "title": "Direction to open results pane", 89 | "description": "Direction to split the active pane when showing project search results. If 'none', the results will be shown in the active pane." 90 | }, 91 | "closeFindPanelAfterSearch": { 92 | "type": "boolean", 93 | "default": false, 94 | "title": "Close Project Find Panel After Search", 95 | "description": "Close the find panel after executing a project-wide search." 96 | }, 97 | "scrollToResultOnLiveSearch": { 98 | "type": "boolean", 99 | "default": false, 100 | "title": "Scroll To Result On Live-Search (incremental find in buffer)", 101 | "description": "Scroll to and select the closest match while typing in the buffer find box." 102 | }, 103 | "liveSearchMinimumCharacters": { 104 | "type": "integer", 105 | "default": 3, 106 | "minimum": 0, 107 | "description": "The minimum number of characters which need to be typed into the buffer find box before search starts matching and highlighting matches as you type." 108 | }, 109 | "searchContextLineCountBefore": { 110 | "type": "integer", 111 | "default": 3, 112 | "minimum": 0, 113 | "description": "The number of extra lines of context to query before the match for project results" 114 | }, 115 | "searchContextLineCountAfter": { 116 | "type": "integer", 117 | "default": 3, 118 | "minimum": 0, 119 | "description": "The number of extra lines of context to query after the match for project results" 120 | }, 121 | "showSearchWrapIcon": { 122 | "type": "boolean", 123 | "default": true, 124 | "title": "Show Search Wrap Icon", 125 | "description": "Display a visual cue over the editor when looping through search results." 126 | }, 127 | "useRipgrep": { 128 | "type": "boolean", 129 | "default": false, 130 | "title": "Use ripgrep", 131 | "description": "Use the experimental `ripgrep` search crawler. This will make searches substantially faster on large projects." 132 | }, 133 | "enablePCRE2": { 134 | "type": "boolean", 135 | "default": false, 136 | "title": "Enable PCRE2 regex engine", 137 | "description": "Enable PCRE2 regex engine (applies only to `ripgrep` search). This will enable additional regex features such as lookbehind, but may make searches slower." 138 | }, 139 | "autocompleteSearches": { 140 | "type": "boolean", 141 | "default": false, 142 | "title": "Autocomplete Search", 143 | "description": "Autocompletes entries in the find search field." 144 | }, 145 | "preserveCaseOnReplace": { 146 | "type": "boolean", 147 | "default": false, 148 | "title": "Preserve case during replace.", 149 | "description": "Keep the replaced text case during replace: replacing 'user' with 'person' will replace 'User' with 'Person' and 'USER' with 'PERSON'." 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /spec/async-spec-helpers.js: -------------------------------------------------------------------------------- 1 | /** @babel */ 2 | 3 | export async function conditionPromise (condition) { 4 | const startTime = Date.now() 5 | 6 | while (true) { 7 | await timeoutPromise(100) 8 | 9 | if (await condition()) { 10 | return 11 | } 12 | 13 | if (Date.now() - startTime > 5000) { 14 | throw new Error("Timed out waiting on condition") 15 | } 16 | } 17 | } 18 | 19 | function timeoutPromise (timeout) { 20 | return new Promise(function (resolve) { 21 | global.setTimeout(resolve, timeout) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /spec/find-spec.js: -------------------------------------------------------------------------------- 1 | const BufferSearch = require('../lib/buffer-search') 2 | const EmbeddedEditorItem = require('./item/embedded-editor-item') 3 | const DeferredEditorItem = require('./item/deferred-editor-item'); 4 | const UnrecognizedItem = require('./item/unrecognized-item'); 5 | 6 | describe('Find', () => { 7 | describe('updating the find model', () => { 8 | beforeEach(async () => { 9 | atom.workspace.addOpener(EmbeddedEditorItem.opener) 10 | atom.workspace.addOpener(UnrecognizedItem.opener) 11 | atom.workspace.addOpener(DeferredEditorItem.opener) 12 | 13 | const activationPromise = atom.packages.activatePackage('find-and-replace') 14 | atom.commands.dispatch(atom.views.getView(atom.workspace), 'find-and-replace:show') 15 | await activationPromise 16 | 17 | spyOn(BufferSearch.prototype, 'setEditor') 18 | }) 19 | 20 | it("sets the find model's editor whenever an editor is focused", async () => { 21 | let editor = await atom.workspace.open() 22 | expect(BufferSearch.prototype.setEditor).toHaveBeenCalledWith(editor) 23 | 24 | editor = await atom.workspace.open('sample.js') 25 | expect(BufferSearch.prototype.setEditor).toHaveBeenCalledWith(editor) 26 | }) 27 | 28 | it("sets the find model's editor to an embedded text editor", async () => { 29 | const embedded = await atom.workspace.open(EmbeddedEditorItem.uri) 30 | expect(BufferSearch.prototype.setEditor).toHaveBeenCalledWith(embedded.refs.theEditor) 31 | }) 32 | 33 | it("sets the find model's editor to an embedded text editor after activation", async () => { 34 | const deferred = await atom.workspace.open(DeferredEditorItem.uri) 35 | expect(BufferSearch.prototype.setEditor).not.toHaveBeenCalled() 36 | 37 | await deferred.showEditor() 38 | expect(BufferSearch.prototype.setEditor).toHaveBeenCalledWith(deferred.refs.theEditor) 39 | 40 | await deferred.hideEditor() 41 | expect(BufferSearch.prototype.setEditor).toHaveBeenCalledWith(null) 42 | }) 43 | 44 | it("sets the find model's editor to null if a non-editor is focused", async () => { 45 | await atom.workspace.open(UnrecognizedItem.uri) 46 | expect(BufferSearch.prototype.setEditor).toHaveBeenCalledWith(null) 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /spec/fixtures/one-long-line.coffee: -------------------------------------------------------------------------------- 1 | test test test test test test test test test test test test test test test test test test test test test test a b c d e f g h i j k l abcdefghijklmnopqrstuvwxyz 2 | -------------------------------------------------------------------------------- /spec/fixtures/project/long-match.js: -------------------------------------------------------------------------------- 1 | 1 2 | 1 3 | 1 4 | 1 5 | 1 6 | 1 7 | 1 8 | 1 9 | 1 10 | 1 11 | 1 12 | 1 13 | 1 14 | 1 15 | 1 16 | 1 17 | 18 | 1 19 | 1 20 | 1 21 | 1 22 | 1 23 | 1 24 | 1 25 | 1 26 | 1 27 | 1 28 | 1 29 | 1 30 | 1 31 | 1 32 | 33 | 1 34 | 1 35 | 1 36 | 1 37 | 1 38 | 1 39 | 1 40 | 1 41 | 1 42 | 43 | 1 44 | 1 45 | 1 46 | 1 47 | 1 48 | 1 49 | 1 50 | 1 51 | 1 52 | 1 53 | 1 54 | 1 55 | 1 56 | 1 57 | 1 58 | 1 59 | 60 | 1 61 | 1 62 | 1 63 | 1 64 | 1 65 | 1 66 | 1 67 | 1 68 | 1 69 | 1 70 | 1 71 | 1 72 | 1 73 | 1 74 | 1 75 | 1 76 | 1 77 | 78 | 1 79 | 11 80 | 81 | 11 82 | 1 83 | 84 | 11 85 | 86 | 1 87 | 1 88 | 1 89 | 1 90 | 1 91 | -------------------------------------------------------------------------------- /spec/fixtures/project/one-long-line.coffee: -------------------------------------------------------------------------------- 1 | test test test test test test test test test test test test test test test test test test test test test test a b c d e f g h i j k l abcdefghijklmnopqrstuvwxyz 2 | -------------------------------------------------------------------------------- /spec/fixtures/project/sample.coffee: -------------------------------------------------------------------------------- 1 | class quicksort 2 | sort: (items) -> 3 | return items if items.length <= 1 4 | 5 | pivot = items.shift() 6 | left = [] 7 | right = [] 8 | 9 | # Comment in the middle (and add the word 'items' again) 10 | 11 | while items.length > 0 12 | current = items.shift() 13 | if current < pivot 14 | left.push(current) 15 | else 16 | right.push(current); 17 | 18 | sort(left).concat(pivot).concat(sort(right)) 19 | 20 | noop: -> 21 | # just a noop 22 | 23 | exports.modules = quicksort 24 | -------------------------------------------------------------------------------- /spec/fixtures/project/sample.js: -------------------------------------------------------------------------------- 1 | var quicksort = function () { 2 | var sort = function(items) { 3 | if (items.length <= 1) return items; 4 | var pivot = items.shift(), current, left = [], right = []; 5 | while(items.length > 0) { 6 | current = items.shift(); 7 | current < pivot ? left.push(current) : right.push(current); 8 | } 9 | return sort(left).concat(pivot).concat(sort(right)); 10 | }; 11 | 12 | return sort(Array.apply(this, arguments)); 13 | }; -------------------------------------------------------------------------------- /spec/fixtures/sample.coffee: -------------------------------------------------------------------------------- 1 | class quicksort 2 | sort: (items) -> 3 | return items if items.length <= 1 4 | 5 | pivot = items.shift() 6 | left = [] 7 | right = [] 8 | 9 | # Comment in the middle (and add the word 'items' again) 10 | 11 | while items.length > 0 12 | current = items.shift() 13 | if current < pivot 14 | left.push(current) 15 | else 16 | right.push(current); 17 | 18 | sort(left).concat(pivot).concat(sort(right)) 19 | 20 | noop: -> 21 | # just a noop 22 | 23 | exports.modules = quicksort 24 | -------------------------------------------------------------------------------- /spec/fixtures/sample.js: -------------------------------------------------------------------------------- 1 | var quicksort = function () { 2 | var sort = function(items) { 3 | if (items.length <= 1) return items; 4 | var pivot = items.shift(), current, left = [], right = []; 5 | while(items.length > 0) { 6 | current = items.shift(); 7 | current < pivot ? left.push(current) : right.push(current); 8 | } 9 | return sort(left).concat(pivot).concat(sort(right)); 10 | }; 11 | 12 | return sort(Array.apply(this, arguments)); 13 | }; -------------------------------------------------------------------------------- /spec/item/deferred-editor-item.js: -------------------------------------------------------------------------------- 1 | // An active workspace item that embeds an AtomTextEditor we wish to expose to Find and Replace that does not become 2 | // available until some time after the item is activated. 3 | 4 | const etch = require('etch'); 5 | const $ = etch.dom; 6 | 7 | const { TextEditor, Emitter } = require('atom'); 8 | 9 | class DeferredEditorItem { 10 | static opener(u) { 11 | if (u === DeferredEditorItem.uri) { 12 | return new DeferredEditorItem(); 13 | } else { 14 | return undefined; 15 | } 16 | } 17 | 18 | constructor() { 19 | this.editorShown = false; 20 | this.emitter = new Emitter(); 21 | 22 | etch.initialize(this); 23 | } 24 | 25 | render() { 26 | if (this.editorShown) { 27 | return ( 28 | $.div({className: 'wrapper'}, 29 | etch.dom(TextEditor, {ref: 'theEditor'}) 30 | ) 31 | ) 32 | } else { 33 | return ( 34 | $.div({className: 'wrapper'}, 'Empty') 35 | ) 36 | } 37 | } 38 | 39 | update() { 40 | return etch.update(this) 41 | } 42 | 43 | observeEmbeddedTextEditor(cb) { 44 | if (this.editorShown) { 45 | cb(this.refs.theEditor) 46 | } 47 | return this.emitter.on('did-change-embedded-text-editor', cb) 48 | } 49 | 50 | async showEditor() { 51 | const wasShown = this.editorShown 52 | this.editorShown = true 53 | await this.update() 54 | if (!wasShown) { 55 | this.emitter.emit('did-change-embedded-text-editor', this.refs.theEditor) 56 | } 57 | } 58 | 59 | async hideEditor() { 60 | const wasShown = this.editorShown 61 | this.editorShown = false 62 | await this.update() 63 | if (wasShown) { 64 | this.emitter.emit('did-change-embedded-text-editor', null) 65 | } 66 | } 67 | } 68 | 69 | DeferredEditorItem.uri = 'atom://find-and-replace/spec/deferred-editor' 70 | 71 | module.exports = DeferredEditorItem 72 | -------------------------------------------------------------------------------- /spec/item/embedded-editor-item.js: -------------------------------------------------------------------------------- 1 | // An active workspace item that embeds an AtomTextEditor we wish to expose to Find and Replace. 2 | 3 | const etch = require('etch'); 4 | const $ = etch.dom; 5 | 6 | const { TextEditor } = require('atom'); 7 | 8 | class EmbeddedEditorItem { 9 | static opener(u) { 10 | if (u === EmbeddedEditorItem.uri) { 11 | return new EmbeddedEditorItem(); 12 | } else { 13 | return undefined; 14 | } 15 | } 16 | 17 | constructor() { 18 | etch.initialize(this); 19 | } 20 | 21 | render() { 22 | return ( 23 | $.div({className: 'wrapper'}, 24 | etch.dom(TextEditor, {ref: 'theEditor'}) 25 | ) 26 | ) 27 | } 28 | 29 | update() {} 30 | 31 | getEmbeddedTextEditor() { 32 | return this.refs.theEditor 33 | } 34 | } 35 | 36 | EmbeddedEditorItem.uri = 'atom://find-and-replace/spec/embedded-editor' 37 | 38 | module.exports = EmbeddedEditorItem 39 | -------------------------------------------------------------------------------- /spec/item/unrecognized-item.js: -------------------------------------------------------------------------------- 1 | // An active workspace item that doesn't contain a TextEditor. 2 | 3 | const etch = require('etch'); 4 | const $ = etch.dom; 5 | 6 | class UnrecognizedItem { 7 | static opener(u) { 8 | if (u === UnrecognizedItem.uri) { 9 | return new UnrecognizedItem(); 10 | } else { 11 | return undefined; 12 | } 13 | } 14 | 15 | constructor() { 16 | etch.initialize(this); 17 | } 18 | 19 | render() { 20 | return ( 21 | $.div({className: 'wrapper'}, 'Some text') 22 | ) 23 | } 24 | 25 | update() {} 26 | } 27 | 28 | UnrecognizedItem.uri = 'atom://find-and-replace/spec/unrecognized' 29 | 30 | module.exports = UnrecognizedItem 31 | -------------------------------------------------------------------------------- /spec/result-row-spec.js: -------------------------------------------------------------------------------- 1 | /** @babel */ 2 | 3 | const { 4 | LeadingContextRow, 5 | TrailingContextRow, 6 | ResultPathRow, 7 | MatchRow, 8 | ResultRowGroup 9 | } = require("../lib/project/result-row"); 10 | 11 | describe("ResultRowGroup", () => { 12 | const lines = (new Array(18)).fill().map((x, i) => `line-${i}`) 13 | const rg = (i) => [[i, 0], [i, lines[i].length]] 14 | const testedRowIndices = [0, 7, 13, 16, 17] 15 | 16 | const result = { 17 | filePath: 'fake-file-path', 18 | matches: testedRowIndices.map(lineNb => ({ 19 | range: rg(lineNb), 20 | leadingContextLines: lines.slice(Math.max(lineNb - 3, 0), lineNb), 21 | trailingContextLines: lines.slice(lineNb + 1, lineNb + 4), 22 | lineTextOffset: 0, 23 | lineText: lines[lineNb], 24 | matchText: 'fake-match-text' 25 | })) 26 | } 27 | 28 | describe("generateRows", () => { 29 | it("generates a path row and several match rows", () => { 30 | const rowGroup = new ResultRowGroup( 31 | result, 32 | { leadingContextLineCount: 0, trailingContextLineCount: 0 } 33 | ) 34 | 35 | const expectedRows = [ 36 | new ResultPathRow(rowGroup), 37 | new MatchRow(rowGroup, false, 0, [ result.matches[0] ]), 38 | new MatchRow(rowGroup, true, 7, [ result.matches[1] ]), 39 | new MatchRow(rowGroup, true, 13, [ result.matches[2] ]), 40 | new MatchRow(rowGroup, true, 16, [ result.matches[3] ]), 41 | new MatchRow(rowGroup, false, 17, [ result.matches[4] ]) 42 | ] 43 | 44 | for (let i = 0; i < rowGroup.rows.length; ++i) { 45 | expect(rowGroup.rows[i].data).toEqual(expectedRows[i].data) 46 | } 47 | }) 48 | 49 | it("generates context rows between matches", () => { 50 | const rowGroup = new ResultRowGroup( 51 | result, 52 | { leadingContextLineCount: 3, trailingContextLineCount: 2 } 53 | ) 54 | 55 | const expectedRows = [ 56 | new ResultPathRow(rowGroup), 57 | 58 | new MatchRow(rowGroup, false, 0, [ result.matches[0] ]), 59 | new TrailingContextRow(rowGroup, lines[1], false, 0, 1), 60 | new TrailingContextRow(rowGroup, lines[2], false, 0, 2), 61 | 62 | new LeadingContextRow(rowGroup, lines[4], true, 7, 3), 63 | new LeadingContextRow(rowGroup, lines[5], false, 7, 2), 64 | new LeadingContextRow(rowGroup, lines[6], false, 7, 1), 65 | new MatchRow(rowGroup, false, 7, [ result.matches[1] ]), 66 | new TrailingContextRow(rowGroup, lines[8], false, 7, 1), 67 | new TrailingContextRow(rowGroup, lines[9], false, 7, 2), 68 | 69 | new LeadingContextRow(rowGroup, lines[10], false, 13, 3), 70 | new LeadingContextRow(rowGroup, lines[11], false, 13, 2), 71 | new LeadingContextRow(rowGroup, lines[12], false, 13, 1), 72 | new MatchRow(rowGroup, false, 13, [ result.matches[2] ]), 73 | new TrailingContextRow(rowGroup, lines[14], false, 13, 1), 74 | new TrailingContextRow(rowGroup, lines[15], false, 13, 2), 75 | 76 | new MatchRow(rowGroup, false, 16, [ result.matches[3] ]), 77 | new MatchRow(rowGroup, false, 17, [ result.matches[4] ]) 78 | ] 79 | 80 | for (let i = 0; i < rowGroup.rows.length; ++i) { 81 | expect(rowGroup.rows[i].data).toEqual(expectedRows[i].data) 82 | } 83 | }) 84 | }) 85 | 86 | describe("getLineNumber", () => { 87 | it("generates correct line numbers", () => { 88 | const rowGroup = new ResultRowGroup( 89 | result, 90 | { leadingContextLineCount: 1, trailingContextLineCount: 1 } 91 | ) 92 | 93 | expect(rowGroup.rows.slice(1).map(row => row.data.lineNumber)).toEqual( 94 | [0, 1, 6, 7, 8, 12, 13, 14, 15, 16, 17] 95 | ) 96 | }) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /spec/results-model-spec.js: -------------------------------------------------------------------------------- 1 | /** @babel */ 2 | 3 | const path = require("path"); 4 | const ResultsModel = require("../lib/project/results-model"); 5 | const FindOptions = require("../lib/find-options"); 6 | 7 | describe("ResultsModel", () => { 8 | let editor, resultsModel, reporterSpy; 9 | 10 | beforeEach(async () => { 11 | atom.config.set("core.excludeVcsIgnoredPaths", false); 12 | atom.config.set("find-and-replace.searchContextLineCountBefore", 2); 13 | atom.config.set("find-and-replace.searchContextLineCountAfter", 3); 14 | atom.project.setPaths([path.join(__dirname, "fixtures/project")]); 15 | 16 | editor = await atom.workspace.open("sample.js"); 17 | reporterSpy = { 18 | sendSearchEvent: jasmine.createSpy() 19 | } 20 | resultsModel = new ResultsModel(new FindOptions(), reporterSpy); 21 | }); 22 | 23 | describe("searching for a pattern", () => { 24 | it("populates the model with all the results, and updates in response to changes in the buffer", async () => { 25 | const resultAddedSpy = jasmine.createSpy(); 26 | const resultSetSpy = jasmine.createSpy(); 27 | const resultRemovedSpy = jasmine.createSpy(); 28 | 29 | resultsModel.onDidAddResult(resultAddedSpy); 30 | resultsModel.onDidSetResult(resultSetSpy); 31 | resultsModel.onDidRemoveResult(resultRemovedSpy); 32 | await resultsModel.search("items", "*.js", ""); 33 | 34 | expect(resultAddedSpy).toHaveBeenCalled(); 35 | expect(resultAddedSpy.callCount).toBe(1); 36 | 37 | let result = resultsModel.getResult(editor.getPath()); 38 | expect(result.matches.length).toBe(6); 39 | expect(resultsModel.getPathCount()).toBe(1); 40 | expect(resultsModel.getMatchCount()).toBe(6); 41 | expect(resultsModel.getPaths()).toEqual([editor.getPath()]); 42 | expect(result.matches[0].leadingContextLines.length).toBe(1); 43 | expect(result.matches[0].leadingContextLines[0]).toBe("var quicksort = function () {"); 44 | expect(result.matches[0].trailingContextLines.length).toBe(3); 45 | expect(result.matches[0].trailingContextLines[0]).toBe(" if (items.length <= 1) return items;"); 46 | expect(result.matches[0].trailingContextLines[1]).toBe(" var pivot = items.shift(), current, left = [], right = [];"); 47 | expect(result.matches[0].trailingContextLines[2]).toBe(" while(items.length > 0) {"); 48 | expect(result.matches[5].leadingContextLines.length).toBe(2); 49 | expect(result.matches[5].trailingContextLines.length).toBe(3); 50 | 51 | editor.setText("there are some items in here"); 52 | advanceClock(editor.buffer.stoppedChangingDelay); 53 | expect(resultAddedSpy.callCount).toBe(1); 54 | expect(resultSetSpy.callCount).toBe(1); 55 | 56 | result = resultsModel.getResult(editor.getPath()); 57 | expect(result.matches.length).toBe(1); 58 | expect(resultsModel.getPathCount()).toBe(1); 59 | expect(resultsModel.getMatchCount()).toBe(1); 60 | expect(resultsModel.getPaths()).toEqual([editor.getPath()]); 61 | expect(result.matches[0].lineText).toBe("there are some items in here"); 62 | expect(result.matches[0].leadingContextLines.length).toBe(0); 63 | expect(result.matches[0].trailingContextLines.length).toBe(0); 64 | 65 | editor.setText("no matches in here"); 66 | advanceClock(editor.buffer.stoppedChangingDelay); 67 | expect(resultAddedSpy.callCount).toBe(1); 68 | expect(resultSetSpy.callCount).toBe(1); 69 | expect(resultRemovedSpy.callCount).toBe(1); 70 | 71 | result = resultsModel.getResult(editor.getPath()); 72 | expect(result).not.toBeDefined(); 73 | expect(resultsModel.getPathCount()).toBe(0); 74 | expect(resultsModel.getMatchCount()).toBe(0); 75 | 76 | resultsModel.clear(); 77 | spyOn(editor, "scan").andCallThrough(); 78 | editor.setText("no matches in here"); 79 | advanceClock(editor.buffer.stoppedChangingDelay); 80 | expect(editor.scan).not.toHaveBeenCalled(); 81 | expect(resultsModel.getPathCount()).toBe(0); 82 | expect(resultsModel.getMatchCount()).toBe(0); 83 | }); 84 | 85 | it("ignores changes in untitled buffers", async () => { 86 | await atom.workspace.open(); 87 | await resultsModel.search("items", "*.js", ""); 88 | 89 | editor = atom.workspace.getCenter().getActiveTextEditor(); 90 | editor.setText("items\nitems"); 91 | spyOn(editor, "scan").andCallThrough(); 92 | advanceClock(editor.buffer.stoppedChangingDelay); 93 | expect(editor.scan).not.toHaveBeenCalled(); 94 | }); 95 | 96 | it("contains valid match objects after destroying a buffer (regression)", async () => { 97 | await resultsModel.search('items', '*.js', ''); 98 | 99 | advanceClock(editor.buffer.stoppedChangingDelay) 100 | editor.getBuffer().destroy() 101 | result = resultsModel.getResult(editor.getPath()) 102 | expect(result.matches[0].lineText).toBe(" var sort = function(items) {") 103 | }); 104 | }); 105 | 106 | describe("cancelling a search", () => { 107 | let cancelledSpy; 108 | 109 | beforeEach(() => { 110 | cancelledSpy = jasmine.createSpy(); 111 | resultsModel.onDidCancelSearching(cancelledSpy); 112 | }); 113 | 114 | it("populates the model with all the results, and updates in response to changes in the buffer", async () => { 115 | const searchPromise = resultsModel.search("items", "*.js", ""); 116 | expect(resultsModel.inProgressSearchPromise).toBeTruthy(); 117 | resultsModel.clear(); 118 | expect(resultsModel.inProgressSearchPromise).toBeFalsy(); 119 | 120 | await searchPromise; 121 | expect(cancelledSpy).toHaveBeenCalled(); 122 | }); 123 | 124 | it("populates the model with all the results, and updates in response to changes in the buffer", async () => { 125 | resultsModel.search("items", "*.js", ""); 126 | await resultsModel.search("sort", "*.js", ""); 127 | 128 | expect(cancelledSpy).toHaveBeenCalled(); 129 | expect(resultsModel.getPathCount()).toBe(1); 130 | expect(resultsModel.getMatchCount()).toBe(5); 131 | }); 132 | }); 133 | 134 | describe("logging metrics", () => { 135 | it("logs the elapsed time and the number of results", async () => { 136 | await resultsModel.search('items', '*.js', ''); 137 | 138 | advanceClock(editor.buffer.stoppedChangingDelay) 139 | editor.getBuffer().destroy() 140 | result = resultsModel.getResult(editor.getPath()) 141 | 142 | expect(Number.isInteger(reporterSpy.sendSearchEvent.calls[0].args[0])).toBeTruthy() 143 | expect(reporterSpy.sendSearchEvent.calls[0].args[1]).toBe(6) 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /spec/select-next-spec.js: -------------------------------------------------------------------------------- 1 | /** @babel */ 2 | 3 | const path = require('path'); 4 | const SelectNext = require('../lib/select-next'); 5 | const dedent = require('dedent'); 6 | 7 | describe("SelectNext", () => { 8 | let workspaceElement, editorElement, editor; 9 | 10 | beforeEach(async () => { 11 | workspaceElement = atom.views.getView(atom.workspace); 12 | atom.project.setPaths([path.join(__dirname, 'fixtures')]); 13 | 14 | editor = await atom.workspace.open('sample.js'); 15 | editorElement = atom.views.getView(editor); 16 | 17 | jasmine.attachToDOM(workspaceElement); 18 | const activationPromise = atom.packages.activatePackage("find-and-replace"); 19 | atom.commands.dispatch(editorElement, 'find-and-replace:show'); 20 | await activationPromise; 21 | }); 22 | 23 | describe("find-and-replace:select-next", () => { 24 | describe("when nothing is selected", () => { 25 | it("selects the word under the cursor", () => { 26 | editor.setCursorBufferPosition([1, 3]); 27 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 28 | expect(editor.getSelectedBufferRanges()).toEqual([[[1, 2], [1, 5]]]); 29 | }); 30 | }); 31 | 32 | describe("when a word is selected", () => { 33 | describe("when the selection was created using select-next", () => { 34 | beforeEach(() => {}); 35 | 36 | it("selects the next occurrence of the selected word skipping any non-word matches", () => { 37 | editor.setText(dedent` 38 | for 39 | information 40 | format 41 | another for 42 | fork 43 | a 3rd for is here 44 | `); 45 | 46 | editor.setCursorBufferPosition([0, 0]); 47 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 48 | expect(editor.getSelectedBufferRanges()).toEqual([ 49 | [[0, 0], [0, 3]] 50 | ]); 51 | 52 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 53 | expect(editor.getSelectedBufferRanges()).toEqual([ 54 | [[0, 0], [0, 3]], 55 | [[3, 8], [3, 11]] 56 | ]); 57 | 58 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 59 | expect(editor.getSelectedBufferRanges()).toEqual([ 60 | [[0, 0], [0, 3]], 61 | [[3, 8], [3, 11]], 62 | [[5, 6], [5, 9]] 63 | ]); 64 | 65 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 66 | expect(editor.getSelectedBufferRanges()).toEqual([ 67 | [[0, 0], [0, 3]], 68 | [[3, 8], [3, 11]], 69 | [[5, 6], [5, 9]] 70 | ]); 71 | 72 | editor.setText("Testing reallyTesting"); 73 | editor.setCursorBufferPosition([0, 0]); 74 | 75 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 76 | expect(editor.getSelectedBufferRanges()).toEqual([ 77 | [[0, 0], [0, 7]] 78 | ]); 79 | 80 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 81 | expect(editor.getSelectedBufferRanges()).toEqual([ 82 | [[0, 0], [0, 7]] 83 | ]);});}); 84 | 85 | describe("when the selection was not created using select-next", () => { 86 | it("selects the next occurrence of the selected characters including non-word matches", () => { 87 | editor.setText(dedent` 88 | for 89 | information 90 | format 91 | another for 92 | fork 93 | a 3rd for is here 94 | `); 95 | 96 | editor.setSelectedBufferRange([[0, 0], [0, 3]]); 97 | 98 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 99 | expect(editor.getSelectedBufferRanges()).toEqual([ 100 | [[0, 0], [0, 3]], 101 | [[1, 2], [1, 5]] 102 | ]); 103 | 104 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 105 | expect(editor.getSelectedBufferRanges()).toEqual([ 106 | [[0, 0], [0, 3]], 107 | [[1, 2], [1, 5]], 108 | [[2, 0], [2, 3]] 109 | ]); 110 | 111 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 112 | expect(editor.getSelectedBufferRanges()).toEqual([ 113 | [[0, 0], [0, 3]], 114 | [[1, 2], [1, 5]], 115 | [[2, 0], [2, 3]], 116 | [[3, 8], [3, 11]] 117 | ]); 118 | 119 | editor.setText("Testing reallyTesting"); 120 | editor.setSelectedBufferRange([[0, 0], [0, 7]]); 121 | 122 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 123 | expect(editor.getSelectedBufferRanges()).toEqual([ 124 | [[0, 0], [0, 7]], 125 | [[0, 14], [0, 21]] 126 | ]); 127 | }); 128 | }); 129 | }); 130 | 131 | describe("when part of a word is selected", () => { 132 | it("selects the next occurrence of the selected text", () => { 133 | editor.setText(dedent` 134 | for 135 | information 136 | format 137 | another for 138 | fork 139 | a 3rd for is here 140 | `); 141 | 142 | editor.setSelectedBufferRange([[1, 2], [1, 5]]); 143 | 144 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 145 | expect(editor.getSelectedBufferRanges()).toEqual([ 146 | [[1, 2], [1, 5]], 147 | [[2, 0], [2, 3]] 148 | ]); 149 | 150 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 151 | expect(editor.getSelectedBufferRanges()).toEqual([ 152 | [[1, 2], [1, 5]], 153 | [[2, 0], [2, 3]], 154 | [[3, 8], [3, 11]] 155 | ]); 156 | 157 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 158 | expect(editor.getSelectedBufferRanges()).toEqual([ 159 | [[1, 2], [1, 5]], 160 | [[2, 0], [2, 3]], 161 | [[3, 8], [3, 11]], 162 | [[4, 0], [4, 3]] 163 | ]); 164 | 165 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 166 | expect(editor.getSelectedBufferRanges()).toEqual([ 167 | [[1, 2], [1, 5]], 168 | [[2, 0], [2, 3]], 169 | [[3, 8], [3, 11]], 170 | [[4, 0], [4, 3]], 171 | [[5, 6], [5, 9]] 172 | ]); 173 | 174 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 175 | expect(editor.getSelectedBufferRanges()).toEqual([ 176 | [[1, 2], [1, 5]], 177 | [[2, 0], [2, 3]], 178 | [[3, 8], [3, 11]], 179 | [[4, 0], [4, 3]], 180 | [[5, 6], [5, 9]], 181 | [[0, 0], [0, 3]] 182 | ]); 183 | }); 184 | }); 185 | 186 | describe("when a non-word is selected", () => { 187 | it("selects the next occurrence of the selected text", () => { 188 | editor.setText(dedent` 189 | { 202 | it("does not select the newlines", () => { 203 | editor.setText(dedent` 204 | a 205 | 206 | a 207 | `); 208 | 209 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 210 | expect(editor.getSelectedBufferRanges()).toEqual([ 211 | [[0, 0], [0, 1]] 212 | ]); 213 | 214 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 215 | expect(editor.getSelectedBufferRanges()).toEqual([ 216 | [[0, 0], [0, 1]], 217 | [[2, 0], [2, 1]] 218 | ]); 219 | 220 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 221 | expect(editor.getSelectedBufferRanges()).toEqual([ 222 | [[0, 0], [0, 1]], 223 | [[2, 0], [2, 1]] 224 | ]); 225 | }); 226 | }); 227 | 228 | it('honors the reversed orientation of previous selections', () => { 229 | editor.setText('ab ab ab ab') 230 | editor.setSelectedBufferRange([[0, 0], [0, 2]], {reversed: true}) 231 | 232 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next') 233 | expect(editor.getSelections().length).toBe(2) 234 | expect(editor.getSelections().every(s => s.isReversed())).toBe(true) 235 | 236 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next') 237 | expect(editor.getSelections().length).toBe(3) 238 | expect(editor.getSelections().every(s => s.isReversed())).toBe(true) 239 | 240 | editor.setSelectedBufferRange([[0, 0], [0, 2]], {reversed: false}) 241 | 242 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next') 243 | expect(editor.getSelections().length).toBe(2) 244 | expect(editor.getSelections().every(s => !s.isReversed())).toBe(true) 245 | 246 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next') 247 | expect(editor.getSelections().length).toBe(3) 248 | expect(editor.getSelections().every(s => !s.isReversed())).toBe(true) 249 | }) 250 | }); 251 | 252 | describe("find-and-replace:select-all", () => { 253 | describe("when there is no selection", () => { 254 | it("find and selects all occurrences of the word under the cursor", () => { 255 | editor.setText(dedent` 256 | for 257 | information 258 | format 259 | another for 260 | fork 261 | a 3rd for is here 262 | `); 263 | 264 | atom.commands.dispatch(editorElement, 'find-and-replace:select-all'); 265 | expect(editor.getSelectedBufferRanges()).toEqual([ 266 | [[0, 0], [0, 3]], 267 | [[3, 8], [3, 11]], 268 | [[5, 6], [5, 9]] 269 | ]); 270 | 271 | atom.commands.dispatch(editorElement, 'find-and-replace:select-all'); 272 | expect(editor.getSelectedBufferRanges()).toEqual([ 273 | [[0, 0], [0, 3]], 274 | [[3, 8], [3, 11]], 275 | [[5, 6], [5, 9]] 276 | ]); 277 | }) 278 | }); 279 | 280 | describe("when a word is selected", () => { 281 | describe("when the word was selected using select-next", () => { 282 | it("find and selects all occurrences of the word", () => { 283 | editor.setText(dedent` 284 | for 285 | information 286 | format 287 | another for 288 | fork 289 | a 3rd for is here 290 | `); 291 | 292 | atom.commands.dispatch(editorElement, 'find-and-replace:select-all'); 293 | expect(editor.getSelectedBufferRanges()).toEqual([ 294 | [[0, 0], [0, 3]], 295 | [[3, 8], [3, 11]], 296 | [[5, 6], [5, 9]] 297 | ]); 298 | 299 | atom.commands.dispatch(editorElement, 'find-and-replace:select-all'); 300 | expect(editor.getSelectedBufferRanges()).toEqual([ 301 | [[0, 0], [0, 3]], 302 | [[3, 8], [3, 11]], 303 | [[5, 6], [5, 9]] 304 | ]); 305 | }); 306 | }); 307 | 308 | describe("when the word was not selected using select-next", () => { 309 | it("find and selects all occurrences including non-words", () => { 310 | editor.setText(dedent` 311 | for 312 | information 313 | format 314 | another for 315 | fork 316 | a 3rd for is here 317 | `); 318 | 319 | editor.setSelectedBufferRange([[3, 8], [3, 11]]); 320 | 321 | atom.commands.dispatch(editorElement, 'find-and-replace:select-all'); 322 | expect(editor.getSelectedBufferRanges()).toEqual([ 323 | [[3, 8], [3, 11]], 324 | [[0, 0], [0, 3]], 325 | [[1, 2], [1, 5]], 326 | [[2, 0], [2, 3]], 327 | [[4, 0], [4, 3]], 328 | [[5, 6], [5, 9]] 329 | ]); 330 | }); 331 | }); 332 | }); 333 | 334 | describe("when a non-word is selected", () => { 335 | it("selects the next occurrence of the selected text", () => { 336 | editor.setText(dedent` 337 | { 350 | editor.setText('ab ab ab ab') 351 | editor.setSelectedBufferRange([[0, 0], [0, 2]], {reversed: true}) 352 | 353 | atom.commands.dispatch(editorElement, 'find-and-replace:select-all') 354 | expect(editor.getSelections().length).toBe(4) 355 | expect(editor.getSelections().every(s => s.isReversed())).toBe(true) 356 | 357 | editor.setSelectedBufferRange([[0, 0], [0, 2]], {reversed: false}) 358 | 359 | atom.commands.dispatch(editorElement, 'find-and-replace:select-all') 360 | expect(editor.getSelections().length).toBe(4) 361 | expect(editor.getSelections().every(s => !s.isReversed())).toBe(true) 362 | }) 363 | }); 364 | 365 | describe("find-and-replace:select-undo", () => { 366 | describe("when there is no selection", () => { 367 | it("does nothing", () => { 368 | editor.setText(dedent` 369 | for 370 | information 371 | format 372 | another for 373 | fork 374 | a 3rd for is here 375 | `); 376 | 377 | atom.commands.dispatch(editorElement, 'find-and-replace:select-undo'); 378 | expect(editor.getSelectedBufferRanges()).toEqual([ 379 | [[0, 0], [0, 0]] 380 | ]); 381 | }) 382 | }); 383 | 384 | describe("when a word is selected", () => { 385 | it("unselects current word", () => { 386 | editor.setText(dedent` 387 | for 388 | information 389 | format 390 | another for 391 | fork 392 | a 3rd for is here 393 | `); 394 | 395 | editor.setSelectedBufferRange([[3, 8], [3, 11]]); 396 | 397 | atom.commands.dispatch(editorElement, 'find-and-replace:select-undo'); 398 | expect(editor.getSelectedBufferRanges()).toEqual([ 399 | [[3, 11], [3, 11]] 400 | ]); 401 | }) 402 | }); 403 | 404 | describe("when two words are selected", () => { 405 | it("unselects words in order", () => { 406 | editor.setText(dedent` 407 | for 408 | information 409 | format 410 | another for 411 | fork 412 | a 3rd for is here 413 | `); 414 | 415 | editor.setSelectedBufferRange([[3, 8], [3, 11]]); 416 | 417 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 418 | atom.commands.dispatch(editorElement, 'find-and-replace:select-undo'); 419 | expect(editor.getSelectedBufferRanges()).toEqual([ 420 | [[3, 8], [3, 11]] 421 | ]); 422 | 423 | atom.commands.dispatch(editorElement, 'find-and-replace:select-undo'); 424 | expect(editor.getSelectedBufferRanges()).toEqual([ 425 | [[3, 11], [3, 11]] 426 | ]); 427 | }) 428 | }); 429 | 430 | describe("when three words are selected", () => { 431 | it("unselects words in order", () => { 432 | editor.setText(dedent` 433 | for 434 | information 435 | format 436 | another for 437 | fork 438 | a 3rd for is here 439 | `); 440 | 441 | editor.setCursorBufferPosition([0, 0]); 442 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 443 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 444 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 445 | 446 | atom.commands.dispatch(editorElement, 'find-and-replace:select-undo'); 447 | expect(editor.getSelectedBufferRanges()).toEqual([ 448 | [[0, 0], [0, 3]], 449 | [[3, 8], [3, 11]] 450 | ]); 451 | 452 | atom.commands.dispatch(editorElement, 'find-and-replace:select-undo'); 453 | expect(editor.getSelectedBufferRanges()).toEqual([ 454 | [[0, 0], [0, 3]] 455 | ]); 456 | }) 457 | }); 458 | 459 | describe("when starting at the bottom word", () => { 460 | it("unselects words in order", () => { 461 | editor.setText(dedent` 462 | for 463 | information 464 | format 465 | another for 466 | fork 467 | a 3rd for is here 468 | `); 469 | 470 | editor.setCursorBufferPosition([5, 7]); 471 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 472 | expect(editor.getSelectedBufferRanges()).toEqual([ 473 | [[5, 6], [5, 9]] 474 | ]); 475 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 476 | expect(editor.getSelectedBufferRanges()).toEqual([ 477 | [[5, 6], [5, 9]], 478 | [[0, 0], [0, 3]] 479 | ]); 480 | atom.commands.dispatch(editorElement, 'find-and-replace:select-undo'); 481 | expect(editor.getSelectedBufferRanges()).toEqual([ 482 | [[5, 6], [5, 9]] 483 | ]);}); 484 | 485 | it("doesn't stack previously selected", () => { 486 | editor.setText(dedent` 487 | for 488 | information 489 | format 490 | another for 491 | fork 492 | a 3rd for is here 493 | `); 494 | 495 | editor.setCursorBufferPosition([5, 7]); 496 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 497 | expect(editor.getSelectedBufferRanges()).toEqual([ 498 | [[5, 6], [5, 9]] 499 | ]); 500 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 501 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 502 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 503 | atom.commands.dispatch(editorElement, 'find-and-replace:select-undo'); 504 | expect(editor.getSelectedBufferRanges()).toEqual([ 505 | [[5, 6], [5, 9]], 506 | [[0, 0], [0, 3]] 507 | ]); 508 | }); 509 | }); 510 | }); 511 | 512 | describe("find-and-replace:select-skip", () => { 513 | describe("when there is no selection", () => { 514 | it("does nothing", () => { 515 | editor.setText(dedent` 516 | for 517 | information 518 | format 519 | another for 520 | fork 521 | a 3rd for is here 522 | `); 523 | 524 | atom.commands.dispatch(editorElement, 'find-and-replace:select-skip'); 525 | expect(editor.getSelectedBufferRanges()).toEqual([ 526 | [[0, 0], [0, 0]] 527 | ]); 528 | }) 529 | }); 530 | 531 | describe("when a word is selected", () => { 532 | it("unselects current word and selects next match", () => { 533 | editor.setText(dedent` 534 | for 535 | information 536 | format 537 | another for 538 | fork 539 | a 3rd for is here 540 | `); 541 | 542 | editor.setCursorBufferPosition([3, 8]); 543 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 544 | expect(editor.getSelectedBufferRanges()).toEqual([ 545 | [[3, 8], [3, 11]] 546 | ]); 547 | 548 | atom.commands.dispatch(editorElement, 'find-and-replace:select-skip'); 549 | expect(editor.getSelectedBufferRanges()).toEqual([ 550 | [[5, 6], [5, 9]] 551 | ]); 552 | }) 553 | }); 554 | 555 | describe("when two words are selected", () => { 556 | it("unselects second word and selects next match", () => { 557 | editor.setText(dedent` 558 | for 559 | information 560 | format 561 | another for 562 | fork 563 | a 3rd for is here 564 | `); 565 | 566 | editor.setCursorBufferPosition([0, 0]); 567 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 568 | expect(editor.getSelectedBufferRanges()).toEqual([ 569 | [[0, 0], [0, 3]] 570 | ]); 571 | 572 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 573 | atom.commands.dispatch(editorElement, 'find-and-replace:select-skip'); 574 | expect(editor.getSelectedBufferRanges()).toEqual([ 575 | [[0, 0], [0, 3]], 576 | [[5, 6], [5, 9]] 577 | ]); 578 | 579 | atom.commands.dispatch(editorElement, 'find-and-replace:select-skip'); 580 | expect(editor.getSelectedBufferRanges()).toEqual([ 581 | [[0, 0], [0, 3]] 582 | ]); 583 | }); 584 | }); 585 | 586 | describe("when starting at the bottom word", () => { 587 | it("unselects second word and selects next match", () => { 588 | editor.setText(dedent` 589 | for 590 | information 591 | format 592 | another for 593 | fork 594 | a 3rd for is here 595 | `); 596 | 597 | editor.setCursorBufferPosition([5, 7]); 598 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 599 | expect(editor.getSelectedBufferRanges()).toEqual([ 600 | [[5, 6], [5, 9]] 601 | ]); 602 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next'); 603 | atom.commands.dispatch(editorElement, 'find-and-replace:select-skip'); 604 | expect(editor.getSelectedBufferRanges()).toEqual([ 605 | [[5, 6], [5, 9]], 606 | [[3, 8], [3, 11]] 607 | ]); 608 | }); 609 | }); 610 | 611 | it('honors the reversed orientation of previous selections', () => { 612 | editor.setText('ab ab ab ab') 613 | editor.setSelectedBufferRange([[0, 0], [0, 2]], {reversed: true}) 614 | 615 | atom.commands.dispatch(editorElement, 'find-and-replace:select-skip') 616 | expect(editor.getSelections().length).toBe(1) 617 | expect(editor.getSelections().every(s => s.isReversed())).toBe(true) 618 | 619 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next') 620 | atom.commands.dispatch(editorElement, 'find-and-replace:select-skip') 621 | expect(editor.getSelections().length).toBe(2) 622 | expect(editor.getSelections().every(s => s.isReversed())).toBe(true) 623 | 624 | editor.setSelectedBufferRange([[0, 0], [0, 2]], {reversed: false}) 625 | 626 | atom.commands.dispatch(editorElement, 'find-and-replace:select-skip') 627 | expect(editor.getSelections().length).toBe(1) 628 | expect(editor.getSelections().every(s => !s.isReversed())).toBe(true) 629 | 630 | atom.commands.dispatch(editorElement, 'find-and-replace:select-next') 631 | atom.commands.dispatch(editorElement, 'find-and-replace:select-skip') 632 | expect(editor.getSelections().length).toBe(2) 633 | expect(editor.getSelections().every(s => !s.isReversed())).toBe(true) 634 | }) 635 | }); 636 | }); 637 | -------------------------------------------------------------------------------- /spec/setup-spec.js: -------------------------------------------------------------------------------- 1 | require('etch').setScheduler({ 2 | updateDocument(callback) { callback(); }, 3 | getNextUpdatePromise() { return Promise.resolve(); } 4 | }); 5 | 6 | // The CI on Atom has been failing with this package. Experiments 7 | // indicate the dev tools were being opened, which caused test 8 | // failures. Dev tools are meant to be triggered on an uncaught 9 | // exception. 10 | // 11 | // These failures are flaky though, so an exact cause 12 | // has not yet been found. For now the following is added 13 | // to reduce the number of flaky failures, which have been 14 | // causing false failures on unrelated Atom PRs. 15 | // 16 | // See more in https://github.com/atom/atom/pull/21335 17 | global.beforeEach(() => { 18 | spyOn(atom, 'openDevTools').andReturn((console.error("ERROR: Dev tools attempted to open"), Promise.resolve())); 19 | }); 20 | -------------------------------------------------------------------------------- /styles/find-and-replace.less: -------------------------------------------------------------------------------- 1 | @import "ui-variables"; 2 | @import "syntax-variables"; 3 | 4 | // result markers 5 | atom-text-editor { 6 | .find-result .region { 7 | background-color: transparent; 8 | border-radius: @component-border-radius; 9 | border: 1px solid @syntax-result-marker-color; 10 | box-sizing: border-box; 11 | z-index: 0; 12 | } 13 | 14 | .current-result .region { 15 | border-radius: @component-border-radius; 16 | border: 1px solid @syntax-result-marker-color-selected; 17 | box-sizing: border-box; 18 | z-index: 0; 19 | } 20 | 21 | .find-result, 22 | .current-result { 23 | display: none; 24 | } 25 | } 26 | 27 | atom-workspace.find-visible { 28 | atom-text-editor { 29 | .find-result, 30 | .current-result { 31 | display: block; 32 | } 33 | } 34 | } 35 | 36 | // Both project and buffer FNR styles 37 | .find-and-replace, 38 | .preview-pane, 39 | .project-find { 40 | @min-width: 200px; // min width before it starts scrolling 41 | 42 | -webkit-user-select: none; 43 | padding: @component-padding/2; 44 | overflow-x: auto; 45 | 46 | .header { 47 | padding: @component-padding/4 @component-padding/2; 48 | min-width: @min-width; 49 | line-height: 1.75; 50 | } 51 | .header-item { 52 | margin: @component-padding/4 0; 53 | } 54 | 55 | .input-block { 56 | display: flex; 57 | flex-wrap: wrap; 58 | width: 100%; 59 | min-width: @min-width; 60 | } 61 | .input-block-item { 62 | display: flex; 63 | flex: 1; 64 | padding: @component-padding / 2; 65 | } 66 | 67 | .btn-group { 68 | display: flex; 69 | flex: 1; 70 | .btn { 71 | flex: 1; 72 | } 73 | & + .btn-group { 74 | margin-left: @component-padding; 75 | } 76 | } 77 | 78 | .btn > .icon { 79 | width: 20px; 80 | height: 16px; 81 | vertical-align: middle; 82 | fill: currentColor; 83 | stroke: currentColor; 84 | pointer-events: none; 85 | } 86 | 87 | .close-button { 88 | margin-left: @component-padding; 89 | cursor: pointer; 90 | color: @text-color-subtle; 91 | &:hover { 92 | color: @text-color-highlight; 93 | } 94 | .icon::before { 95 | margin-right: 0; 96 | text-align: center; 97 | vertical-align: middle; 98 | } 99 | } 100 | 101 | .description { 102 | display: inline-block; 103 | .subtle-info-message { 104 | padding-left: 5px; 105 | color: @text-color-subtle; 106 | .highlight { 107 | color: @text-color; 108 | font-weight: normal; 109 | } 110 | } 111 | } 112 | 113 | .options-label { 114 | color: @text-color-subtle; 115 | position: relative; 116 | .options { 117 | margin-right: .5em; 118 | color: @text-color; 119 | } 120 | } 121 | 122 | .btn-group-options { 123 | display: inline-flex; 124 | margin-top: -.1em; 125 | 126 | .btn { 127 | width: 36px; 128 | padding: 0; 129 | line-height: 1.75; 130 | } 131 | } 132 | 133 | .editor-container { 134 | position: relative; 135 | atom-text-editor { 136 | width: 100%; 137 | } 138 | } 139 | 140 | } 141 | 142 | // Buffer find and replace 143 | .find-and-replace { 144 | @input-width: 260px; 145 | @block-width: 260px; 146 | 147 | .input-block-item { 148 | flex: 1 1 @block-width; 149 | } 150 | .input-block-item--flex { 151 | flex: 100 1 @input-width; 152 | } 153 | 154 | .btn-group-find, 155 | .btn-group-replace { 156 | flex: 1; 157 | } 158 | 159 | .btn-group-find-all, 160 | .btn-group-replace-all { 161 | flex: 2; 162 | } 163 | 164 | .find-container atom-text-editor { 165 | padding-right: 64px; // leave some room for the results count 166 | } 167 | 168 | // results count 169 | .find-meta-container { 170 | position: absolute; 171 | top: 1px; 172 | right: 0; 173 | margin: @component-padding/2 @component-padding/2 0 0; 174 | z-index: 2; 175 | font-size: .9em; 176 | line-height: @component-line-height; 177 | pointer-events: none; 178 | .result-counter { 179 | margin-right: @component-padding; 180 | } 181 | } 182 | } 183 | 184 | .find-wrap-icon { 185 | @wrap-size: @font-size * 10; 186 | 187 | opacity: 0; 188 | transition: opacity 0.5s; 189 | &.visible { opacity: 1; } 190 | 191 | position: absolute; 192 | 193 | // These are getting placed in the DOM as a pane item, so override the pane 194 | // item positioning styles. :/ 195 | top: 50% !important; 196 | left: 50% !important; 197 | right: initial !important; 198 | bottom: initial !important; 199 | 200 | margin-top: @wrap-size * -0.5; 201 | margin-left: @wrap-size * -0.5; 202 | 203 | background: fadeout(darken(@syntax-background-color, 4%), 55%); 204 | border-radius: @component-border-radius * 2; 205 | text-align: center; 206 | pointer-events: none; 207 | &:before { 208 | // Octicons look best in sizes that are multiples of 16px 209 | font-size: @wrap-size - mod(@wrap-size, 16px) - 32px; 210 | line-height: @wrap-size; 211 | height: @wrap-size; 212 | width: @wrap-size; 213 | color: @syntax-text-color; 214 | opacity: .5; 215 | } 216 | } 217 | 218 | // Project find and replace 219 | .project-find { 220 | @project-input-width: 260px; 221 | @project-block-width: 160px; 222 | 223 | .input-block-item { 224 | flex: 1 1 @project-block-width; 225 | } 226 | .input-block-item--flex { 227 | flex: 100 1 @project-input-width; 228 | } 229 | 230 | .loading, 231 | .preview-block, 232 | .error-messages, 233 | .filter-container { 234 | display: none; 235 | } 236 | } 237 | 238 | .preview-pane { 239 | position: relative; 240 | display: flex; 241 | flex-direction: column; 242 | padding: 0; 243 | 244 | .preview-header { 245 | display: flex; 246 | flex-wrap: wrap; 247 | padding: @component-padding/2; 248 | align-items: center; 249 | justify-content: space-between; 250 | overflow: hidden; 251 | font-weight: normal; 252 | border-bottom: 1px solid @panel-heading-border-color; 253 | background-color: @panel-heading-background-color; 254 | } 255 | 256 | .preview-count { 257 | margin: @component-padding/2; 258 | } 259 | 260 | .preview-controls { 261 | display: flex; 262 | flex-wrap: wrap; 263 | .btn-group { 264 | margin: @component-padding/2; 265 | } 266 | } 267 | 268 | .loading-spinner-tiny, 269 | .loading-spinner-tiny + .inline-block { 270 | vertical-align: middle; 271 | } 272 | 273 | .no-results-overlay { 274 | visibility: hidden; 275 | } 276 | 277 | &.no-results .no-results-overlay { 278 | visibility: visible; 279 | } 280 | 281 | .results-view { 282 | overflow: auto; 283 | position: relative; 284 | flex: 1; 285 | 286 | &-container { 287 | // adds some padding 288 | // so the last item can be clicked 289 | // when there is a horizontal scrollbar -> #943 290 | padding-bottom: @component-padding; 291 | } 292 | 293 | .list-item { 294 | padding: 0 0 0 @component-padding; 295 | } 296 | .context-row, .match-row { 297 | padding: 0 0 0 @component-padding; 298 | margin-left: 8px; 299 | 300 | box-shadow: inset 0 1px 0 mix(@base-border-color, @base-background-color); 301 | 302 | // box-shadow over a border is used to not affect height calculation 303 | &.separator { 304 | box-shadow: inset 0 1px 0 @base-border-color; 305 | } 306 | } 307 | 308 | .line-number { 309 | margin-right: 1ex; 310 | text-align: right; 311 | display: inline-block; 312 | } 313 | .match-row.selected .line-number { 314 | color: @text-color-selected; 315 | } 316 | 317 | .path-match-number { 318 | padding-left: @component-padding; 319 | color: @text-color-subtle; 320 | } 321 | 322 | .preview { 323 | word-break: break-all; 324 | white-space: pre; 325 | color: @text-color-subtle; 326 | } 327 | 328 | .match-row .preview { 329 | color: @text-color-highlight; 330 | } 331 | .match-row.selected .preview { 332 | color: @text-color-selected; 333 | } 334 | 335 | 336 | .selected { 337 | .highlight-info { 338 | box-shadow: inset 0 0 1px lighten(@background-color-info, 50%); 339 | } 340 | 341 | .highlight-error { 342 | box-shadow: inset 0 0 1px lighten(@background-color-error, 25%); 343 | } 344 | 345 | .highlight-success { 346 | box-shadow: inset 0 0 1px lighten(@background-color-success, 25%); 347 | } 348 | } 349 | } 350 | } 351 | 352 | .find-container atom-text-editor, .replace-container atom-text-editor { 353 | // Styles for regular expression highlighting 354 | .syntax--regexp { 355 | .syntax--escape { 356 | color: @text-color-info; 357 | } 358 | .syntax--range, .syntax--character-class, .syntax--wildcard { 359 | color: @text-color-success; 360 | } 361 | .syntax--wildcard { 362 | font-weight: bold; 363 | } 364 | .syntax--set { 365 | color: inherit; 366 | } 367 | .syntax--keyword, .syntax--punctuation { 368 | color: @text-color-error; 369 | font-weight: normal; 370 | } 371 | 372 | .syntax--replacement.syntax--variable { 373 | color: @text-color-warning; 374 | } 375 | } 376 | } 377 | --------------------------------------------------------------------------------