From b830254d055b59a0a36d91b2406136c42fc69d8d Mon Sep 17 00:00:00 2001 From: Trevor Date: Tue, 24 Oct 2017 15:31:03 -0600 Subject: [PATCH] Add 'Find in Open Files' feature --- lib/find-options.coffee | 2 + lib/find.coffee | 43 +- lib/open-files-find-view.js | 508 +++++++++++ lib/project/results-model.js | 108 +++ lib/project/results-pane.js | 4 +- menus/find-and-replace.cson | 3 + package.json | 3 + spec/open-files-find-view-spec.js | 1392 +++++++++++++++++++++++++++++ styles/find-and-replace.less | 3 + 9 files changed, 2052 insertions(+), 14 deletions(-) create mode 100644 lib/open-files-find-view.js create mode 100644 spec/open-files-find-view-spec.js diff --git a/lib/find-options.coffee b/lib/find-options.coffee index ee9feaca..f40aa8c3 100644 --- a/lib/find-options.coffee +++ b/lib/find-options.coffee @@ -4,6 +4,7 @@ _ = require 'underscore-plus' Params = [ 'findPattern' 'replacePattern' + 'paths' 'pathsPattern' 'useRegex' 'wholeWord' @@ -20,6 +21,7 @@ class FindOptions @findPattern = '' @replacePattern = state.replacePattern ? '' + @paths = state.paths ? [] @pathsPattern = state.pathsPattern ? '' @useRegex = state.useRegex ? atom.config.get('find-and-replace.useRegex') ? false @caseSensitive = state.caseSensitive ? atom.config.get('find-and-replace.caseSensitive') ? false diff --git a/lib/find.coffee b/lib/find.coffee index efe7d746..9c547c95 100644 --- a/lib/find.coffee +++ b/lib/find.coffee @@ -6,6 +6,7 @@ FindOptions = require './find-options' BufferSearch = require './buffer-search' FileIcons = require './file-icons' FindView = require './find-view' +OpenFilesFindView = require './open-files-find-view' ProjectFindView = require './project-find-view' ResultsModel = require './project/results-model' ResultsPaneView = require './project/results-pane' @@ -35,39 +36,48 @@ module.exports = else @findModel.setEditor(null) - @subscriptions.add atom.commands.add '.find-and-replace, .project-find', 'window:focus-next-pane', -> + @subscriptions.add atom.commands.add '.find-and-replace, .open-files-find, .project-find', 'window:focus-next-pane', -> atom.views.getView(atom.workspace).focus() + @subscriptions.add atom.commands.add 'atom-workspace', 'open-files-find:show', => + @createViews() + showPanel @openFilesFindPanel, => @openFilesFindView.focusFindElement() + + @subscriptions.add atom.commands.add 'atom-workspace', 'open-files-find:toggle', => + @createViews() + togglePanel @openFilesFindPanel, => @openFilesFindView.focusFindElement() + @subscriptions.add atom.commands.add 'atom-workspace', 'project-find:show', => @createViews() - showPanel @projectFindPanel, @findPanel, => @projectFindView.focusFindElement() + showPanel @projectFindPanel, => @projectFindView.focusFindElement() @subscriptions.add atom.commands.add 'atom-workspace', 'project-find:toggle', => @createViews() - togglePanel @projectFindPanel, @findPanel, => @projectFindView.focusFindElement() + togglePanel @projectFindPanel, => @projectFindView.focusFindElement() @subscriptions.add atom.commands.add 'atom-workspace', 'project-find:show-in-current-directory', ({target}) => @createViews() @findPanel.hide() + @openFilesFindPanel.hide() @projectFindPanel.show() @projectFindView.focusFindElement() @projectFindView.findInCurrentlySelectedDirectory(target) @subscriptions.add atom.commands.add 'atom-workspace', 'find-and-replace:use-selection-as-find-pattern', => - return if @projectFindPanel?.isVisible() or @findPanel?.isVisible() + return if @openFilesFindPanel?.isVisible() or @projectFindPanel?.isVisible() or @findPanel?.isVisible() @createViews() @subscriptions.add atom.commands.add 'atom-workspace', 'find-and-replace:toggle', => @createViews() - togglePanel @findPanel, @projectFindPanel, => @findView.focusFindEditor() + togglePanel @findPanel, => @findView.focusFindEditor() @subscriptions.add atom.commands.add 'atom-workspace', 'find-and-replace:show', => @createViews() - showPanel @findPanel, @projectFindPanel, => @findView.focusFindEditor() + showPanel @findPanel, => @findView.focusFindEditor() @subscriptions.add atom.commands.add 'atom-workspace', 'find-and-replace:show-replace', => @createViews() - showPanel @findPanel, @projectFindPanel, => @findView.focusReplaceEditor() + showPanel @findPanel, => @findView.focusReplaceEditor() @subscriptions.add atom.commands.add 'atom-workspace', 'find-and-replace:clear-history', => @findHistory.clear() @@ -78,6 +88,7 @@ module.exports = isMiniEditor = target.tagName is 'ATOM-TEXT-EDITOR' and target.hasAttribute('mini') unless isMiniEditor @findPanel?.hide() + @openFilesFindPanel?.hide() @projectFindPanel?.hide() @subscriptions.add atom.commands.add 'atom-workspace', @@ -93,13 +104,13 @@ module.exports = @selectNextObjects.set(editor, selectNext) selectNext - showPanel = (panelToShow, panelToHide, postShowAction) -> - panelToHide.hide() + showPanel = (panelToShow, postShowAction) => + @panels.map (p) => p.hide() unless p is panelToShow panelToShow.show() postShowAction?() - togglePanel = (panelToToggle, panelToHide, postToggleAction) -> - panelToHide.hide() + togglePanel = (panelToToggle, postToggleAction) => + @panels.map (p) => p.hide() unless p is panelToToggle if panelToToggle.isVisible() panelToToggle.hide() @@ -159,13 +170,16 @@ module.exports = options = {findBuffer, replaceBuffer, pathsBuffer, findHistoryCycler, replaceHistoryCycler, pathsHistoryCycler} @findView = new FindView(@findModel, options) - + @openFilesFindView = new OpenFilesFindView(@resultsModel, options) @projectFindView = new ProjectFindView(@resultsModel, options) @findPanel = atom.workspace.addBottomPanel(item: @findView, visible: false, className: 'tool-panel panel-bottom') + @openFilesFindPanel = atom.workspace.addBottomPanel(item: @openFilesFindView, visible: false, className: 'tool-panel panel-bottom') @projectFindPanel = atom.workspace.addBottomPanel(item: @projectFindView, visible: false, className: 'tool-panel panel-bottom') + @panels = [@findPanel, @openFilesFindPanel, @projectFindPanel] @findView.setPanel(@findPanel) + @openFilesFindView.setPanel(@openFilesFindPanel) @projectFindView.setPanel(@projectFindPanel) # HACK: Soooo, we need to get the model to the pane view whenever it is @@ -191,6 +205,11 @@ module.exports = @findModel?.destroy() @findModel = null + @openFilesFindPanel?.destroy() + @openFilesFindPanel = null + @openFilesFindView?.destroy() + @openFilesFindView = null + @projectFindPanel?.destroy() @projectFindPanel = null @projectFindView?.destroy() diff --git a/lib/open-files-find-view.js b/lib/open-files-find-view.js new file mode 100644 index 00000000..e7294151 --- /dev/null +++ b/lib/open-files-find-view.js @@ -0,0 +1,508 @@ +const fs = require('fs-plus'); +const path = require('path'); +const _ = require('underscore-plus'); +const { TextEditor, Disposable, CompositeDisposable } = require('atom'); +const etch = require('etch'); +const Util = require('./project/util'); +const ResultsModel = require('./project/results-model'); +const ResultsPaneView = require('./project/results-pane'); +const $ = etch.dom; + +module.exports = +class OpenFilesFindView { + constructor(model, {findBuffer, replaceBuffer, findHistoryCycler, replaceHistoryCycler}) { + this.model = model + this.findBuffer = findBuffer + this.replaceBuffer = replaceBuffer + this.findHistoryCycler = findHistoryCycler; + this.replaceHistoryCycler = replaceHistoryCycler; + this.subscriptions = new CompositeDisposable() + + etch.initialize(this) + + this.handleEvents(); + + this.findHistoryCycler.addEditorElement(this.findEditor.element); + this.replaceHistoryCycler.addEditorElement(this.replaceEditor.element); + + this.onlyRunIfChanged = true; + + this.clearMessages(); + this.updateOptionViews(); + } + + update() {} + + render() { + return ( + $.div({tabIndex: -1, className: 'open-files-find padded'}, + $.header({className: 'header'}, + $.span({ref: 'closeButton', className: 'header-item close-button pull-right'}, + $.i({className: "icon icon-x clickable"}) + ), + $.span({ref: 'descriptionLabel', className: 'header-item description'}), + $.span({className: 'header-item options-label pull-right'}, + $.span({}, 'Finding with Options: '), + $.span({ref: 'optionsLabel', className: 'options'}), + $.span({className: 'btn-group btn-toggle btn-group-options'}, + $.button({ref: 'regexOptionButton', className: 'btn option-regex'}, + $.svg({className: "icon", innerHTML: ``}) + ), + $.button({ref: 'caseOptionButton', className: 'btn option-case-sensitive'}, + $.svg({className: "icon", innerHTML: ``}) + ), + $.button({ref: 'wholeWordOptionButton', className: 'btn option-whole-word'}, + $.svg({className: "icon", innerHTML:``}) + ) + ) + ) + ), + + $.section({ref: 'replacmentInfoBlock', className: 'input-block'}, + $.progress({ref: 'replacementProgress', className: 'inline-block'}), + $.span({ref: 'replacmentInfo', className: 'inline-block'}, 'Replaced 2 files of 10 files') + ), + + $.section({className: 'input-block find-container'}, + $.div({className: 'input-block-item input-block-item--flex editor-container'}, + etch.dom(TextEditor, { + ref: 'findEditor', + mini: true, + placeholderText: 'Find in open files', + buffer: this.findBuffer + }) + ), + $.div({className: 'input-block-item'}, + $.div({className: 'btn-group btn-group-find'}, + $.button({ref: 'findAllButton', className: 'btn'}, 'Find All') + ) + ) + ), + + $.section({className: 'input-block replace-container'}, + $.div({className: 'input-block-item input-block-item--flex editor-container'}, + etch.dom(TextEditor, { + ref: 'replaceEditor', + mini: true, + placeholderText: 'Replace in open files', + buffer: this.replaceBuffer + }) + ), + $.div({className: 'input-block-item'}, + $.div({className: 'btn-group btn-group-replace-all'}, + $.button({ref: 'replaceAllButton', className: 'btn disabled'}, 'Replace All') + ) + ) + ) + ) + ); + } + + get findEditor() { return this.refs.findEditor } + get replaceEditor() { return this.refs.replaceEditor } + + destroy() { + if (this.subscriptions) this.subscriptions.dispose(); + if (this.tooltipSubscriptions) this.tooltipSubscriptions.dispose(); + } + + setPanel(panel) { + this.panel = panel; + this.subscriptions.add(this.panel.onDidChangeVisible(visible => { + if (visible) { + this.didShow(); + } else { + this.didHide(); + } + })); + } + + didShow() { + atom.views.getView(atom.workspace).classList.add('find-visible'); + if (this.tooltipSubscriptions != null) { return; } + + this.updateReplaceAllButtonEnablement(); + + this.subscriptions.add(atom.workspace.onDidDestroyPaneItem(({ item }) => { + if (this.model.getFindOptions().openFiles && item && item.constructor.name === 'TextEditor' + && atom.workspace.getActivePaneItem().constructor.name === 'ResultsPaneView') { + this.search({onlyRunIfActive: true, openFiles: true}); + } + })); + + // this.subscriptions.add(atom.workspace.onDidAddTextEditor(() => { + // if (this.model.getFindOptions().openFiles) { + // this.search({onlyRunIfActive: true, openFiles: true}); + // } + // })); + + this.tooltipSubscriptions = new CompositeDisposable( + atom.tooltips.add(this.refs.closeButton, { + title: 'Close Panel Esc', + html: true + }), + + atom.tooltips.add(this.refs.regexOptionButton, { + title: "Use Regex", + keyBindingCommand: 'open-files-find:toggle-regex-option', + keyBindingTarget: this.findEditor.element + }), + + atom.tooltips.add(this.refs.caseOptionButton, { + title: "Match Case", + keyBindingCommand: 'open-files-find:toggle-case-option', + keyBindingTarget: this.findEditor.element + }), + + atom.tooltips.add(this.refs.wholeWordOptionButton, { + title: "Whole Word", + keyBindingCommand: 'open-files-find:toggle-whole-word-option', + keyBindingTarget: this.findEditor.element + }), + + atom.tooltips.add(this.refs.findAllButton, { + title: "Find All", + keyBindingCommand: 'find-and-replace:search', + keyBindingTarget: this.findEditor.element + }) + ); + } + + didHide() { + this.hideAllTooltips(); + let workspaceElement = atom.views.getView(atom.workspace); + workspaceElement.focus(); + workspaceElement.classList.remove('find-visible'); + } + + hideAllTooltips() { + this.tooltipSubscriptions.dispose(); + this.tooltipSubscriptions = null; + } + + handleEvents() { + this.subscriptions.add(atom.commands.add('atom-workspace', { + 'find-and-replace:use-selection-as-find-pattern': () => this.setSelectionAsFindPattern() + })); + + this.subscriptions.add(atom.commands.add(this.element, { + 'find-and-replace:focus-next': () => this.focusNextElement(1), + 'find-and-replace:focus-previous': () => this.focusNextElement(-1), + 'core:confirm': () => this.confirm(), + 'core:close': () => this.panel && this.panel.hide(), + 'core:cancel': () => this.panel && this.panel.hide(), + 'open-files-find:confirm': () => this.confirm(), + 'open-files-find:toggle-regex-option': () => this.toggleRegexOption(), + 'open-files-find:toggle-case-option': () => this.toggleCaseOption(), + 'open-files-find:toggle-whole-word-option': () => this.toggleWholeWordOption(), + 'open-files-find:replace-all': () => this.replaceAll() + })); + + let updateInterfaceForSearching = () => { + this.setInfoMessage('Searching...'); + }; + + let updateInterfaceForResults = results => { + if (results.matchCount === 0 && results.findPattern === '') { + this.clearMessages(); + } else { + this.generateResultsMessage(results); + } + this.updateReplaceAllButtonEnablement(results); + }; + + let resetInterface = () => { + this.clearMessages(); + this.updateReplaceAllButtonEnablement(null); + }; + + let afterSearch = () => { + if (atom.config.get('find-and-replace.closeFindPanelAfterSearch')) { + this.panel && this.panel.hide(); + } + } + + let searchFinished = results => { + afterSearch(); + updateInterfaceForResults(results); + }; + + this.subscriptions.add(this.model.onDidClear(resetInterface)); + this.subscriptions.add(this.model.onDidClearReplacementState(updateInterfaceForResults)); + this.subscriptions.add(this.model.onDidStartSearching(updateInterfaceForSearching)); + this.subscriptions.add(this.model.onDidNoopSearch(afterSearch)); + this.subscriptions.add(this.model.onDidFinishSearching(updateInterfaceForResults)); + this.subscriptions.add(this.model.getFindOptions().onDidChange(this.updateOptionViews.bind(this))); + + this.element.addEventListener('focus', () => this.findEditor.element.focus()); + this.refs.closeButton.addEventListener('click', () => this.panel && this.panel.hide()); + this.refs.regexOptionButton.addEventListener('click', () => this.toggleRegexOption()); + this.refs.caseOptionButton.addEventListener('click', () => this.toggleCaseOption()); + this.refs.wholeWordOptionButton.addEventListener('click', () => this.toggleWholeWordOption()); + this.refs.replaceAllButton.addEventListener('click', () => this.replaceAll()); + this.refs.findAllButton.addEventListener('click', () => this.search()); + + const focusCallback = () => this.onlyRunIfChanged = false; + window.addEventListener('focus', focusCallback); + this.subscriptions.add(new Disposable(() => window.removeEventListener('focus', focusCallback))) + + this.findEditor.getBuffer().onDidChange(() => { + this.updateReplaceAllButtonEnablement(this.model.getResultsSummary()); + }); + this.handleEventsForReplace(); + } + + handleEventsForReplace() { + this.replaceEditor.getBuffer().onDidChange(() => this.model.clearReplacementState()); + this.replaceEditor.onDidStopChanging(() => this.model.getFindOptions().set({replacePattern: this.replaceEditor.getText()})); + this.replacementsMade = 0; + this.subscriptions.add(this.model.onDidStartReplacing(promise => { + this.replacementsMade = 0; + this.refs.replacmentInfoBlock.style.display = ''; + this.refs.replacementProgress.removeAttribute('value'); + })); + + this.subscriptions.add(this.model.onDidReplacePath(result => { + this.replacementsMade++; + this.refs.replacementProgress.value = this.replacementsMade / this.model.getPathCount(); + this.refs.replacmentInfo.textContent = `Replaced ${this.replacementsMade} of ${_.pluralize(this.model.getPathCount(), 'open file')}`; + })); + + this.subscriptions.add(this.model.onDidFinishReplacing(result => this.onFinishedReplacing(result))); + } + + focusNextElement(direction) { + const elements = [ + this.findEditor.element, + this.replaceEditor.element + ]; + + let focusedIndex = elements.findIndex(el => el.hasFocus()) + direction; + if (focusedIndex >= elements.length) focusedIndex = 0; + if (focusedIndex < 0) focusedIndex = elements.length - 1; + + elements[focusedIndex].focus(); + elements[focusedIndex].getModel().selectAll(); + } + + focusFindElement() { + const activeEditor = atom.workspace.getActiveTextEditor(); + let selectedText = activeEditor && activeEditor.getSelectedText() + if (selectedText && selectedText.indexOf('\n') < 0) { + if (this.model.getFindOptions().useRegex) { + selectedText = Util.escapeRegex(selectedText); + } + this.findEditor.setText(selectedText); + } + this.findEditor.getElement().focus(); + this.findEditor.selectAll(); + } + + confirm() { + if (this.findEditor.getText().length === 0) { + this.model.clear(); + return; + } + + this.findHistoryCycler.store(); + this.replaceHistoryCycler.store(); + + let searchPromise = this.search({onlyRunIfChanged: this.onlyRunIfChanged}); + this.onlyRunIfChanged = true; + return searchPromise; + } + + getOpenFilePaths() { + return atom.workspace.getTextEditors().map(editor => editor.getPath()); + } + + search(options) { + // We always want to set the options passed in, even if we dont end up doing the search + if (options == null) { options = {}; } + this.model.getFindOptions().set(options); + + let findPattern = this.findEditor.getText(); + let replacePattern = this.replaceEditor.getText(); + let paths = this.getOpenFilePaths(); + + let {onlyRunIfActive, onlyRunIfChanged} = options; + if ((onlyRunIfActive && !this.model.active) || !findPattern) return Promise.resolve(); + + return this.showResultPane().then(() => { + try { + return this.model.searchPaths(findPattern, paths, replacePattern, options); + } catch (e) { + this.setErrorMessage(e.message); + } + }); + } + + replaceAll() { + if (!this.model.matchCount) { + atom.beep(); + return; + } + + const findPattern = this.model.getLastFindPattern(); + const currentPattern = this.findEditor.getText(); + if (findPattern && findPattern !== currentPattern) { + atom.confirm({ + message: `The searched pattern '${findPattern}' was changed to '${currentPattern}'`, + detailedMessage: `Please run the search with the new pattern '${currentPattern}' before running a replace-all`, + buttons: ['OK'] + }); + return; + } + + return this.showResultPane().then(() => { + const replacePattern = this.replaceEditor.getText(); + + // TODO: What happens when there are no open text editors? + + const message = `This will replace '${findPattern}' with '${replacePattern}' ${_.pluralize(this.model.matchCount, 'time')} in ${_.pluralize(this.model.pathCount, 'open file')}`; + const buttonChosen = atom.confirm({ + message: 'Are you sure you want to replace all?', + detailedMessage: message, + buttons: ['OK', 'Cancel'] + }); + + if (buttonChosen === 0) { + this.clearMessages(); + return this.model.replacePaths(replacePattern, this.model.getPaths()); + } + }); + } + + showResultPane() { + let options = {searchAllPanes: true}; + let openDirection = atom.config.get('find-and-replace.projectSearchResultsPaneSplitDirection'); + if (openDirection !== 'none') { options.split = openDirection; } + return atom.workspace.open(ResultsPaneView.URI, options); + } + + onFinishedReplacing(results) { + if (!results.replacedPathCount) atom.beep(); + this.refs.replacmentInfoBlock.style.display = 'none'; + } + + generateResultsMessage(results) { + let message = Util.getSearchResultsMessage(results); + if (results.replacedPathCount != null) { message = Util.getReplacementResultsMessage(results); } + this.setInfoMessage(message); + } + + clearMessages() { + this.element.classList.remove('has-results', 'has-no-results'); + this.setInfoMessage('Find in Open Files'); + this.refs.replacmentInfoBlock.style.display = 'none'; + } + + setInfoMessage(infoMessage) { + this.refs.descriptionLabel.innerHTML = infoMessage; + this.refs.descriptionLabel.classList.remove('text-error'); + } + + setErrorMessage(errorMessage) { + this.refs.descriptionLabel.innerHTML = errorMessage; + this.refs.descriptionLabel.classList.add('text-error'); + } + + updateReplaceAllButtonEnablement(results) { + const canReplace = results && + results.matchCount && + results.findPattern == this.findEditor.getText(); + if (canReplace && !this.refs.replaceAllButton.classList.contains('disabled')) return; + + if (this.replaceTooltipSubscriptions) this.replaceTooltipSubscriptions.dispose(); + this.replaceTooltipSubscriptions = new CompositeDisposable; + + if (canReplace) { + this.refs.replaceAllButton.classList.remove('disabled'); + this.replaceTooltipSubscriptions.add(atom.tooltips.add(this.refs.replaceAllButton, { + title: "Replace All", + keyBindingCommand: 'open-files-find:replace-all', + keyBindingTarget: this.replaceEditor.element + })); + } else { + this.refs.replaceAllButton.classList.add('disabled'); + this.replaceTooltipSubscriptions.add(atom.tooltips.add(this.refs.replaceAllButton, { + title: "Replace All [run a search to enable]"} + )); + } + } + + setSelectionAsFindPattern() { + const editor = atom.workspace.getCenter().getActivePaneItem(); + if (editor && editor.getSelectedText) { + let pattern = editor.getSelectedText() || editor.getWordUnderCursor(); + if (this.model.getFindOptions().useRegex) { + pattern = Util.escapeRegex(pattern); + } + if (pattern) { + this.findEditor.setText(pattern); + } + } + } + + updateOptionViews() { + this.updateOptionButtons(); + this.updateOptionsLabel(); + this.updateSyntaxHighlighting(); + } + + updateSyntaxHighlighting() { + if (this.model.getFindOptions().useRegex) { + this.findEditor.setGrammar(atom.grammars.grammarForScopeName('source.js.regexp')); + return this.replaceEditor.setGrammar(atom.grammars.grammarForScopeName('source.js.regexp.replacement')); + } else { + this.findEditor.setGrammar(atom.grammars.nullGrammar); + return this.replaceEditor.setGrammar(atom.grammars.nullGrammar); + } + } + + updateOptionsLabel() { + const label = []; + + if (this.model.getFindOptions().useRegex) { + label.push('Regex'); + } + + if (this.model.getFindOptions().caseSensitive) { + label.push('Case Sensitive'); + } else { + label.push('Case Insensitive'); + } + + if (this.model.getFindOptions().wholeWord) { + label.push('Whole Word'); + } + + this.refs.optionsLabel.textContent = label.join(', '); + } + + updateOptionButtons() { + this.setOptionButtonState(this.refs.regexOptionButton, this.model.getFindOptions().useRegex); + this.setOptionButtonState(this.refs.caseOptionButton, this.model.getFindOptions().caseSensitive); + this.setOptionButtonState(this.refs.wholeWordOptionButton, this.model.getFindOptions().wholeWord); + } + + setOptionButtonState(optionButton, selected) { + if (selected) { + optionButton.classList.add('selected'); + } else { + optionButton.classList.remove('selected'); + } + } + + toggleRegexOption() { + this.search({onlyRunIfActive: true, useRegex: !this.model.getFindOptions().useRegex}); + } + + toggleCaseOption() { + this.search({onlyRunIfActive: true, caseSensitive: !this.model.getFindOptions().caseSensitive}); + } + + toggleWholeWordOption() { + this.search({onlyRunIfActive: true, wholeWord: !this.model.getFindOptions().wholeWord}); + } +}; diff --git a/lib/project/results-model.js b/lib/project/results-model.js index 6cc66eb9..c7479f01 100644 --- a/lib/project/results-model.js +++ b/lib/project/results-model.js @@ -1,5 +1,8 @@ const _ = require('underscore-plus') const {Emitter, TextEditor, Range} = require('atom') +const {PathReplacer, PathSearcher} = require('scandal') +const replacer = new PathReplacer() +const searcher = new PathSearcher() const escapeHelper = require('../escape-helper') class Result { @@ -196,6 +199,78 @@ module.exports = class ResultsModel { }) } + shouldRerunSearchPaths (findPattern, paths, replacePattern, options) { + if (options == null) { options = {} } + const {onlyRunIfChanged} = options + return !(onlyRunIfChanged && (findPattern != null) && + (findPattern === this.lastFindPattern) && (paths === this.lastPaths)) + } + + searchPaths (findPattern, paths, replacePattern, options) { + if (options == null) { options = {} } + if (!this.shouldRerunSearchPaths(findPattern, paths, replacePattern, options)) { + this.emitter.emit('did-noop-search') + return Promise.resolve() + } + + const {keepReplacementState} = options + if (keepReplacementState) { + this.clearSearchState() + } else { + this.clear() + } + + this.lastFindPattern = findPattern + this.lastPaths = paths + this.findOptions.set(_.extend({findPattern, replacePattern, paths}, options)) + this.regex = this.findOptions.getFindPatternRegex() + + this.active = true + + const leadingContextLineCount = atom.config.get('find-and-replace.searchContextLineCountBefore') + const trailingContextLineCount = atom.config.get('find-and-replace.searchContextLineCountAfter') + + searcher.on('results-found', result => { + this.setResult(result.filePath, Result.create(result)) + }) + + /* build a cancellable promise like workspace.scan does internally so that it + looks identical to #search to outsiders looking in */ + const isCancelled = false + this.inProgressSearchPromise = new Promise((resolve, reject) => { + if (isCancelled) { resolve('cancelled') } + searcher.searchPaths(this.regex, paths, (results, errors) => { + if (errors) { + errors.forEach(e => { + this.searchErrors = this.searchErrors || [] + this.searchErrors.push(e) + this.emitter.emit('did-error-for-path', e) + }) + reject() + } else { + this.emitter.emit('did-search-paths', paths.length) + resolve(null) + } + }) + }) + + this.inProgressSearchPromise.cancel = () => { + isCancelled = true + } + // this.inProgressSearchPromise.done = () => {} + + this.emitter.emit('did-start-searching', this.inProgressSearchPromise) + + return this.inProgressSearchPromise.then(message => { + if (message === 'cancelled') { + this.emitter.emit('did-cancel-searching') + } else { + this.inProgressSearchPromise = null + this.emitter.emit('did-finish-searching', this.getResultsSummary()) + } + }) + } + replace (pathsPattern, replacePattern, replacementPaths) { if (!this.findOptions.findPattern || (this.regex == null)) { return } @@ -229,6 +304,39 @@ module.exports = class ResultsModel { }).catch(e => console.error(e.stack)) } + replacePaths (replacePattern, replacementPaths) { + if (!this.findOptions.findPattern || (this.regex == null)) { return } + + this.findOptions.set({replacePattern}) + + if (this.findOptions.useRegex) { replacePattern = escapeHelper.unescapeEscapeSequence(replacePattern) } + + this.active = false // not active until the search is finished + this.replacedPathCount = 0 + this.replacementCount = 0 + + const promise = pathReplacer.replacePaths(this.regex, replacePattern, replacementPaths, (result, error) => { + if (result) { + if (result.replacements) { + this.replacedPathCount++ + this.replacementCount += result.replacements + } + this.emitter.emit('did-replace-path', result) + } else { + if (this.replacementErrors == null) { this.replacementErrors = [] } + this.replacementErrors.push(error) + this.emitter.emit('did-error-for-path', error) + } + }) + + this.emitter.emit('did-start-replacing', promise) + return promise.then(() => { + this.emitter.emit('did-finish-replacing', this.getResultsSummary()) + return this.searchPaths(this.findOptions.findPattern, this.findOptions.paths, + this.findOptions.replacePattern, {keepReplacementState: true}) + }).catch(e => console.error(e.stack)) + } + setActive (isActive) { if ((isActive && this.findOptions.findPattern) || !isActive) { this.active = isActive diff --git a/lib/project/results-pane.js b/lib/project/results-pane.js index 598ab9ee..572fee53 100644 --- a/lib/project/results-pane.js +++ b/lib/project/results-pane.js @@ -203,7 +203,7 @@ class ResultsPaneView { } getTitle() { - return 'Project Find Results'; + return 'Project search results'; } getIconName() { @@ -334,4 +334,4 @@ class ResultsPaneView { } } -module.exports.URI = "atom://find-and-replace/project-results"; +module.exports.URI = "atom://find-and-replace/results"; diff --git a/menus/find-and-replace.cson b/menus/find-and-replace.cson index e459ed2e..caaaac6b 100644 --- a/menus/find-and-replace.cson +++ b/menus/find-and-replace.cson @@ -10,6 +10,9 @@ { 'label': 'Find in Project', 'command': 'project-find:show'} { 'label': 'Toggle Find in Project', 'command': 'project-find:toggle'} { 'type': 'separator' } + { 'label': 'Find in Open Files', 'command': 'open-files-find:show'} + { 'label': 'Toggle Find in Open Files', 'command': 'open-files-find:toggle'} + { 'type': 'separator' } { 'label': 'Find All', 'command': 'find-and-replace:find-all'} { 'label': 'Find Next', 'command': 'find-and-replace:find-next'} { 'label': 'Find Previous', 'command': 'find-and-replace:find-previous'} diff --git a/package.json b/package.json index 6d81ccc2..266faed7 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "license": "MIT", "activationCommands": { "atom-workspace": [ + "open-files-find:show", + "open-files-find:toggle", "project-find:show", "project-find:toggle", "project-find:show-in-current-directory", @@ -33,6 +35,7 @@ "element-resize-detector": "^1.1.10", "etch": "0.9.3", "fs-plus": "^3.0.0", + "scandal": "^3.1.0", "temp": "^0.8.3", "underscore-plus": "1.x" }, diff --git a/spec/open-files-find-view-spec.js b/spec/open-files-find-view-spec.js new file mode 100644 index 00000000..df55bb39 --- /dev/null +++ b/spec/open-files-find-view-spec.js @@ -0,0 +1,1392 @@ +/** @babel */ + +const os = require('os'); +const path = require('path'); +const temp = require('temp'); +const fs = require('fs-plus'); +const {TextBuffer} = require('atom'); +const {PathReplacer, PathSearcher} = require('scandal'); +const ResultsPaneView = require('../lib/project/results-pane'); +const etch = require('etch'); +const {beforeEach, it, fit, ffit, fffit, conditionPromise} = require('./async-spec-helpers') + +describe('OpenFilesFindView', () => { + const {stoppedChangingDelay} = TextBuffer.prototype; + let activationPromise, searchPromise, editor, editorElement, findView, + openFilesFindView, pathSearcher, workspaceElement; + + function getAtomPanel() { + return workspaceElement.querySelector('.open-files-find').parentNode; + } + + function getExistingResultsPane() { + const pane = atom.workspace.paneForURI(ResultsPaneView.URI); + if (pane) { + + // Allow element-resize-detector to perform batched measurements + advanceClock(1); + + return pane.itemForURI(ResultsPaneView.URI); + } + } + + function getResultsView() { + return getExistingResultsPane().refs.resultsView; + } + + beforeEach(async () => { + pathSearcher = new PathSearcher(); + workspaceElement = atom.views.getView(atom.workspace); + atom.config.set('core.excludeVcsIgnoredPaths', false); + atom.project.setPaths([path.join(__dirname, 'fixtures')]); + await atom.workspace.open(path.join(__dirname, 'fixtures', 'one-long-line.coffee')); + await atom.workspace.open(path.join(__dirname, 'fixtures', 'sample.js')); + jasmine.attachToDOM(workspaceElement); + + activationPromise = atom.packages.activatePackage("find-and-replace").then(function({mainModule}) { + mainModule.createViews(); + ({findView, openFilesFindView} = mainModule); + const spy = spyOn(openFilesFindView, 'search').andCallFake((options) => { + return searchPromise = spy.originalValue.call(openFilesFindView, options); + }); + }); + }); + + describe("when open-files-find:show is triggered", () => { + it("attaches openFilesFindView to the root view", async () => { + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + await activationPromise; + + openFilesFindView.findEditor.setText('items'); + expect(getAtomPanel()).toBeVisible(); + expect(openFilesFindView.findEditor.getSelectedBufferRange()).toEqual([[0, 0], [0, 5]]); + }); + + describe("with an open buffer", () => { + beforeEach(async () => { + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + await activationPromise; + openFilesFindView.findEditor.setText(''); + editor = await atom.workspace.open('sample.js'); + }); + + it("populates the findEditor with selection when there is a selection", () => { + editor.setSelectedBufferRange([[2, 8], [2, 13]]); + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + expect(getAtomPanel()).toBeVisible(); + expect(openFilesFindView.findEditor.getText()).toBe('items'); + + editor.setSelectedBufferRange([[2, 14], [2, 20]]); + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + expect(getAtomPanel()).toBeVisible(); + expect(openFilesFindView.findEditor.getText()).toBe('length'); + }); + + it("populates the findEditor with the previous selection when there is no selection", () => { + editor.setSelectedBufferRange([[2, 14], [2, 20]]); + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + expect(getAtomPanel()).toBeVisible(); + expect(openFilesFindView.findEditor.getText()).toBe('length'); + + editor.setSelectedBufferRange([[2, 30], [2, 30]]); + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + expect(getAtomPanel()).toBeVisible(); + expect(openFilesFindView.findEditor.getText()).toBe('length'); + }); + + it("places selected text into the find editor and escapes it when Regex is enabled", () => { + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-regex-option'); + editor.setSelectedBufferRange([[6, 6], [6, 65]]); + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + expect(openFilesFindView.findEditor.getText()).toBe('current < pivot \\? left\\.push\\(current\\) : right\\.push\\(current\\);'); + }); + }); + + describe("when the openFilesFindView is already attached", () => { + beforeEach(async () => { + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + await activationPromise; + + openFilesFindView.findEditor.setText('items'); + openFilesFindView.findEditor.setSelectedBufferRange([[0, 0], [0, 0]]); + }); + + it("focuses the find editor and selects all the text", () => { + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + expect(openFilesFindView.findEditor.getElement()).toHaveFocus(); + expect(openFilesFindView.findEditor.getSelectedText()).toBe("items"); + }); + }); + + it("honors config settings for find options", async () => { + atom.config.set('find-and-replace.useRegex', true); + atom.config.set('find-and-replace.caseSensitive', true); + atom.config.set('find-and-replace.wholeWord', true); + + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + await activationPromise; + + expect(openFilesFindView.refs.caseOptionButton).toHaveClass('selected'); + expect(openFilesFindView.refs.regexOptionButton).toHaveClass('selected'); + expect(openFilesFindView.refs.wholeWordOptionButton).toHaveClass('selected'); + }); + }); + + describe("when open-files-find:toggle is triggered", () => { + it("toggles the visibility of the OpenFilesFindView", async () => { + atom.commands.dispatch(workspaceElement, 'open-files-find:toggle'); + await activationPromise; + + expect(getAtomPanel()).toBeVisible(); + atom.commands.dispatch(workspaceElement, 'open-files-find:toggle'); + expect(getAtomPanel()).not.toBeVisible(); + }); + }); + + describe("finding", () => { + beforeEach(async () => { + editor = await atom.workspace.open('sample.js'); + editorElement = atom.views.getView(editor); + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + await activationPromise; + workspaceElement.style.height = '800px' + }); + + describe("when the find string contains an escaped char", () => { + beforeEach(async () => { + let projectPath = temp.mkdirSync("atom"); + fs.writeFileSync(path.join(projectPath, "tabs.txt"), "\t\n\\\t\n\\\\t"); + await atom.workspace.open(path.join(projectPath, "tabs.txt")); + atom.project.setPaths([projectPath]); + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + }); + + describe("when regex seach is enabled", () => { + it("finds a literal tab character", async () => { + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-regex-option'); + openFilesFindView.findEditor.setText('\\t'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + const resultsView = getResultsView(); + await resultsView.heightInvalidationPromise + expect(resultsView.element).toBeVisible(); + expect(resultsView.refs.listView.element.querySelectorAll(".search-result")).toHaveLength(2); + }) + }); + + describe("when regex seach is disabled", () => { + it("finds the escape char", async () => { + openFilesFindView.findEditor.setText('\\t'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + const resultsView = getResultsView(); + await resultsView.heightInvalidationPromise + expect(resultsView.element).toBeVisible(); + expect(resultsView.refs.listView.element.querySelectorAll(".search-result")).toHaveLength(1); + }); + + it("finds a backslash", async () => { + openFilesFindView.findEditor.setText('\\'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + const resultsView = getResultsView(); + await resultsView.heightInvalidationPromise + expect(resultsView.element).toBeVisible(); + expect(resultsView.refs.listView.element.querySelectorAll(".search-result")).toHaveLength(3); + }); + + it("doesn't insert a escaped char if there are multiple backslashs in front of the char", async () => { + openFilesFindView.findEditor.setText('\\\\t'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + const resultsView = getResultsView(); + await resultsView.heightInvalidationPromise + expect(resultsView.element).toBeVisible(); + expect(resultsView.refs.listView.element.querySelectorAll(".search-result")).toHaveLength(1); + }); + }); + }); + + describe("when core:cancel is triggered", () => { + it("detaches from the root view", () => { + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + openFilesFindView.element.focus(); + atom.commands.dispatch(document.activeElement, 'core:cancel'); + expect(getAtomPanel()).not.toBeVisible(); + }); + }); + + describe("when close option is true", () => { + beforeEach(() => { + atom.config.set('find-and-replace.closeFindPanelAfterSearch', true); + }) + + it("closes the panel after search", async () => { + openFilesFindView.findEditor.setText('something'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + expect(getAtomPanel()).not.toBeVisible(); + }); + + it("leaves the panel open after an empty search", async () => { + openFilesFindView.findEditor.setText(''); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + expect(getAtomPanel()).toBeVisible(); + }); + + it("closes the panel after a no-op search", async () => { + openFilesFindView.findEditor.setText('something'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + await activationPromise; + + expect(getAtomPanel()).toBeVisible(); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + expect(getAtomPanel()).not.toBeVisible(); + }); + + it("does not close the panel after the replacement text is altered", async () => { + openFilesFindView.replaceEditor.setText('something else'); + + expect(getAtomPanel()).toBeVisible(); + }); + }); + + describe("splitting into a second pane", () => { + beforeEach(() => { + workspaceElement.style.height = '1000px'; + atom.commands.dispatch(editorElement, 'open-files-find:show'); + }); + + it("splits when option is right", async () => { + const initialPane = atom.workspace.getActivePane(); + atom.config.set('find-and-replace.projectSearchResultsPaneSplitDirection', 'right'); + openFilesFindView.findEditor.setText('items'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + expect(atom.workspace.getActivePane()).not.toBe(initialPane); + }); + + it("splits when option is bottom", async () => { + const initialPane = atom.workspace.getActivePane(); + atom.config.set('find-and-replace.projectSearchResultsPaneSplitDirection', 'down'); + openFilesFindView.findEditor.setText('items'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + expect(atom.workspace.getActivePane()).not.toBe(initialPane); + }); + + it("does not split when option is false", async () => { + const initialPane = atom.workspace.getActivePane(); + openFilesFindView.findEditor.setText('items'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + expect(atom.workspace.getActivePane()).toBe(initialPane); + }); + + it("can be duplicated on the right", async () => { + atom.config.set('find-and-replace.projectSearchResultsPaneSplitDirection', 'right'); + openFilesFindView.findEditor.setText('items'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + const resultsPaneView1 = atom.views.getView(getExistingResultsPane()); + const pane1 = atom.workspace.getActivePane(); + const resultsView1 = pane1.getItems()[0].refs.resultsView + pane1.splitRight({copyActiveItem: true}); + + const pane2 = atom.workspace.getActivePane(); + const resultsView2 = pane2.getItems()[0].refs.resultsView + const resultsPaneView2 = atom.views.getView(pane2.itemForURI(ResultsPaneView.URI)); + expect(pane1).not.toBe(pane2); + expect(resultsPaneView1).not.toBe(resultsPaneView2); + simulateResizeEvent(resultsView2.element); + + const {length: resultCount} = resultsPaneView1.querySelectorAll('.search-result'); + expect(resultCount).toBeGreaterThan(0); + expect(resultsPaneView2.querySelectorAll('.search-result')).toHaveLength(resultCount); + expect(resultsPaneView2.querySelector('.preview-count').innerHTML).toEqual(resultsPaneView1.querySelector('.preview-count').innerHTML); + }); + + it("can be duplicated at the bottom", async () => { + atom.config.set('find-and-replace.projectSearchResultsPaneSplitDirection', 'down'); + openFilesFindView.findEditor.setText('items'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + const resultsPaneView1 = atom.views.getView(getExistingResultsPane()); + const pane1 = atom.workspace.getActivePane(); + const resultsView1 = pane1.getItems()[0].refs.resultsView + + pane1.splitDown({copyActiveItem: true}); + const pane2 = atom.workspace.getActivePane(); + const resultsView2 = pane2.getItems()[0].refs.resultsView + const resultsPaneView2 = atom.views.getView(pane2.itemForURI(ResultsPaneView.URI)); + expect(pane1).not.toBe(pane2); + expect(resultsPaneView1).not.toBe(resultsPaneView2); + expect(resultsPaneView2.querySelector('.preview-count').innerHTML).toEqual(resultsPaneView1.querySelector('.preview-count').innerHTML); + }); + }); + + describe("serialization", () => { + it("serializes if the case, regex and whole word options", async () => { + atom.commands.dispatch(editorElement, 'open-files-find:show'); + expect(openFilesFindView.refs.caseOptionButton).not.toHaveClass('selected'); + openFilesFindView.refs.caseOptionButton.click(); + expect(openFilesFindView.refs.caseOptionButton).toHaveClass('selected'); + + expect(openFilesFindView.refs.regexOptionButton).not.toHaveClass('selected'); + openFilesFindView.refs.regexOptionButton.click(); + expect(openFilesFindView.refs.regexOptionButton).toHaveClass('selected'); + + expect(openFilesFindView.refs.wholeWordOptionButton).not.toHaveClass('selected'); + openFilesFindView.refs.wholeWordOptionButton.click(); + expect(openFilesFindView.refs.wholeWordOptionButton).toHaveClass('selected'); + + atom.packages.deactivatePackage("find-and-replace"); + + activationPromise = atom.packages.activatePackage("find-and-replace").then(function({mainModule}) { + mainModule.createViews(); + return {openFilesFindView} = mainModule; + }); + + atom.commands.dispatch(editorElement, 'open-files-find:show'); + await activationPromise; + + expect(openFilesFindView.refs.caseOptionButton).toHaveClass('selected'); + expect(openFilesFindView.refs.regexOptionButton).toHaveClass('selected'); + expect(openFilesFindView.refs.wholeWordOptionButton).toHaveClass('selected'); + }) + }); + + describe("description label", () => { + beforeEach(() => { + atom.commands.dispatch(editorElement, 'open-files-find:show'); + }); + + it("indicates that it's searching, then shows the results", async () => { + openFilesFindView.findEditor.setText('item'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + + await openFilesFindView.showResultPane(); + + expect(openFilesFindView.refs.descriptionLabel.textContent).toContain('Searching...'); + + await searchPromise; + + expect(openFilesFindView.refs.descriptionLabel.textContent).toContain('13 results found in 2 open files'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + expect(openFilesFindView.refs.descriptionLabel.textContent).toContain('13 results found in 2 open files'); + }); + + it("shows an error when the pattern is invalid and clears when no error", async () => { + spyOn(pathSearcher, 'searchPaths').andReturn(Promise.resolve()); // TODO: Remove? + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-regex-option'); + openFilesFindView.findEditor.setText('['); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + + await searchPromise; + + expect(openFilesFindView.refs.descriptionLabel).toHaveClass('text-error'); + expect(openFilesFindView.refs.descriptionLabel.textContent).toContain('Invalid regular expression'); + + openFilesFindView.findEditor.setText(''); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + + expect(openFilesFindView.refs.descriptionLabel).not.toHaveClass('text-error'); + expect(openFilesFindView.refs.descriptionLabel.textContent).toContain('Find in Project'); + + openFilesFindView.findEditor.setText('items'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + + await searchPromise; + + expect(openFilesFindView.refs.descriptionLabel).not.toHaveClass('text-error'); + expect(openFilesFindView.refs.descriptionLabel.textContent).toContain('items'); + }); + }); + + describe("regex", () => { + beforeEach(() => { + atom.commands.dispatch(editorElement, 'open-files-find:show'); + openFilesFindView.findEditor.setText('i(\\w)ems+'); + spyOn(pathSearcher, 'searchPaths').andCallFake(async () => {}); + }); + + it("escapes regex patterns by default", async () => { + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + expect(pathSearcher.searchPaths.argsForCall[0][0]).toEqual(/i\(\\w\)ems\+/gi); + }); + + it("shows an error when the regex pattern is invalid", async () => { + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-regex-option'); + openFilesFindView.findEditor.setText('['); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + expect(openFilesFindView.refs.descriptionLabel).toHaveClass('text-error'); + }); + + describe("when search has not been run yet", () => { + it("toggles regex option via an event but does not run the search", () => { + expect(openFilesFindView.refs.regexOptionButton).not.toHaveClass('selected'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-regex-option'); + expect(openFilesFindView.refs.regexOptionButton).toHaveClass('selected'); + expect(pathSearcher.searchPaths).not.toHaveBeenCalled(); + }) + }); + + describe("when search has been run", () => { + beforeEach(async () => { + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + }); + + it("toggles regex option via an event and finds files matching the pattern", async () => { + expect(openFilesFindView.refs.regexOptionButton).not.toHaveClass('selected'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-regex-option'); + + await searchPromise; + + expect(openFilesFindView.refs.regexOptionButton).toHaveClass('selected'); + expect(pathSearcher.searchPaths.mostRecentCall.args[0]).toEqual(/i(\w)ems+/gi); + }); + + it("toggles regex option via a button and finds files matching the pattern", async () => { + expect(openFilesFindView.refs.regexOptionButton).not.toHaveClass('selected'); + openFilesFindView.refs.regexOptionButton.click(); + + await searchPromise; + + expect(openFilesFindView.refs.regexOptionButton).toHaveClass('selected'); + expect(pathSearcher.searchPaths.mostRecentCall.args[0]).toEqual(/i(\w)ems+/gi); + }); + }); + }); + + describe("case sensitivity", () => { + beforeEach(async () => { + atom.commands.dispatch(editorElement, 'open-files-find:show'); + spyOn(pathSearcher, 'searchPaths').andCallFake(() => Promise.resolve()); + openFilesFindView.findEditor.setText('ITEMS'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + }); + + it("runs a case insensitive search by default", () => expect(String(PathSearcher.searchPaths.argsForCall[0][0])).toEqual(String(/ITEMS/gi))); + + it("toggles case sensitive option via an event and finds files matching the pattern", async () => { + expect(openFilesFindView.refs.caseOptionButton).not.toHaveClass('selected'); + + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-case-option'); + await searchPromise; + + expect(openFilesFindView.refs.caseOptionButton).toHaveClass('selected'); + expect(pathSearcher.searchPaths.mostRecentCall.args[0]).toEqual(/ITEMS/g); + }); + + it("toggles case sensitive option via a button and finds files matching the pattern", async () => { + expect(openFilesFindView.refs.caseOptionButton).not.toHaveClass('selected'); + + openFilesFindView.refs.caseOptionButton.click(); + await searchPromise; + + expect(openFilesFindView.refs.caseOptionButton).toHaveClass('selected'); + expect(pathSearcher.searchPaths.mostRecentCall.args[0]).toEqual(/ITEMS/g); + }); + }); + + describe("whole word", () => { + beforeEach(async () => { + atom.commands.dispatch(editorElement, 'open-files-find:show'); + spyOn(pathSearcher, 'searchPaths').andCallFake(async () => {}); + openFilesFindView.findEditor.setText('wholeword'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + }); + + it("does not run whole word search by default", () => { + expect(pathSearcher.searchPaths.argsForCall[0][0]).toEqual(/wholeword/gi) + }); + + it("toggles whole word option via an event and finds files matching the pattern", async () => { + expect(openFilesFindView.refs.wholeWordOptionButton).not.toHaveClass('selected'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-whole-word-option'); + + await searchPromise; + expect(openFilesFindView.refs.wholeWordOptionButton).toHaveClass('selected'); + expect(pathSearcher.searchPaths.mostRecentCall.args[0]).toEqual(/\bwholeword\b/gi); + }); + + it("toggles whole word option via a button and finds files matching the pattern", async () => { + expect(openFilesFindView.refs.wholeWordOptionButton).not.toHaveClass('selected'); + + openFilesFindView.refs.wholeWordOptionButton.click(); + await searchPromise; + + expect(openFilesFindView.refs.wholeWordOptionButton).toHaveClass('selected'); + expect(pathSearcher.searchPaths.mostRecentCall.args[0]).toEqual(/\bwholeword\b/gi); + }); + }); + + describe("when open-files-find:confirm is triggered", () => { + it("displays the results and no errors", async () => { + openFilesFindView.findEditor.setText('items'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:confirm'); + + await searchPromise; + + const resultsView = getResultsView(); + await resultsView.heightInvalidationPromise + expect(resultsView.element).toBeVisible(); + expect(resultsView.refs.listView.element.querySelectorAll(".search-result")).toHaveLength(13); + }) + }); + + describe("when core:confirm is triggered", () => { + beforeEach(() => { + atom.commands.dispatch(workspaceElement, 'open-files-find:show') + }); + + describe("when the there search field is empty", () => { + it("does not run the seach but clears the model", () => { + spyOn(pathSearcher, 'searchPaths'); + spyOn(openFilesFindView.model, 'clear'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + expect(pathSearcher.searchPaths).not.toHaveBeenCalled(); + expect(openFilesFindView.model.clear).toHaveBeenCalled(); + }) + }); + + it("reruns the search when confirmed again after focusing the window", async () => { + openFilesFindView.findEditor.setText('thisdoesnotmatch'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + + await searchPromise; + + spyOn(pathSearcher, 'searchPaths'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + + await searchPromise; + + expect(pathSearcher.searchPaths).not.toHaveBeenCalled(); + pathSearcher.searchPaths.reset(); + window.dispatchEvent(new FocusEvent("focus")); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + + await searchPromise; + + expect(pathSearcher.searchPaths).toHaveBeenCalled(); + pathSearcher.searchPaths.reset(); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + + await searchPromise; + + expect(pathSearcher.searchPaths).not.toHaveBeenCalled(); + }); + + describe("when results exist", () => { + beforeEach(() => { + openFilesFindView.findEditor.setText('items') + }); + + it("displays the results and no errors", async () => { + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + const resultsView = getResultsView(); + const resultsPaneView = getExistingResultsPane(); + + await resultsView.heightInvalidationPromise + expect(resultsView.element).toBeVisible(); + expect(resultsView.refs.listView.element.querySelectorAll(".search-result")).toHaveLength(13); + + expect(resultsPaneView.refs.previewCount.textContent).toBe("13 results found in 2 files for items"); + expect(openFilesFindView.errorMessages).not.toBeVisible(); + }); + + it("updates the results list when a buffer changes", async () => { + const buffer = atom.project.bufferForPathSync('sample.js'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + const resultsView = getResultsView(); + const resultsPaneView = getExistingResultsPane(); + + await resultsView.heightInvalidationPromise + expect(resultsView.refs.listView.element.querySelectorAll(".search-result")).toHaveLength(13); + expect(resultsPaneView.refs.previewCount.textContent).toBe("13 results found in 2 files for items"); + + resultsView.selectFirstResult(); + for (let i = 0; i < 7; i++) await resultsView.moveDown() + expect(resultsView.refs.listView.element.querySelectorAll(".path")[1]).toHaveClass('selected'); + + buffer.setText('there is one "items" in this file'); + advanceClock(buffer.stoppedChangingDelay); + await etch.getScheduler().getNextUpdatePromise() + expect(resultsPaneView.refs.previewCount.textContent).toBe("8 results found in 2 files for items"); + expect(resultsView.refs.listView.element.querySelectorAll(".path")[1]).toHaveClass('selected'); + + buffer.setText('no matches in this file'); + advanceClock(buffer.stoppedChangingDelay); + await etch.getScheduler().getNextUpdatePromise() + expect(resultsPaneView.refs.previewCount.textContent).toBe("7 results found in 1 file for items"); + }); + }); + + describe("when no results exist", () => { + beforeEach(() => { + openFilesFindView.findEditor.setText('notintheprojectbro'); + spyOn(pathSearcher, 'searchPaths').andCallFake(async () => {}); + }); + + it("displays no errors and no results", async () => { + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + const resultsView = getResultsView(); + expect(openFilesFindView.refs.errorMessages).not.toBeVisible(); + expect(resultsView.element).toBeVisible(); + expect(resultsView.refs.listView.element.querySelectorAll(".search-result")).toHaveLength(0); + }); + }); + }); + + describe("history", () => { + beforeEach(() => { + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + spyOn(pathSearcher, 'searchPaths').andCallFake(() => { + let promise = Promise.resolve(); + promise.cancel = () => {}; + return promise; + }); + + openFilesFindView.findEditor.setText('sort'); + openFilesFindView.replaceEditor.setText('bort'); + atom.commands.dispatch(openFilesFindView.findEditor.getElement(), 'core:confirm'); + + openFilesFindView.findEditor.setText('items'); + openFilesFindView.replaceEditor.setText('eyetims'); + atom.commands.dispatch(openFilesFindView.findEditor.getElement(), 'core:confirm'); + }); + + it("can navigate the entire history stack", () => { + expect(openFilesFindView.findEditor.getText()).toEqual('items'); + + atom.commands.dispatch(openFilesFindView.findEditor.getElement(), 'core:move-up'); + expect(openFilesFindView.findEditor.getText()).toEqual('sort'); + + atom.commands.dispatch(openFilesFindView.findEditor.getElement(), 'core:move-down'); + expect(openFilesFindView.findEditor.getText()).toEqual('items'); + + atom.commands.dispatch(openFilesFindView.findEditor.getElement(), 'core:move-down'); + expect(openFilesFindView.findEditor.getText()).toEqual(''); + + expect(openFilesFindView.replaceEditor.getText()).toEqual('eyetims'); + + atom.commands.dispatch(openFilesFindView.replaceEditor.element, 'core:move-up'); + expect(openFilesFindView.replaceEditor.getText()).toEqual('bort'); + + atom.commands.dispatch(openFilesFindView.replaceEditor.element, 'core:move-down'); + expect(openFilesFindView.replaceEditor.getText()).toEqual('eyetims'); + + atom.commands.dispatch(openFilesFindView.replaceEditor.element, 'core:move-down'); + expect(openFilesFindView.replaceEditor.getText()).toEqual(''); + }); + }); + + describe("when find-and-replace:use-selection-as-find-pattern is triggered", () => { + it("places the selected text into the find editor", () => { + editor.setSelectedBufferRange([[1, 6], [1, 10]]); + atom.commands.dispatch(workspaceElement, 'find-and-replace:use-selection-as-find-pattern'); + expect(openFilesFindView.findEditor.getText()).toBe('sort'); + + editor.setSelectedBufferRange([[1, 13], [1, 21]]); + atom.commands.dispatch(workspaceElement, 'find-and-replace:use-selection-as-find-pattern'); + expect(openFilesFindView.findEditor.getText()).toBe('function'); + }); + + it("places the word under the cursor into the find editor", () => { + editor.setSelectedBufferRange([[1, 8], [1, 8]]); + atom.commands.dispatch(workspaceElement, 'find-and-replace:use-selection-as-find-pattern'); + expect(openFilesFindView.findEditor.getText()).toBe('sort'); + + editor.setSelectedBufferRange([[1, 15], [1, 15]]); + atom.commands.dispatch(workspaceElement, 'find-and-replace:use-selection-as-find-pattern'); + expect(openFilesFindView.findEditor.getText()).toBe('function'); + }); + + it("places the previously selected text into the find editor if no selection and no word under cursor", () => { + editor.setSelectedBufferRange([[1, 13], [1, 21]]); + atom.commands.dispatch(workspaceElement, 'find-and-replace:use-selection-as-find-pattern'); + expect(openFilesFindView.findEditor.getText()).toBe('function'); + + editor.setSelectedBufferRange([[1, 1], [1, 1]]); + atom.commands.dispatch(workspaceElement, 'find-and-replace:use-selection-as-find-pattern'); + expect(openFilesFindView.findEditor.getText()).toBe('function'); + }); + + it("places selected text into the find editor and escapes it when Regex is enabled", () => { + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-regex-option'); + editor.setSelectedBufferRange([[6, 6], [6, 65]]); + atom.commands.dispatch(workspaceElement, 'find-and-replace:use-selection-as-find-pattern'); + expect(openFilesFindView.findEditor.getText()).toBe('current < pivot \\? left\\.push\\(current\\) : right\\.push\\(current\\);'); + }); + }); + + describe("when there is an error searching", () => { + it("displays the errors in the results pane", async () => { + openFilesFindView.findEditor.setText('items'); + + let errorList; + spyOn(pathSearcher, 'searchPaths').andCallFake(async (regex, options, callback) => { + const resultsPaneView = getExistingResultsPane(); + ({errorList} = resultsPaneView.refs); + expect(errorList.querySelectorAll("li")).toHaveLength(0); + + callback(null, {path: '/some/path.js', code: 'ENOENT', message: 'Nope'}); + expect(errorList).toBeVisible(); + expect(errorList.querySelectorAll("li")).toHaveLength(1); + + callback(null, {path: '/some/path.js', code: 'ENOENT', message: 'Broken'}); + expect(errorList.querySelectorAll("li")).toHaveLength(2); + }); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + + await searchPromise; + + expect(errorList).toBeVisible(); + expect(errorList.querySelectorAll("li")).toHaveLength(2); + expect(errorList.querySelectorAll("li")[0].textContent).toBe('Nope'); + expect(errorList.querySelectorAll("li")[1].textContent).toBe('Broken'); + }) + }); + + describe("buffer search sharing of the find options", () => { + function getResultDecorations(clazz) { + const result = []; + const decorations = editor.decorationsStateForScreenRowRange(0, editor.getLineCount()); + for (let id in decorations) { + const decoration = decorations[id]; + if (decoration.properties.class === clazz) { + result.push(decoration); + } + } + return result; + } + + it("setting the find text does not interfere with the project replace state", async () => { + // Not sure why I need to advance the clock before setting the text. If + // this advanceClock doesnt happen, the text will be ''. wtf. + advanceClock(openFilesFindView.findEditor.getBuffer().stoppedChangingDelay + 1); + + openFilesFindView.findEditor.setText('findme'); + advanceClock(openFilesFindView.findEditor.getBuffer().stoppedChangingDelay + 1); + + await openFilesFindView.search({onlyRunIfActive: false, onlyRunIfChanged: true}); + expect(pathSearcher.searchPaths).toHaveBeenCalled(); + }); + + it("shares the buffers and history cyclers between both buffer and open files views", () => { + openFilesFindView.findEditor.setText('findme'); + openFilesFindView.replaceEditor.setText('replaceme'); + + atom.commands.dispatch(editorElement, 'find-and-replace:show'); + expect(findView.findEditor.getText()).toBe('findme'); + expect(findView.replaceEditor.getText()).toBe('replaceme'); + + // add some things to the history + atom.commands.dispatch(findView.findEditor.element, 'core:confirm'); + findView.findEditor.setText('findme1'); + atom.commands.dispatch(findView.findEditor.element, 'core:confirm'); + findView.findEditor.setText(''); + + atom.commands.dispatch(findView.replaceEditor.element, 'core:confirm'); + findView.replaceEditor.setText('replaceme1'); + atom.commands.dispatch(findView.replaceEditor.element, 'core:confirm'); + findView.replaceEditor.setText(''); + + // Back to the open files view to make sure we're using the same cycler + atom.commands.dispatch(editorElement, 'open-files-find:show'); + + expect(openFilesFindView.findEditor.getText()).toBe(''); + atom.commands.dispatch(openFilesFindView.findEditor.element, 'core:move-up'); + expect(openFilesFindView.findEditor.getText()).toBe('findme1'); + atom.commands.dispatch(openFilesFindView.findEditor.element, 'core:move-up'); + expect(openFilesFindView.findEditor.getText()).toBe('findme'); + + expect(openFilesFindView.replaceEditor.getText()).toBe(''); + atom.commands.dispatch(openFilesFindView.replaceEditor.element, 'core:move-up'); + expect(openFilesFindView.replaceEditor.getText()).toBe('replaceme1'); + atom.commands.dispatch(openFilesFindView.replaceEditor.element, 'core:move-up'); + expect(openFilesFindView.replaceEditor.getText()).toBe('replaceme'); + }); + + it('highlights the search results in the selected file', async () => { + // Process here is to + // * open samplejs + // * run a search that has sample js results + // * that should place the pattern in the buffer find + // * focus sample.js by clicking on a sample.js result + // * when the file has been activated, it's results for the project search should be highlighted + + editor = await atom.workspace.open('sample.js'); + expect(getResultDecorations('find-result')).toHaveLength(0); + + openFilesFindView.findEditor.setText('item'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + const resultsView = getResultsView(); + resultsView.scrollToBottom(); // To load ALL the results + expect(resultsView.element).toBeVisible(); + expect(resultsView.refs.listView.element.querySelectorAll(".search-result")).toHaveLength(13); + + resultsView.selectFirstResult(); + for (let i = 0; i < 10; i++) await resultsView.moveDown(); + + atom.commands.dispatch(resultsView.element, 'core:confirm'); + await new Promise(resolve => editor.onDidChangeSelectionRange(resolve)) + + // sample.js has 6 results + expect(getResultDecorations('find-result')).toHaveLength(5); + expect(getResultDecorations('current-result')).toHaveLength(1); + expect(workspaceElement).toHaveClass('find-visible'); + + const initialSelectedRange = editor.getSelectedBufferRange(); + + // now we can find next + atom.commands.dispatch(atom.views.getView(editor), 'find-and-replace:find-next'); + expect(editor.getSelectedBufferRange()).not.toEqual(initialSelectedRange); + + // Now we toggle the whole-word option to make sure it is updated in the buffer find + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-whole-word-option'); + await searchPromise; + + // sample.js has 0 results for whole word `item` + expect(getResultDecorations('find-result')).toHaveLength(0); + expect(workspaceElement).toHaveClass('find-visible'); + + // Now we toggle the whole-word option to make sure it is updated in the buffer find + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-whole-word-option'); + }); + }); + }); + + describe("replacing", () => { + let testDir, sampleJs, sampleCoffee, replacePromise; + + beforeEach(async () => { + pathReplacer = new PathReplacer(); + testDir = path.join(os.tmpdir(), "atom-find-and-replace"); + sampleJs = path.join(testDir, 'sample.js'); + sampleCoffee = path.join(testDir, 'sample.coffee'); + + fs.makeTreeSync(testDir); + fs.writeFileSync(sampleCoffee, fs.readFileSync(require.resolve('./fixtures/sample.coffee'))); + fs.writeFileSync(sampleJs, fs.readFileSync(require.resolve('./fixtures/sample.js'))); + + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + await activationPromise; + + atom.project.setPaths([testDir]); + const spy = spyOn(openFilesFindView, 'replaceAll').andCallFake(() => { + replacePromise = spy.originalValue.call(openFilesFindView); + }); + }); + + afterEach(async () => { + // On Windows, you can not remove a watched directory/file, therefore we + // have to close the project before attempting to delete. Unfortunately, + // Pathwatcher's close function is also not synchronous. Once + // atom/node-pathwatcher#4 is implemented this should be alot cleaner. + let activePane = atom.workspace.getActivePane(); + if (activePane) { + for (const item of activePane.getItems()) { + if (item.shouldPromptToSave != null) { + spyOn(item, 'shouldPromptToSave').andReturn(false); + } + activePane.destroyItem(item); + } + } + + for (;;) { + try { + fs.removeSync(testDir); + break + } catch (e) { + await new Promise(resolve => setTimeout(resolve, 50)) + } + } + }); + + describe("when the replace string contains an escaped char", () => { + let filePath = null; + + beforeEach(() => { + let projectPath = temp.mkdirSync("atom"); + filePath = path.join(projectPath, "tabs.txt"); + fs.writeFileSync(filePath, "a\nb\na"); + atom.project.setPaths([projectPath]); + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + + spyOn(atom, 'confirm').andReturn(0); + }); + + describe("when the regex option is chosen", () => { + beforeEach(async () => { + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-regex-option'); + openFilesFindView.findEditor.setText('a'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:confirm'); + await searchPromise; + }); + + it("finds the escape char", async () => { + openFilesFindView.replaceEditor.setText('\\t'); + + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:replace-all'); + await replacePromise; + + expect(fs.readFileSync(filePath, 'utf8')).toBe("\t\nb\n\t"); + }); + + it("doesn't insert an escaped char if there are multiple backslashs in front of the char", async () => { + openFilesFindView.replaceEditor.setText('\\\\t'); + + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:replace-all'); + await replacePromise; + + expect(fs.readFileSync(filePath, 'utf8')).toBe("\\t\nb\n\\t"); + }); + }); + + describe("when regex option is not set", () => { + beforeEach(async () => { + openFilesFindView.findEditor.setText('a'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:confirm'); + await searchPromise; + }); + + it("finds the escape char", async () => { + openFilesFindView.replaceEditor.setText('\\t'); + + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:replace-all'); + await replacePromise; + + expect(fs.readFileSync(filePath, 'utf8')).toBe("\\t\nb\n\\t"); + }); + }); + }); + + describe("replace all button enablement", () => { + let disposable = null; + + it("is disabled initially", () => { + expect(openFilesFindView.refs.replaceAllButton).toHaveClass('disabled') + }); + + it("is disabled when a search returns no results", async () => { + openFilesFindView.findEditor.setText('items'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:confirm'); + await searchPromise; + + expect(openFilesFindView.refs.replaceAllButton).not.toHaveClass('disabled'); + + openFilesFindView.findEditor.setText('nopenotinthefile'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:confirm'); + await searchPromise; + + expect(openFilesFindView.refs.replaceAllButton).toHaveClass('disabled'); + }); + + it("is enabled when a search has results and disabled when there are no results", async () => { + openFilesFindView.findEditor.setText('items'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:confirm'); + + await searchPromise; + + disposable = openFilesFindView.replaceTooltipSubscriptions; + spyOn(disposable, 'dispose'); + + expect(openFilesFindView.refs.replaceAllButton).not.toHaveClass('disabled'); + + // The replace all button should still be disabled as the text has been changed and a new search has not been run + openFilesFindView.findEditor.setText('itemss'); + advanceClock(stoppedChangingDelay); + expect(openFilesFindView.refs.replaceAllButton).toHaveClass('disabled'); + expect(disposable.dispose).toHaveBeenCalled(); + + // The button should still be disabled because the search and search pattern are out of sync + openFilesFindView.replaceEditor.setText('omgomg'); + advanceClock(stoppedChangingDelay); + expect(openFilesFindView.refs.replaceAllButton).toHaveClass('disabled'); + + disposable = openFilesFindView.replaceTooltipSubscriptions; + spyOn(disposable, 'dispose'); + openFilesFindView.findEditor.setText('items'); + advanceClock(stoppedChangingDelay); + expect(openFilesFindView.refs.replaceAllButton).not.toHaveClass('disabled'); + + openFilesFindView.findEditor.setText(''); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:confirm'); + + expect(openFilesFindView.refs.replaceAllButton).toHaveClass('disabled'); + }); + }); + + describe("when the replace button is pressed", () => { + beforeEach(() => { + spyOn(atom, 'confirm').andReturn(0); + }); + + it("runs the search, and replaces all the matches", async () => { + openFilesFindView.findEditor.setText('items'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + openFilesFindView.replaceEditor.setText('sunshine'); + openFilesFindView.refs.replaceAllButton.click(); + await replacePromise; + + expect(openFilesFindView.errorMessages).not.toBeVisible(); + expect(openFilesFindView.refs.descriptionLabel.textContent).toContain('Replaced'); + + const sampleJsContent = fs.readFileSync(sampleJs, 'utf8'); + expect(sampleJsContent.match(/items/g)).toBeFalsy(); + expect(sampleJsContent.match(/sunshine/g)).toHaveLength(6); + + const sampleCoffeeContent = fs.readFileSync(sampleCoffee, 'utf8'); + expect(sampleCoffeeContent.match(/items/g)).toBeFalsy(); + expect(sampleCoffeeContent.match(/sunshine/g)).toHaveLength(7); + }); + + describe("when there are search results after a replace", () => { + it("runs the search after the replace", async () => { + openFilesFindView.findEditor.setText('items'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + openFilesFindView.replaceEditor.setText('items-123'); + openFilesFindView.refs.replaceAllButton.click(); + await replacePromise; + + expect(openFilesFindView.errorMessages).not.toBeVisible(); + expect(getExistingResultsPane().refs.previewCount.textContent).toContain('13 results found in 2 open files for items'); + expect(openFilesFindView.refs.descriptionLabel.textContent).toContain('Replaced items with items-123 13 times in 2 open files'); + + openFilesFindView.replaceEditor.setText('cats'); + advanceClock(openFilesFindView.replaceEditor.getBuffer().stoppedChangingDelay); + expect(openFilesFindView.refs.descriptionLabel.textContent).not.toContain('Replaced items'); + expect(openFilesFindView.refs.descriptionLabel.textContent).toContain("13 results found in 2 open files for items"); + }) + }); + }); + + describe("when the open-files-find:replace-all is triggered", () => { + describe("when no search has been run", () => { + beforeEach(() => { + spyOn(atom, 'confirm').andReturn(0) + }); + + it("does nothing", () => { + openFilesFindView.findEditor.setText('items'); + openFilesFindView.replaceEditor.setText('sunshine'); + + spyOn(atom, 'beep'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:replace-all'); + + expect(replacePromise).toBeUndefined(); + + expect(atom.beep).toHaveBeenCalled(); + expect(openFilesFindView.refs.descriptionLabel.textContent).toContain("Find in Open Files"); + }); + }); + + describe("when a search with no results has been run", () => { + beforeEach(async () => { + spyOn(atom, 'confirm').andReturn(0); + openFilesFindView.findEditor.setText('nopenotinthefile'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + + await searchPromise; + }); + + it("doesnt replace anything", () => { + openFilesFindView.replaceEditor.setText('sunshine'); + + spyOn(pathSearcher, 'searchPaths').andCallThrough(); + spyOn(atom, 'beep'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:replace-all'); + + // The replacement isnt even run + expect(replacePromise).toBeUndefined(); + + expect(pathSearcher.searchPaths).not.toHaveBeenCalled(); + expect(atom.beep).toHaveBeenCalled(); + expect(openFilesFindView.refs.descriptionLabel.textContent.replace(/( )/g, ' ')).toContain("No results"); + }); + }); + + describe("when a search with results has been run", () => { + beforeEach(async () => { + openFilesFindView.findEditor.setText('items'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + + await searchPromise; + }); + + it("messages the user when the search text has changed since that last search", () => { + spyOn(atom, 'confirm').andReturn(0); + spyOn(pathSearcher, 'searchPaths').andCallThrough(); + + openFilesFindView.findEditor.setText('sort'); + openFilesFindView.replaceEditor.setText('ok'); + + advanceClock(stoppedChangingDelay); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:replace-all'); + + expect(replacePromise).toBeUndefined(); + expect(pathSearcher.searchPaths).not.toHaveBeenCalled(); + expect(atom.confirm).toHaveBeenCalled(); + expect(atom.confirm.mostRecentCall.args[0].message).toContain('was changed to'); + }); + + it("replaces all the matches and updates the results view", async () => { + spyOn(atom, 'confirm').andReturn(0); + openFilesFindView.replaceEditor.setText('sunshine'); + + expect(openFilesFindView.errorMessages).not.toBeVisible(); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:replace-all'); + await replacePromise; + + const resultsView = getResultsView(); + expect(resultsView.element).toBeVisible(); + expect(resultsView.refs.listView.element.querySelectorAll(".search-result")).toHaveLength(0); + + expect(openFilesFindView.refs.descriptionLabel.textContent).toContain("Replaced items with sunshine 13 times in 2 open files"); + + let sampleJsContent = fs.readFileSync(sampleJs, 'utf8'); + expect(sampleJsContent.match(/items/g)).toBeFalsy(); + expect(sampleJsContent.match(/sunshine/g)).toHaveLength(6); + + let sampleCoffeeContent = fs.readFileSync(sampleCoffee, 'utf8'); + expect(sampleCoffeeContent.match(/items/g)).toBeFalsy(); + expect(sampleCoffeeContent.match(/sunshine/g)).toHaveLength(7); + }); + + describe("when the confirm box is cancelled", () => { + beforeEach(() => { + spyOn(atom, 'confirm').andReturn(1) + }); + + it("does not replace", async () => { + openFilesFindView.replaceEditor.setText('sunshine'); + + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:replace-all'); + await replacePromise; + + expect(openFilesFindView.refs.descriptionLabel.textContent).toContain("13 results found"); + }); + }); + }); + }); + + describe("when there is an error replacing", () => { + beforeEach(async () => { + spyOn(atom, 'confirm').andReturn(0); + openFilesFindView.findEditor.setText('items'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:confirm'); + await searchPromise; + }); + + it("displays the errors in the results pane", async () => { + let errorList + spyOn(pathReplacer, 'replacePaths').andCallFake(async (regex, replacement, paths, callback) => { + ({ errorList } = getExistingResultsPane().refs); + expect(errorList.querySelectorAll("li")).toHaveLength(0); + + callback(null, {path: '/some/path.js', code: 'ENOENT', message: 'Nope'}); + expect(errorList).toBeVisible(); + expect(errorList.querySelectorAll("li")).toHaveLength(1); + + callback(null, {path: '/some/path.js', code: 'ENOENT', message: 'Broken'}); + expect(errorList.querySelectorAll("li")).toHaveLength(2); + }); + + openFilesFindView.replaceEditor.setText('sunshine'); + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:replace-all'); + await replacePromise; + + expect(errorList).toBeVisible(); + expect(errorList.querySelectorAll("li")).toHaveLength(2); + expect(errorList.querySelectorAll("li")[0].textContent).toBe('Nope'); + expect(errorList.querySelectorAll("li")[1].textContent).toBe('Broken'); + }); + }); + }); + + describe("panel focus", () => { + beforeEach(async () => { + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + await activationPromise; + }); + + it("focuses the find editor when the panel gets focus", () => { + openFilesFindView.replaceEditor.element.focus(); + expect(openFilesFindView.replaceEditor.element).toHaveFocus(); + + openFilesFindView.element.focus(); + expect(openFilesFindView.findEditor.getElement()).toHaveFocus(); + }); + + it("moves focus between editors with find-and-replace:focus-next", () => { + openFilesFindView.findEditor.element.focus(); + expect(openFilesFindView.findEditor.element).toHaveFocus() + + atom.commands.dispatch(openFilesFindView.findEditor.element, 'find-and-replace:focus-next'); + expect(openFilesFindView.replaceEditor.element).toHaveFocus() + + atom.commands.dispatch(openFilesFindView.replaceEditor.element, 'find-and-replace:focus-next'); + expect(openFilesFindView.findEditor.element).toHaveFocus() + + atom.commands.dispatch(openFilesFindView.replaceEditor.element, 'find-and-replace:focus-previous'); + expect(openFilesFindView.replaceEditor.element).toHaveFocus() + }); + }); + + describe("panel opening", () => { + describe("when a panel is already open on the right", () => { + beforeEach(async () => { + atom.config.set('find-and-replace.projectSearchResultsPaneSplitDirection', 'right'); + + editor = await atom.workspace.open('sample.js'); + editorElement = atom.views.getView(editor); + + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + await activationPromise; + + openFilesFindView.findEditor.setText('items'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + }); + + it("doesn't open another panel even if the active pane is vertically split", async () => { + atom.commands.dispatch(editorElement, 'pane:split-down'); + openFilesFindView.findEditor.setText('items'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + expect(workspaceElement.querySelectorAll('.preview-pane').length).toBe(1); + }); + }); + + describe("when a panel is already open at the bottom", () => { + beforeEach(async () => { + atom.config.set('find-and-replace.projectSearchResultsPaneSplitDirection', 'down'); + + editor = await atom.workspace.open('sample.js'); + editorElement = atom.views.getView(editor); + + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + await activationPromise; + + openFilesFindView.findEditor.setText('items'); + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + }); + + it("doesn't open another panel even if the active pane is horizontally split", async () => { + atom.commands.dispatch(editorElement, 'pane:split-right'); + openFilesFindView.findEditor.setText('items'); + + atom.commands.dispatch(openFilesFindView.element, 'core:confirm'); + await searchPromise; + + expect(workspaceElement.querySelectorAll('.preview-pane').length).toBe(1); + }); + }); + }); + + describe("when language-javascript is active", () => { + beforeEach(async () => { + await atom.packages.activatePackage("language-javascript"); + }); + + it("uses the regexp grammar when regex-mode is loaded from configuration", async () => { + atom.config.set('find-and-replace.useRegex', true); + + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + await activationPromise; + + expect(openFilesFindView.model.getFindOptions().useRegex).toBe(true); + expect(openFilesFindView.findEditor.getGrammar().scopeName).toBe('source.js.regexp'); + expect(openFilesFindView.replaceEditor.getGrammar().scopeName).toBe('source.js.regexp.replacement'); + }); + + describe("when panel is active", () => { + beforeEach(async () => { + atom.commands.dispatch(workspaceElement, 'open-files-find:show'); + await activationPromise; + }); + + it("does not use regexp grammar when in non-regex mode", () => { + expect(openFilesFindView.model.getFindOptions().useRegex).not.toBe(true); + expect(openFilesFindView.findEditor.getGrammar().scopeName).toBe('text.plain.null-grammar'); + expect(openFilesFindView.replaceEditor.getGrammar().scopeName).toBe('text.plain.null-grammar'); + }); + + it("uses regexp grammar when in regex mode and clears the regexp grammar when regex is disabled", () => { + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-regex-option'); + + expect(openFilesFindView.model.getFindOptions().useRegex).toBe(true); + expect(openFilesFindView.findEditor.getGrammar().scopeName).toBe('source.js.regexp'); + expect(openFilesFindView.replaceEditor.getGrammar().scopeName).toBe('source.js.regexp.replacement'); + + atom.commands.dispatch(openFilesFindView.element, 'open-files-find:toggle-regex-option'); + + expect(openFilesFindView.model.getFindOptions().useRegex).not.toBe(true); + expect(openFilesFindView.findEditor.getGrammar().scopeName).toBe('text.plain.null-grammar'); + expect(openFilesFindView.replaceEditor.getGrammar().scopeName).toBe('text.plain.null-grammar'); + }); + }); + }); +}); + +function simulateResizeEvent(element) { + Array.from(element.children).forEach((child) => { + child.dispatchEvent(new AnimationEvent('animationstart')); + }); + advanceClock(1); +} diff --git a/styles/find-and-replace.less b/styles/find-and-replace.less index cef1acbf..d4e3f3e1 100644 --- a/styles/find-and-replace.less +++ b/styles/find-and-replace.less @@ -37,7 +37,9 @@ atom-workspace.find-visible { // Both project and buffer FNR styles .find-and-replace, +.open-files-find, .preview-pane, +.open-files-find, .project-find { @min-width: 200px; // min width before it starts scrolling @@ -218,6 +220,7 @@ atom-workspace.find-visible { } // Project find and replace +.open-files-find, .project-find { @project-input-width: 260px; @project-block-width: 160px;