mirror of
https://github.com/atom/atom.git
synced 2026-01-22 21:38:10 -05:00
Merge pull request #7022 from atom/bf-directory-searcher
Introduce atom.directory-searcher service v0.1.0.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
95
src/default-directory-searcher.coffee
Normal file
95
src/default-directory-searcher.coffee
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user