src/search/FindInFiles.js
/*
* Copyright (c) 2012 - present Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
/*
* The core search functionality used by Find in Files and single-file Replace Batch.
*/
define(function (require, exports, module) {
"use strict";
var _ = require("thirdparty/lodash"),
FileFilters = require("search/FileFilters"),
Async = require("utils/Async"),
StringUtils = require("utils/StringUtils"),
ProjectManager = require("project/ProjectManager"),
PreferencesManager = require("preferences/PreferencesManager"),
DocumentModule = require("document/Document"),
DocumentManager = require("document/DocumentManager"),
MainViewManager = require("view/MainViewManager"),
FileSystem = require("filesystem/FileSystem"),
LanguageManager = require("language/LanguageManager"),
SearchModel = require("search/SearchModel").SearchModel,
PerfUtils = require("utils/PerfUtils"),
NodeDomain = require("utils/NodeDomain"),
FileUtils = require("file/FileUtils"),
FindUtils = require("search/FindUtils"),
HealthLogger = require("utils/HealthLogger");
var _bracketsPath = FileUtils.getNativeBracketsDirectoryPath(),
_modulePath = FileUtils.getNativeModuleDirectoryPath(module),
_nodePath = "node/FindInFilesDomain",
_domainPath = [_bracketsPath, _modulePath, _nodePath].join("/"),
searchDomain = new NodeDomain("FindInFiles", _domainPath),
searchScopeChanged = false,
findOrReplaceInProgress = false,
changedFileList = {};
/**
* Token used to indicate a specific reason for zero search results
* @const @type {!Object}
*/
var ZERO_FILES_TO_SEARCH = {};
/**
* Maximum length of text displayed in search results panel
* @const
*/
var MAX_DISPLAY_LENGTH = 200;
/**
* The search query and results model.
* @type {SearchModel}
*/
var searchModel = new SearchModel();
/* Forward declarations */
var _documentChangeHandler,
_fileSystemChangeHandler,
_processCachedFileSystemEvents,
_debouncedFileSystemChangeHandler,
_fileNameChangeHandler,
clearSearch;
/**
* Waits for FS changes to stack up until processing them
* (scripts like npm install can do a lot of movements on the disk)
* @const
*/
var FILE_SYSTEM_EVENT_DEBOUNCE_TIME = 100;
/** Remove the listeners that were tracking potential search result changes */
function _removeListeners() {
DocumentModule.off("documentChange", _documentChangeHandler);
FileSystem.off("change", _debouncedFileSystemChangeHandler);
DocumentManager.off("fileNameChange", _fileNameChangeHandler);
}
/** Add listeners to track events that might change the search result set */
function _addListeners() {
// Avoid adding duplicate listeners - e.g. if a 2nd search is run without closing the old results panel first
_removeListeners();
DocumentModule.on("documentChange", _documentChangeHandler);
FileSystem.on("change", _debouncedFileSystemChangeHandler);
DocumentManager.on("fileNameChange", _fileNameChangeHandler);
}
function nodeFileCacheComplete(event, numFiles, cacheSize) {
if (/\/test\/SpecRunner\.html$/.test(window.location.pathname)) {
// Ignore the event in the SpecRunner window
return;
}
var projectRoot = ProjectManager.getProjectRoot(),
projectName = projectRoot ? projectRoot.name : null;
if (!projectName) {
console.error("'File cache complete' event received, but no project root found");
projectName = "noName00";
}
FindUtils.setInstantSearchDisabled(false);
// Node search could be disabled if some error has happened in node. But upon
// project change, if we get this message, then it means that node search is working,
// we re-enable node search. If a search fails, node search will be switched off eventually.
FindUtils.setNodeSearchDisabled(false);
FindUtils.notifyIndexingFinished();
HealthLogger.setProjectDetail(projectName, numFiles, cacheSize);
}
/**
* @private
* Searches through the contents and returns an array of matches
* @param {string} contents
* @param {RegExp} queryExpr
* @return {!Array.<{start: {line:number,ch:number}, end: {line:number,ch:number}, line: string}>}
*/
function _getSearchMatches(contents, queryExpr) {
// Quick exit if not found or if we hit the limit
if (searchModel.foundMaximum || contents.search(queryExpr) === -1) {
return [];
}
var match, lineNum, line, ch, totalMatchLength, matchedLines, numMatchedLines, lastLineLength, endCh,
padding, leftPadding, rightPadding, highlightOffset, highlightEndCh,
lines = StringUtils.getLines(contents),
matches = [];
while ((match = queryExpr.exec(contents)) !== null) {
lineNum = StringUtils.offsetToLineNum(lines, match.index);
line = lines[lineNum];
ch = match.index - contents.lastIndexOf("\n", match.index - 1) - 1; // 0-based index
matchedLines = match[0].split("\n");
numMatchedLines = matchedLines.length;
totalMatchLength = match[0].length;
lastLineLength = matchedLines[matchedLines.length - 1].length;
endCh = (numMatchedLines === 1 ? ch + totalMatchLength : lastLineLength);
highlightEndCh = (numMatchedLines === 1 ? endCh : line.length);
highlightOffset = 0;
if (highlightEndCh <= MAX_DISPLAY_LENGTH) {
// Don't store more than 200 chars per line
line = line.substr(0, MAX_DISPLAY_LENGTH);
} else if (totalMatchLength > MAX_DISPLAY_LENGTH) {
// impossible to display the whole match
line = line.substr(ch, ch + MAX_DISPLAY_LENGTH);
highlightOffset = ch;
} else {
// Try to have both beginning and end of match displayed
padding = MAX_DISPLAY_LENGTH - totalMatchLength;
rightPadding = Math.floor(Math.min(padding / 2, line.length - highlightEndCh));
leftPadding = Math.ceil(padding - rightPadding);
highlightOffset = ch - leftPadding;
line = line.substring(highlightOffset, highlightEndCh + rightPadding);
}
matches.push({
start: {line: lineNum, ch: ch},
end: {line: lineNum + numMatchedLines - 1, ch: endCh},
highlightOffset: highlightOffset,
// Note that the following offsets from the beginning of the file are *not* updated if the search
// results change. These are currently only used for multi-file replacement, and we always
// abort the replace (by shutting the results panel) if we detect any result changes, so we don't
// need to keep them up to date. Eventually, we should either get rid of the need for these (by
// doing everything in terms of line/ch offsets, though that will require re-splitting files when
// doing a replace) or properly update them.
startOffset: match.index,
endOffset: match.index + totalMatchLength,
line: line,
result: match,
isChecked: true
});
// We have the max hits in just this 1 file. Stop searching this file.
// This fixed issue #1829 where code hangs on too many hits.
// Adds one over MAX_TOTAL_RESULTS in order to know if the search has exceeded
// or is equal to MAX_TOTAL_RESULTS. Additional result removed in SearchModel
if (matches.length > SearchModel.MAX_TOTAL_RESULTS) {
queryExpr.lastIndex = 0;
break;
}
// Pathological regexps like /^/ return 0-length matches. Ensure we make progress anyway
if (totalMatchLength === 0) {
queryExpr.lastIndex++;
}
}
return matches;
}
/**
* @private
* Update the search results using the given list of changes for the given document
* @param {Document} doc The Document that changed, should be the current one
* @param {Array.<{from: {line:number,ch:number}, to: {line:number,ch:number}, text: !Array.<string>}>} changeList
* An array of changes as described in the Document constructor
*/
function _updateResults(doc, changeList) {
var i, diff, matches, lines, start, howMany,
resultsChanged = false,
fullPath = doc.file.fullPath,
resultInfo = searchModel.results[fullPath];
// Remove the results before we make any changes, so the SearchModel can accurately update its count.
searchModel.removeResults(fullPath);
changeList.forEach(function (change) {
lines = [];
start = 0;
howMany = 0;
// There is no from or to positions, so the entire file changed, we must search all over again
if (!change.from || !change.to) {
// TODO: add unit test exercising timestamp logic in this case
// We don't just call _updateSearchMatches() here because we want to continue iterating through changes in
// the list and update at the end.
resultInfo = {matches: _getSearchMatches(doc.getText(), searchModel.queryExpr), timestamp: doc.diskTimestamp};
resultsChanged = true;
} else {
// Get only the lines that changed
for (i = 0; i < change.text.length; i++) {
lines.push(doc.getLine(change.from.line + i));
}
// We need to know how many newlines were inserted/deleted in order to update the rest of the line indices;
// this is the total number of newlines inserted (which is the length of the lines array minus
// 1, since the last line in the array is inserted without a newline after it) minus the
// number of original newlines being removed.
diff = lines.length - 1 - (change.to.line - change.from.line);
if (resultInfo) {
// Search the last match before a replacement, the amount of matches deleted and update
// the lines values for all the matches after the change
resultInfo.matches.forEach(function (item) {
if (item.end.line < change.from.line) {
start++;
} else if (item.end.line <= change.to.line) {
howMany++;
} else {
item.start.line += diff;
item.end.line += diff;
}
});
// Delete the lines that where deleted or replaced
if (howMany > 0) {
resultInfo.matches.splice(start, howMany);
}
resultsChanged = true;
}
// Searches only over the lines that changed
matches = _getSearchMatches(lines.join("\r\n"), searchModel.queryExpr);
if (matches.length) {
// Updates the line numbers, since we only searched part of the file
matches.forEach(function (value, key) {
matches[key].start.line += change.from.line;
matches[key].end.line += change.from.line;
});
// If the file index exists, add the new matches to the file at the start index found before
if (resultInfo) {
Array.prototype.splice.apply(resultInfo.matches, [start, 0].concat(matches));
// If not, add the matches to a new file index
} else {
// TODO: add unit test exercising timestamp logic in self case
resultInfo = {
matches: matches,
collapsed: false,
timestamp: doc.diskTimestamp
};
}
resultsChanged = true;
}
}
});
// Always re-add the results, even if nothing changed.
if (resultInfo && resultInfo.matches.length) {
searchModel.setResults(fullPath, resultInfo);
}
if (resultsChanged) {
// Pass `true` for quickChange here. This will make listeners debounce the change event,
// avoiding lots of updates if the user types quickly.
searchModel.fireChanged(true);
}
}
/**
* Checks that the file matches the given subtree scope. To fully check whether the file
* should be in the search set, use _inSearchScope() instead - a supserset of this.
*
* @param {!File} file
* @param {?FileSystemEntry} scope Search scope, or null if whole project
* @return {boolean}
*/
function _subtreeFilter(file, scope) {
if (scope) {
if (scope.isDirectory) {
// Dirs always have trailing slash, so we don't have to worry about being
// a substring of another dir name
return file.fullPath.indexOf(scope.fullPath) === 0;
} else {
return file.fullPath === scope.fullPath;
}
}
return true;
}
/**
* Filters out files that are known binary types.
* @param {string} fullPath
* @return {boolean} True if the file's contents can be read as text
*/
function _isReadableText(fullPath) {
return !LanguageManager.getLanguageForPath(fullPath).isBinary();
}
/**
* Finds all candidate files to search in the given scope's subtree that are not binary content. Does NOT apply
* the current filter yet.
* @param {?FileSystemEntry} scope Search scope, or null if whole project
* @return {$.Promise} A promise that will be resolved with the list of files in the scope. Never rejected.
*/
function getCandidateFiles(scope) {
function filter(file) {
return _subtreeFilter(file, scope) && _isReadableText(file.fullPath);
}
// If the scope is a single file, just check if the file passes the filter directly rather than
// trying to use ProjectManager.getAllFiles(), both for performance and because an individual
// in-memory file might be an untitled document that doesn't show up in getAllFiles().
if (scope && scope.isFile) {
return new $.Deferred().resolve(filter(scope) ? [scope] : []).promise();
} else {
return ProjectManager.getAllFiles(filter, true, true);
}
}
/**
* Checks that the file is eligible for inclusion in the search (matches the user's subtree scope and
* file exclusion filters, and isn't binary). Used when updating results incrementally - during the
* initial search, these checks are done in bulk via getCandidateFiles() and the filterFileList() call
* after it.
* @param {!File} file
* @return {boolean}
*/
function _inSearchScope(file) {
// Replicate the checks getCandidateFiles() does
if (searchModel && searchModel.scope) {
if (!_subtreeFilter(file, searchModel.scope)) {
return false;
}
} else {
// Still need to make sure it's within project or working set
// In getCandidateFiles(), this is covered by the baseline getAllFiles() itself
if (file.fullPath.indexOf(ProjectManager.getProjectRoot().fullPath) !== 0) {
if (MainViewManager.findInWorkingSet(MainViewManager.ALL_PANES, file.fullPath) === -1) {
return false;
}
}
}
if (!_isReadableText(file.fullPath)) {
return false;
}
// Replicate the filtering filterFileList() does
return FileFilters.filterPath(searchModel.filter, file.fullPath);
}
/**
* @private
* Tries to update the search result on document changes
* @param {$.Event} event
* @param {Document} document
* @param {<{from: {line:number,ch:number}, to: {line:number,ch:number}, text: !Array.<string>}>} change
* A change list as described in the Document constructor
*/
_documentChangeHandler = function (event, document, change) {
if (!findOrReplaceInProgress) {
changedFileList[document.file.fullPath] = true;
} else {
if (_inSearchScope(document.file)) {
_updateResults(document, change);
}
}
};
/**
* @private
* Finds search results in the given file and adds them to 'searchResults.' Resolves with
* true if any matches found, false if none found. Errors reading the file are treated the
* same as if no results found.
*
* Does not perform any filtering - assumes caller has already vetted this file as a search
* candidate.
*
* @param {!File} file
* @return {$.Promise}
*/
function _doSearchInOneFile(file) {
var result = new $.Deferred();
DocumentManager.getDocumentText(file)
.done(function (text, timestamp) {
// Note that we don't fire a model change here, since this is always called by some outer batch
// operation that will fire it once it's done.
var matches = _getSearchMatches(text, searchModel.queryExpr);
searchModel.setResults(file.fullPath, {matches: matches, timestamp: timestamp});
result.resolve(!!matches.length);
})
.fail(function () {
// Always resolve. If there is an error, this file
// is skipped and we move on to the next file.
result.resolve(false);
});
return result.promise();
}
/**
* @private
* Inform node that the document has changed [along with its contents]
* @param {string} docPath the path of the changed document
*/
function _updateDocumentInNode(docPath) {
DocumentManager.getDocumentForPath(docPath).done(function (doc) {
if (doc) {
var updateObject = {
"filePath": docPath,
"docContents": doc.getText()
};
searchDomain.exec("documentChanged", updateObject);
}
});
}
/**
* @private
* sends all changed documents that we have tracked to node
*/
function _updateChangedDocs() {
var key = null;
for (key in changedFileList) {
if (changedFileList.hasOwnProperty(key)) {
_updateDocumentInNode(key);
}
}
}
/**
* @private
* Executes the Find in Files search inside the current scope.
* @param {{query: string, caseSensitive: boolean, isRegexp: boolean}} queryInfo Query info object
* @param {!$.Promise} candidateFilesPromise Promise from getCandidateFiles(), which was called earlier
* @param {?string} filter A "compiled" filter as returned by FileFilters.compile(), or null for no filter
* @return {?$.Promise} A promise that's resolved with the search results (or ZERO_FILES_TO_SEARCH) or rejected when the find competes.
* Will be null if the query is invalid.
*/
function _doSearch(queryInfo, candidateFilesPromise, filter) {
searchModel.filter = filter;
var queryResult = searchModel.setQueryInfo(queryInfo);
if (!queryResult) {
return null;
}
var scopeName = searchModel.scope ? searchModel.scope.fullPath : ProjectManager.getProjectRoot().fullPath,
perfTimer = PerfUtils.markStart("FindIn: " + scopeName + " - " + queryInfo.query);
findOrReplaceInProgress = true;
return candidateFilesPromise
.then(function (fileListResult) {
// Filter out files/folders that match user's current exclusion filter
fileListResult = FileFilters.filterFileList(filter, fileListResult);
if (searchModel.isReplace || FindUtils.isNodeSearchDisabled()) {
if (fileListResult.length) {
searchModel.allResultsAvailable = true;
return Async.doInParallel(fileListResult, _doSearchInOneFile);
} else {
return ZERO_FILES_TO_SEARCH;
}
}
var searchDeferred = new $.Deferred();
if (fileListResult.length) {
var searchObject;
if (searchScopeChanged) {
var files = fileListResult
.filter(function (entry) {
return entry.isFile && _isReadableText(entry.fullPath);
})
.map(function (entry) {
return entry.fullPath;
});
/* The following line prioritizes the open Document in editor and
* pushes it to the top of the filelist. */
files = FindUtils.prioritizeOpenFile(files, FindUtils.getOpenFilePath());
searchObject = {
"files": files,
"queryInfo": queryInfo,
"queryExpr": searchModel.queryExpr
};
searchScopeChanged = false;
} else {
searchObject = {
"queryInfo": queryInfo,
"queryExpr": searchModel.queryExpr
};
}
if (searchModel.isReplace) {
searchObject.getAllResults = true;
}
_updateChangedDocs();
FindUtils.notifyNodeSearchStarted();
searchDomain.exec("doSearch", searchObject)
.done(function (rcvd_object) {
FindUtils.notifyNodeSearchFinished();
if (!rcvd_object || !rcvd_object.results) {
console.log('no node falling back to brackets search');
FindUtils.setNodeSearchDisabled(true);
searchDeferred.fail();
clearSearch();
return;
}
searchModel.results = rcvd_object.results;
searchModel.numMatches = rcvd_object.numMatches;
searchModel.numFiles = rcvd_object.numFiles;
searchModel.exceedsMaximum = rcvd_object.exceedsMaximum;
searchModel.allResultsAvailable = rcvd_object.allResultsAvailable;
searchDeferred.resolve();
})
.fail(function () {
FindUtils.notifyNodeSearchFinished();
console.log('node fails');
FindUtils.setNodeSearchDisabled(true);
clearSearch();
searchDeferred.reject();
});
return searchDeferred.promise();
} else {
return ZERO_FILES_TO_SEARCH;
}
})
.then(function (zeroFilesToken) {
exports._searchDone = true; // for unit tests
PerfUtils.addMeasurement(perfTimer);
if (zeroFilesToken === ZERO_FILES_TO_SEARCH) {
return zeroFilesToken;
} else {
return searchModel.results;
}
}, function (err) {
console.log("find in files failed: ", err);
PerfUtils.finalizeMeasurement(perfTimer);
// In jQuery promises, returning the error here propagates the rejection,
// unlike in Promises/A, where we would need to re-throw it to do so.
return err;
});
}
/**
* @private
* Clears any previous search information, removing update listeners and clearing the model.
* @param {?Entry} scope Project file/subfolder to search within; else searches whole project.
*/
clearSearch = function () {
findOrReplaceInProgress = false;
searchModel.clear();
};
/**
* Does a search in the given scope with the given filter. Used when you want to start a search
* programmatically.
* @param {{query: string, caseSensitive: boolean, isRegexp: boolean}} queryInfo Query info object
* @param {?Entry} scope Project file/subfolder to search within; else searches whole project.
* @param {?string} filter A "compiled" filter as returned by FileFilters.compile(), or null for no filter
* @param {?string} replaceText If this is a replacement, the text to replace matches with. This is just
* stored in the model for later use - the replacement is not actually performed right now.
* @param {?$.Promise} candidateFilesPromise If specified, a promise that should resolve with the same set of files that
* getCandidateFiles(scope) would return.
* @return {$.Promise} A promise that's resolved with the search results or rejected when the find competes.
*/
function doSearchInScope(queryInfo, scope, filter, replaceText, candidateFilesPromise) {
clearSearch();
searchModel.scope = scope;
if (replaceText !== undefined) {
searchModel.isReplace = true;
searchModel.replaceText = replaceText;
}
candidateFilesPromise = candidateFilesPromise || getCandidateFiles(scope);
return _doSearch(queryInfo, candidateFilesPromise, filter);
}
/**
* Given a set of search results, replaces them with the given replaceText, either on disk or in memory.
* @param {Object.<fullPath: string, {matches: Array.<{start: {line:number,ch:number}, end: {line:number,ch:number}, startOffset: number, endOffset: number, line: string}>, collapsed: boolean}>} results
* The list of results to replace, as returned from _doSearch..
* @param {string} replaceText The text to replace each result with.
* @param {?Object} options An options object:
* forceFilesOpen: boolean - Whether to open all files in editors and do replacements there rather than doing the
* replacements on disk. Note that even if this is false, files that are already open in editors will have replacements
* done in memory.
* isRegexp: boolean - Whether the original query was a regexp. If true, $-substitution is performed on the replaceText.
* @return {$.Promise} A promise that's resolved when the replacement is finished or rejected with an array of errors
* if there were one or more errors. Each individual item in the array will be a {item: string, error: string} object,
* where item is the full path to the file that could not be updated, and error is either a FileSystem error or one
* of the `FindInFiles.ERROR_*` constants.
*/
function doReplace(results, replaceText, options) {
return FindUtils.performReplacements(results, replaceText, options).always(function () {
// For UI integration testing only
exports._replaceDone = true;
});
}
/**
* @private
* Flags that the search scope has changed, so that the file list for the following search is recomputed
*/
var _searchScopeChanged = function () {
searchScopeChanged = true;
};
/**
* Notify node that the results should be collapsed
*/
function _searchcollapseResults() {
if (FindUtils.isNodeSearchDisabled()) {
return;
}
searchDomain.exec("collapseResults", FindUtils.isCollapsedResults());
}
/**
* Inform node that the list of files has changed.
* @param {array} fileList The list of files that changed.
*/
function filesChanged(fileList) {
if (FindUtils.isNodeSearchDisabled() || !fileList || fileList.length === 0) {
return;
}
var updateObject = {
"fileList": fileList
};
if (searchModel.filter) {
updateObject.filesInSearchScope = FileFilters.getPathsMatchingFilter(searchModel.filter, fileList);
_searchScopeChanged();
}
searchDomain.exec("filesChanged", updateObject);
}
/**
* Inform node that the list of files have been removed.
* @param {array} fileList The list of files that was removed.
*/
function filesRemoved(fileList) {
if (FindUtils.isNodeSearchDisabled() || !fileList || fileList.length === 0) {
return;
}
var updateObject = {
"fileList": fileList
};
if (searchModel.filter) {
updateObject.filesInSearchScope = FileFilters.getPathsMatchingFilter(searchModel.filter, fileList);
_searchScopeChanged();
}
searchDomain.exec("filesRemoved", updateObject);
}
/**
* @private
* Moves the search results from the previous path to the new one and updates the results list, if required
* @param {$.Event} event
* @param {string} oldName
* @param {string} newName
*/
_fileNameChangeHandler = function (event, oldName, newName) {
var resultsChanged = false;
// Update the search results
_.forEach(searchModel.results, function (item, fullPath) {
if (fullPath.indexOf(oldName) === 0) {
// node search : inform node about the rename
filesRemoved([fullPath]);
filesChanged([fullPath.replace(oldName, newName)]);
if (findOrReplaceInProgress) {
searchModel.removeResults(fullPath);
searchModel.setResults(fullPath.replace(oldName, newName), item);
resultsChanged = true;
}
}
});
if (resultsChanged) {
searchModel.fireChanged();
}
};
/**
* @private
* Updates search results in response to FileSystem "change" event
* @param {$.Event} event
* @param {FileSystemEntry} entry
* @param {Array.<FileSystemEntry>=} added Added children
* @param {Array.<FileSystemEntry>=} removed Removed children
*/
_fileSystemChangeHandler = function (event, entry, added, removed) {
var resultsChanged = false;
/*
* Remove existing search results that match the given entry's path
* @param {Array.<(File|Directory)>} entries
*/
function _removeSearchResultsForEntries(entries) {
var fullPaths = [];
entries.forEach(function (entry) {
Object.keys(searchModel.results).forEach(function (fullPath) {
if (fullPath === entry.fullPath ||
(entry.isDirectory && fullPath.indexOf(entry.fullPath) === 0)) {
// node search : inform node that the file is removed
fullPaths.push(fullPath);
if (findOrReplaceInProgress) {
searchModel.removeResults(fullPath);
resultsChanged = true;
}
}
});
});
// this should be called once with a large array instead of numerous calls with single items
filesRemoved(fullPaths);
}
/*
* Add new search results for these entries and all of its children
* @param {Array.<(File|Directory)>} entries
* @return {jQuery.Promise} Resolves when the results have been added
*/
function _addSearchResultsForEntries(entries) {
var fullPaths = [];
return Async.doInParallel(entries, function (entry) {
var addedFiles = [],
addedFilePaths = [],
deferred = new $.Deferred();
// gather up added files
var visitor = function (child) {
// Replicate filtering that getAllFiles() does
if (ProjectManager.shouldShow(child)) {
if (child.isFile && _isReadableText(child.name)) {
// Re-check the filtering that the initial search applied
if (_inSearchScope(child)) {
addedFiles.push(child);
addedFilePaths.push(child.fullPath);
}
}
return true;
}
return false;
};
entry.visit(visitor, function (err) {
if (err) {
deferred.reject(err);
return;
}
//node Search : inform node about the file changes
//filesChanged(addedFilePaths);
fullPaths = fullPaths.concat(addedFilePaths);
if (findOrReplaceInProgress) {
// find additional matches in all added files
Async.doInParallel(addedFiles, function (file) {
return _doSearchInOneFile(file)
.done(function (foundMatches) {
resultsChanged = resultsChanged || foundMatches;
});
}).always(deferred.resolve);
} else {
deferred.resolve();
}
});
return deferred.promise();
}).always(function () {
// this should be called once with a large array instead of numerous calls with single items
filesChanged(fullPaths);
});
}
if (!entry) {
// TODO: re-execute the search completely?
return;
}
var addPromise;
if (entry.isDirectory) {
if (added.length === 0 && removed.length === 0) {
// If the added or removed sets are null, must redo the search for the entire subtree - we
// don't know which child files/folders may have been added or removed.
_removeSearchResultsForEntries([ entry ]);
var deferred = $.Deferred();
addPromise = deferred.promise();
entry.getContents(function (err, entries) {
_addSearchResultsForEntries(entries).always(deferred.resolve);
});
} else {
_removeSearchResultsForEntries(removed);
addPromise = _addSearchResultsForEntries(added);
}
} else { // entry.isFile
_removeSearchResultsForEntries([ entry ]);
addPromise = _addSearchResultsForEntries([ entry ]);
}
addPromise.always(function () {
// Restore the results if needed
if (resultsChanged) {
searchModel.fireChanged();
}
});
};
/**
* This stores file system events emitted by watchers that were not yet processed
*/
var _cachedFileSystemEvents = [];
/**
* Debounced function to process emitted file system events
* for cases when there's a lot of fs events emitted in a very short period of time
*/
_processCachedFileSystemEvents = _.debounce(function () {
// we need to reduce _cachedFileSystemEvents not to contain duplicates!
_cachedFileSystemEvents = _cachedFileSystemEvents.reduce(function (result, obj) {
var fullPath = obj.entry ? obj.entry.fullPath : null;
// merge added & removed
if (result[fullPath] && obj.isDirectory) {
obj.added = obj.added.concat(result[fullPath].added);
obj.removed = obj.removed.concat(result[fullPath].removed);
}
// use the latest event as base
result[fullPath] = obj;
return result;
}, {});
_.forEach(_cachedFileSystemEvents, function (obj) {
_fileSystemChangeHandler(obj.event, obj.entry, obj.added, obj.removed);
});
_cachedFileSystemEvents = [];
}, FILE_SYSTEM_EVENT_DEBOUNCE_TIME);
/**
* Wrapper function for _fileSystemChangeHandler which handles all incoming fs events
* putting them to cache and executing a debounced function
*/
_debouncedFileSystemChangeHandler = function (event, entry, added, removed) {
// normalize this here so we don't need to handle null later
var isDirectory = false;
if (entry && entry.isDirectory) {
isDirectory = true;
added = added || [];
removed = removed || [];
}
_cachedFileSystemEvents.push({
event: event,
entry: entry,
isDirectory: isDirectory,
added: added,
removed: removed
});
_processCachedFileSystemEvents();
};
/**
* On project change, inform node about the new list of files that needs to be crawled.
* Instant search is also disabled for the time being till the crawl is complete in node.
*/
var _initCache = function () {
function filter(file) {
return _subtreeFilter(file, null) && _isReadableText(file.fullPath);
}
FindUtils.setInstantSearchDisabled(true);
//we always listen for filesytem changes.
_addListeners();
if (!PreferencesManager.get("findInFiles.nodeSearch")) {
return;
}
ProjectManager.getAllFiles(filter, true, true)
.done(function (fileListResult) {
var files = fileListResult,
filter = FileFilters.getActiveFilter();
if (filter && filter.patterns.length > 0) {
files = FileFilters.filterFileList(FileFilters.compile(filter.patterns), files);
}
files = files.filter(function (entry) {
return entry.isFile && _isReadableText(entry.fullPath);
}).map(function (entry) {
return entry.fullPath;
});
FindUtils.notifyIndexingStarted();
searchDomain.exec("initCache", files);
});
_searchScopeChanged();
};
/**
* Gets the next page of search results to append to the result set.
* @return {object} A promise that's resolved with the search results or rejected when the find competes.
*/
function getNextPageofSearchResults() {
var searchDeferred = $.Deferred();
if (searchModel.allResultsAvailable) {
return searchDeferred.resolve().promise();
}
_updateChangedDocs();
FindUtils.notifyNodeSearchStarted();
searchDomain.exec("nextPage")
.done(function (rcvd_object) {
FindUtils.notifyNodeSearchFinished();
if (searchModel.results) {
var resultEntry;
for (resultEntry in rcvd_object.results ) {
if (rcvd_object.results.hasOwnProperty(resultEntry)) {
searchModel.results[resultEntry.toString()] = rcvd_object.results[resultEntry];
}
}
} else {
searchModel.results = rcvd_object.results;
}
searchModel.fireChanged();
searchDeferred.resolve();
})
.fail(function () {
FindUtils.notifyNodeSearchFinished();
console.log('node fails');
FindUtils.setNodeSearchDisabled(true);
searchDeferred.reject();
});
return searchDeferred.promise();
}
function getAllSearchResults() {
var searchDeferred = $.Deferred();
if (searchModel.allResultsAvailable) {
return searchDeferred.resolve().promise();
}
_updateChangedDocs();
FindUtils.notifyNodeSearchStarted();
searchDomain.exec("getAllResults")
.done(function (rcvd_object) {
FindUtils.notifyNodeSearchFinished();
searchModel.results = rcvd_object.results;
searchModel.numMatches = rcvd_object.numMatches;
searchModel.numFiles = rcvd_object.numFiles;
searchModel.allResultsAvailable = true;
searchModel.fireChanged();
searchDeferred.resolve();
})
.fail(function () {
FindUtils.notifyNodeSearchFinished();
console.log('node fails');
FindUtils.setNodeSearchDisabled(true);
searchDeferred.reject();
});
return searchDeferred.promise();
}
ProjectManager.on("projectOpen", _initCache);
FindUtils.on(FindUtils.SEARCH_FILE_FILTERS_CHANGED, _searchScopeChanged);
FindUtils.on(FindUtils.SEARCH_SCOPE_CHANGED, _searchScopeChanged);
FindUtils.on(FindUtils.SEARCH_COLLAPSE_RESULTS, _searchcollapseResults);
searchDomain.on("crawlComplete", nodeFileCacheComplete);
// Public exports
exports.searchModel = searchModel;
exports.doSearchInScope = doSearchInScope;
exports.doReplace = doReplace;
exports.getCandidateFiles = getCandidateFiles;
exports.clearSearch = clearSearch;
exports.ZERO_FILES_TO_SEARCH = ZERO_FILES_TO_SEARCH;
exports.getNextPageofSearchResults = getNextPageofSearchResults;
exports.getAllSearchResults = getAllSearchResults;
// For unit tests only
exports._documentChangeHandler = _documentChangeHandler;
exports._fileNameChangeHandler = _fileNameChangeHandler;
exports._fileSystemChangeHandler = _fileSystemChangeHandler;
});