diff --git a/spec/task-spec.coffee b/spec/task-spec.coffee index bddb59d86..947db5567 100644 --- a/spec/task-spec.coffee +++ b/spec/task-spec.coffee @@ -70,3 +70,29 @@ describe "Task", -> task.terminate() expect(stdout.listeners('data').length).toBe 0 expect(stderr.listeners('data').length).toBe 0 + + describe "::cancel()", -> + it "dispatches 'task:cancelled' when invoked on an active task", -> + task = new Task(require.resolve('./fixtures/task-spec-handler')) + cancelledEventSpy = jasmine.createSpy('eventSpy') + task.on('task:cancelled', cancelledEventSpy) + completedEventSpy = jasmine.createSpy('eventSpy') + task.on('task:completed', completedEventSpy) + + expect(task.cancel()).toBe(true) + expect(cancelledEventSpy).toHaveBeenCalled() + expect(completedEventSpy).not.toHaveBeenCalled() + + it "does not dispatch 'task:cancelled' when invoked on an inactive task", -> + handlerResult = null + task = Task.once require.resolve('./fixtures/task-spec-handler'), (result) -> + handlerResult = result + + waitsFor -> + handlerResult? + + runs -> + cancelledEventSpy = jasmine.createSpy('eventSpy') + task.on('task:cancelled', cancelledEventSpy) + expect(task.cancel()).toBe(false) + expect(cancelledEventSpy).not.toHaveBeenCalled() diff --git a/spec/workspace-spec.coffee b/spec/workspace-spec.coffee index 4918fc65f..4666177c6 100644 --- a/spec/workspace-spec.coffee +++ b/spec/workspace-spec.coffee @@ -938,6 +938,90 @@ describe "Workspace", -> .then -> expect(resultPaths).toEqual([file2]) + describe "when a custom directory searcher is registered", -> + fakeSearch = null + # Function that is invoked once all of the fields on fakeSearch are set. + onFakeSearchCreated = null + + class FakeSearch + constructor: (@options) -> + # Note that hoisting resolve and reject in this way is generally frowned upon. + @promise = new Promise (resolve, reject) => + @hoistedResolve = resolve + @hoistedReject = reject + onFakeSearchCreated?(this) + then: (args...) -> + @promise.then.apply(@promise, args) + cancel: -> + @cancelled = true + @hoistedReject() + + beforeEach -> + fakeSearch = null + onFakeSearchCreated = null + atom.packages.serviceHub.provide('atom.directory-searcher', '0.1.0', { + canSearchDirectory: (directory) -> directory.getPath() is dir1 + search: (directory, regex, options) -> fakeSearch = new FakeSearch(options) + }) + + it "can override the DefaultDirectorySearcher on a per-directory basis", -> + foreignFilePath = 'ssh://foreign-directory:8080/hello.txt' + numPathsSearchedInDir2 = 1 + numPathsToPretendToSearchInCustomDirectorySearcher = 10 + searchResult = + filePath: foreignFilePath, + matches: [ + { + lineText: 'Hello world', + lineTextOffset: 0, + matchText: 'Hello', + range: [[0, 0], [0, 5]], + }, + ] + onFakeSearchCreated = (fakeSearch) -> + fakeSearch.options.didMatch(searchResult) + fakeSearch.options.didSearchPaths(numPathsToPretendToSearchInCustomDirectorySearcher) + fakeSearch.hoistedResolve() + + resultPaths = [] + onPathsSearched = jasmine.createSpy('onPathsSearched') + waitsForPromise -> + atom.workspace.scan /aaaa/, {onPathsSearched}, ({filePath}) -> + resultPaths.push(filePath) + + runs -> + expect(resultPaths.sort()).toEqual([foreignFilePath, file2].sort()) + # onPathsSearched should be called once by each DirectorySearcher. The order is not + # guaranteed, so we can only verify the total number of paths searched is correct + # after the second call. + expect(onPathsSearched.callCount).toBe(2) + expect(onPathsSearched.mostRecentCall.args[0]).toBe( + numPathsToPretendToSearchInCustomDirectorySearcher + numPathsSearchedInDir2) + + it "can be cancelled when the object returned by scan() has its cancel() method invoked", -> + thenable = atom.workspace.scan /aaaa/, -> + expect(fakeSearch.cancelled).toBe(undefined) + thenable.cancel() + expect(fakeSearch.cancelled).toBe(true) + + resultOfPromiseSearch = null + waitsForPromise -> + thenable.then (promiseResult) -> resultOfPromiseSearch = promiseResult + + runs -> + expect(resultOfPromiseSearch).toBe('cancelled') + + it "will have the side-effect of failing the overall search if it fails", -> + cancelableSearch = atom.workspace.scan /aaaa/, -> + fakeSearch.hoistedReject() + + didReject = false + waitsForPromise -> + cancelableSearch.catch -> didReject = true + + runs -> + expect(didReject).toBe(true) + describe "::replace(regex, replacementText, paths, iterator)", -> [filePath, commentFilePath, sampleContent, sampleCommentContent] = [] diff --git a/src/default-directory-searcher.coffee b/src/default-directory-searcher.coffee new file mode 100644 index 000000000..ebe3a35f9 --- /dev/null +++ b/src/default-directory-searcher.coffee @@ -0,0 +1,95 @@ +Task = require './task' + +# Public: Searches local files for lines matching a specified regex. +# +# Implements thenable so it can be used with `Promise.all()`. +class DirectorySearch + constructor: (rootPaths, regex, options) -> + scanHandlerOptions = + ignoreCase: regex.ignoreCase + inclusions: options.inclusions + includeHidden: options.includeHidden + excludeVcsIgnores: options.excludeVcsIgnores + exclusions: options.exclusions + follow: options.follow + @task = new Task(require.resolve('./scan-handler')) + @task.on 'scan:result-found', options.didMatch + @task.on 'scan:file-error', options.didError + @task.on 'scan:paths-searched', options.didSearchPaths + @promise = new Promise (resolve, reject) => + @task.on('task:cancelled', reject) + @task.start(rootPaths, regex.source, scanHandlerOptions, resolve) + + # Public: Implementation of `then()` to satisfy the *thenable* contract. + # This makes it possible to use a `DirectorySearch` with `Promise.all()`. + # + # Returns `Promise`. + then: (args...) -> + @promise.then.apply(@promise, args) + + # Public: Cancels the search. + cancel: -> + # This will cause @promise to reject. + @task.cancel() + null + + +# Default provider for the `atom.directory-searcher` service. +module.exports = +class DefaultDirectorySearcher + # Public: Determines whether this object supports search for a `Directory`. + # + # * `directory` {Directory} whose search needs might be supported by this object. + # + # Returns a `boolean` indicating whether this object can search this `Directory`. + canSearchDirectory: (directory) -> true + + # Public: Performs a text search for files in the specified `Directory`, subject to the + # specified parameters. + # + # Results are streamed back to the caller by invoking methods on the specified `options`, + # such as `didMatch` and `didError`. + # + # * `directories` {Array} of {Directory} objects to search, all of which have been accepted by + # this searcher's `canSearchDirectory()` predicate. + # * `regex` {RegExp} to search with. + # * `options` {Object} with the following properties: + # * `didMatch` {Function} call with a search result structured as follows: + # * `searchResult` {Object} with the following keys: + # * `filePath` {String} absolute path to the matching file. + # * `matches` {Array} with object elements with the following keys: + # * `lineText` {String} The full text of the matching line (without a line terminator character). + # * `lineTextOffset` {Number} (This always seems to be 0?) + # * `matchText` {String} The text that matched the `regex` used for the search. + # * `range` {Range} Identifies the matching region in the file. (Likely as an array of numeric arrays.) + # * `didError` {Function} call with an Error if there is a problem during the search. + # * `didSearchPaths` {Function} periodically call with the number of paths searched thus far. + # * `inclusions` {Array} of glob patterns (as strings) to search within. Note that this + # array may be empty, indicating that all files should be searched. + # + # Each item in the array is a file/directory pattern, e.g., `src` to search in the "src" + # directory or `*.js` to search all JavaScript files. In practice, this often comes from the + # comma-delimited list of patterns in the bottom text input of the ProjectFindView dialog. + # * `ignoreHidden` {boolean} whether to ignore hidden files. + # * `excludeVcsIgnores` {boolean} whether to exclude VCS ignored paths. + # * `exclusions` {Array} similar to inclusions + # * `follow` {boolean} whether symlinks should be followed. + # + # Returns a *thenable* `DirectorySearch` that includes a `cancel()` method. If `cancel()` is + # invoked before the `DirectorySearch` is determined, it will resolve the `DirectorySearch`. + search: (directories, regex, options) -> + rootPaths = directories.map (directory) -> directory.getPath() + isCancelled = false + directorySearch = new DirectorySearch(rootPaths, regex, options) + promise = new Promise (resolve, reject) -> + directorySearch.then resolve, -> + if isCancelled + resolve() + else + reject() + return { + then: promise.then.bind(promise) + cancel: -> + isCancelled = true + directorySearch.cancel() + } diff --git a/src/task.coffee b/src/task.coffee index 939b71635..34c943c6a 100644 --- a/src/task.coffee +++ b/src/task.coffee @@ -150,7 +150,7 @@ class Task # # No more events are emitted once this method is called. terminate: -> - return unless @childProcess? + return false unless @childProcess? @childProcess.removeAllListeners() @childProcess.stdout.removeAllListeners() @@ -158,4 +158,10 @@ class Task @childProcess.kill() @childProcess = null - undefined + true + + cancel: -> + didForcefullyTerminate = @terminate() + if didForcefullyTerminate + @emit('task:cancelled') + didForcefullyTerminate diff --git a/src/workspace.coffee b/src/workspace.coffee index 157d002de..6311cdd6d 100644 --- a/src/workspace.coffee +++ b/src/workspace.coffee @@ -7,6 +7,7 @@ Serializable = require 'serializable' {Emitter, Disposable, CompositeDisposable} = require 'event-kit' Grim = require 'grim' fs = require 'fs-plus' +DefaultDirectorySearcher = require './default-directory-searcher' Model = require './model' TextEditor = require './text-editor' PaneContainer = require './pane-container' @@ -46,6 +47,13 @@ class Workspace extends Model @paneContainer ?= new PaneContainer() @paneContainer.onDidDestroyPaneItem(@didDestroyPaneItem) + @directorySearchers = [] + @defaultDirectorySearcher = new DefaultDirectorySearcher() + atom.packages.serviceHub.consume( + 'atom.directory-searcher', + '^0.1.0', + (provider) => @directorySearchers.unshift(provider)) + @panelContainers = top: new PanelContainer({location: 'top'}) left: new PanelContainer({location: 'left'}) @@ -791,36 +799,65 @@ class Workspace extends Model # * `regex` {RegExp} to search with. # * `options` (optional) {Object} (default: {}) # * `paths` An {Array} of glob patterns to search within + # * `onPathsSearched` (optional) {Function} # * `iterator` {Function} callback on each file found # - # Returns a `Promise`. + # Returns a `Promise` with a `cancel()` method that will cancel all + # of the underlying searches that were started as part of this scan. scan: (regex, options={}, iterator) -> if _.isFunction(options) iterator = options options = {} - deferred = Q.defer() - - searchOptions = - ignoreCase: regex.ignoreCase - inclusions: options.paths - includeHidden: true - excludeVcsIgnores: atom.config.get('core.excludeVcsIgnoredPaths') - exclusions: atom.config.get('core.ignoredNames') - follow: atom.config.get('core.followSymlinks') - - task = Task.once require.resolve('./scan-handler'), atom.project.getPaths(), regex.source, searchOptions, -> - deferred.resolve() - - task.on 'scan:result-found', (result) -> - iterator(result) unless atom.project.isPathModified(result.filePath) - - task.on 'scan:file-error', (error) -> - iterator(null, error) + # Find a searcher for every Directory in the project. Each searcher that is matched + # will be associated with an Array of Directory objects in the Map. + directoriesForSearcher = new Map() + for directory in atom.project.getDirectories() + searcher = @defaultDirectorySearcher + for directorySearcher in @directorySearchers + if directorySearcher.canSearchDirectory(directory) + searcher = directorySearcher + break + directories = directoriesForSearcher.get(searcher) + unless directories + directories = [] + directoriesForSearcher.set(searcher, directories) + directories.push(directory) + # Define the onPathsSearched callback. if _.isFunction(options.onPathsSearched) - task.on 'scan:paths-searched', (numberOfPathsSearched) -> - options.onPathsSearched(numberOfPathsSearched) + # Maintain a map of directories to the number of search results. When notified of a new count, + # replace the entry in the map and update the total. + onPathsSearchedOption = options.onPathsSearched + totalNumberOfPathsSearched = 0 + numberOfPathsSearchedForSearcher = new Map() + onPathsSearched = (searcher, numberOfPathsSearched) -> + oldValue = numberOfPathsSearchedForSearcher.get(searcher) + if oldValue + totalNumberOfPathsSearched -= oldValue + numberOfPathsSearchedForSearcher.set(searcher, numberOfPathsSearched) + totalNumberOfPathsSearched += numberOfPathsSearched + onPathsSearchedOption(totalNumberOfPathsSearched) + else + onPathsSearched = -> + + # Kick off all of the searches and unify them into one Promise. + allSearches = [] + directoriesForSearcher.forEach (directories, searcher) -> + searchOptions = + inclusions: options.paths or [] + includeHidden: true + excludeVcsIgnores: atom.config.get('core.excludeVcsIgnoredPaths') + exclusions: atom.config.get('core.ignoredNames') + follow: atom.config.get('core.followSymlinks') + didMatch: (result) -> + iterator(result) unless atom.project.isPathModified(result.filePath) + didError: (error) -> + iterator(null, error) + didSearchPaths: (count) -> onPathsSearched(searcher, count) + directorySearcher = searcher.search(directories, regex, searchOptions) + allSearches.push(directorySearcher) + searchPromise = Promise.all(allSearches) for buffer in atom.project.getBuffers() when buffer.isModified() filePath = buffer.getPath() @@ -829,11 +866,33 @@ class Workspace extends Model buffer.scan regex, (match) -> matches.push match iterator {filePath, matches} if matches.length > 0 - promise = deferred.promise - promise.cancel = -> - task.terminate() - deferred.resolve('cancelled') - promise + # Make sure the Promise that is returned to the client is cancelable. To be consistent + # with the existing behavior, instead of cancel() rejecting the promise, it should + # resolve it with the special value 'cancelled'. At least the built-in find-and-replace + # package relies on this behavior. + isCancelled = false + cancellablePromise = new Promise (resolve, reject) -> + onSuccess = -> + resolve(null) + onFailure = -> + if isCancelled + resolve('cancelled') + else + reject() + searchPromise.then(onSuccess, onFailure) + cancellablePromise.cancel = -> + isCancelled = true + # Note that cancelling all (or actually, any) of the members of allSearches + # will cause searchPromise to reject, which will cause cancellablePromise to resolve + # in the desired way. + promise.cancel() for promise in allSearches + + # Although this method claims to return a `Promise`, the `ResultsPaneView.onSearch()` + # method in the find-and-replace package expects the object returned by this method to have a + # `done()` method. Include a done() method until find-and-replace can be updated. + cancellablePromise.done = (onSuccessOrFailure) -> + cancellablePromise.then(onSuccessOrFailure, onSuccessOrFailure) + cancellablePromise # Public: Performs a replace across all the specified files in the project. #