diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 8e76da3f0ac..154d00a854b 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -160,6 +160,7 @@ define({ "FIND_IN_FILES_TITLE_PART3" : "— {0} {1} {2} in {3} {4}", "FIND_IN_FILES_SCOPED" : "in {0}", "FIND_IN_FILES_NO_SCOPE" : "in project", + "FIND_IN_FILES_ZERO_FILES" : "Filter excludes all files {0}", "FIND_IN_FILES_FILE" : "file", "FIND_IN_FILES_FILES" : "files", "FIND_IN_FILES_MATCH" : "match", @@ -175,9 +176,12 @@ define({ "NO_FILE_FILTER" : "Exclude files\u2026", "EDIT_FILE_FILTER" : "Edit\u2026", "FILE_FILTER_DIALOG" : "Edit Filter", - "FILE_FILTER_INSTRUCTIONS" : "Exclude files and folders matching any of the following strings / substrings or globs. Enter each string on a new line.", + "FILE_FILTER_INSTRUCTIONS" : "Exclude files and folders matching any of the following strings / substrings or wildcards. Enter each string on a new line.", "FILE_FILTER_LIST_PREFIX" : "except", "FILE_FILTER_CLIPPED_SUFFIX" : "and {0} more", + "FILTER_COUNTING_FILES" : "Counting files\u2026", + "FILTER_FILE_COUNT" : "Allows {0} of {1} files {2}", + "FILTER_FILE_COUNT_ALL" : "Allows all {0} files {1}", // Quick Edit "ERROR_QUICK_EDIT_PROVIDER_NOT_FOUND" : "No Quick Edit provider found for current cursor position", diff --git a/src/search/FileFilters.js b/src/search/FileFilters.js index 0d3ddc46033..b22de7441e9 100644 --- a/src/search/FileFilters.js +++ b/src/search/FileFilters.js @@ -32,6 +32,7 @@ define(function (require, exports, module) { "use strict"; var _ = require("thirdparty/lodash"), + ProjectManager = require("project/ProjectManager"), DefaultDialogs = require("widgets/DefaultDialogs"), Dialogs = require("widgets/Dialogs"), DropdownButton = require("widgets/DropdownButton").DropdownButton, @@ -59,44 +60,6 @@ define(function (require, exports, module) { } - /** - * Opens a dialog box to edit the given filter. When editing is finished, the value of getLastFilter() changes to - * reflect the edits. If the dialog was canceled, the preference is left unchanged. - * @param {!Array.} filter - * @return {!$.Promise} Dialog box promise - */ - function editFilter(filter) { - var lastFocus = window.document.activeElement; - - var html = StringUtils.format(Strings.FILE_FILTER_INSTRUCTIONS, brackets.config.glob_help_url) + - ""; - var buttons = [ - { className : Dialogs.DIALOG_BTN_CLASS_NORMAL, id: Dialogs.DIALOG_BTN_CANCEL, text: Strings.CANCEL }, - { className : Dialogs.DIALOG_BTN_CLASS_PRIMARY, id: Dialogs.DIALOG_BTN_OK, text: Strings.OK } - ]; - var dialog = Dialogs.showModalDialog(DefaultDialogs.DIALOG_ID_INFO, Strings.FILE_FILTER_DIALOG, html, buttons); - - dialog.getElement().find(".exclusions-editor").val(filter.join("\n")).focus(); - - dialog.done(function (buttonId) { - if (buttonId === Dialogs.DIALOG_BTN_OK) { - var newFilter = dialog.getElement().find(".exclusions-editor").val().split("\n"); - - // Remove blank lines - newFilter = newFilter.filter(function (glob) { - return glob.trim().length; - }); - - // Update saved filter preference - setLastFilter(newFilter); - } - lastFocus.focus(); // restore focus to old pos - }); - - return dialog.getPromise(); - } - - /** * Converts a user-specified filter object (as chosen in picker or retrieved from getFilters()) to a 'compiled' form * that can be used with filterPath()/filterFileList(). @@ -153,6 +116,109 @@ define(function (require, exports, module) { } + /** + * Returns false if the given path matches any of the exclusion globs in the given filter. Returns true + * if the path does not match any of the globs. If filtering many paths at once, use filterFileList() + * for much better performance. + * + * @param {?string} compiledFilter 'Compiled' filter object as returned by compile(), or null to no-op + * @param {!string} fullPath + * @return {boolean} + */ + function filterPath(compiledFilter, fullPath) { + if (!compiledFilter) { + return true; + } + + var re = new RegExp(compiledFilter); + return !fullPath.match(re); + } + + /** + * Returns a copy of 'files' filtered to just those that don't match any of the exclusion globs in the filter. + * + * @param {?string} compiledFilter 'Compiled' filter object as returned by compile(), or null to no-op + * @param {!Array.} files + * @return {!Array.} + */ + function filterFileList(compiledFilter, files) { + if (!compiledFilter) { + return files; + } + + var re = new RegExp(compiledFilter); + return files.filter(function (f) { + return !f.fullPath.match(re); + }); + } + + + /** + * Opens a dialog box to edit the given filter. When editing is finished, the value of getLastFilter() changes to + * reflect the edits. If the dialog was canceled, the preference is left unchanged. + * @param {!Array.} filter + * @param {?{label:string, promise:$.Promise}} context Info on which files the filter will be applied to. If specified, + * editing UI will indicate how many files are excluded by the filter. Label should be of the form "in ..." + * @return {!$.Promise} Dialog box promise + */ + function editFilter(filter, context) { + var lastFocus = window.document.activeElement; + + var html = StringUtils.format(Strings.FILE_FILTER_INSTRUCTIONS, brackets.config.glob_help_url) + + "
" + Strings.FILTER_COUNTING_FILES + "
"; + var buttons = [ + { className : Dialogs.DIALOG_BTN_CLASS_NORMAL, id: Dialogs.DIALOG_BTN_CANCEL, text: Strings.CANCEL }, + { className : Dialogs.DIALOG_BTN_CLASS_PRIMARY, id: Dialogs.DIALOG_BTN_OK, text: Strings.OK } + ]; + var dialog = Dialogs.showModalDialog(DefaultDialogs.DIALOG_ID_INFO, Strings.FILE_FILTER_DIALOG, html, buttons); + + var $editField = dialog.getElement().find(".exclusions-editor"); + $editField.val(filter.join("\n")).focus(); + + function getValue() { + var newFilter = $editField.val().split("\n"); + + // Remove blank lines + return newFilter.filter(function (glob) { + return glob.trim().length; + }); + } + + dialog.done(function (buttonId) { + if (buttonId === Dialogs.DIALOG_BTN_OK) { + // Update saved filter preference + setLastFilter(getValue()); + } + lastFocus.focus(); // restore focus to old pos + }); + + + // Code to update the file count readout at bottom of dialog (if context provided) + var $fileCount = dialog.getElement().find(".exclusions-filecount"); + + function updateFileCount() { + context.promise.done(function (files) { + var filter = getValue(); + if (filter.length) { + var filtered = filterFileList(compile(filter), files); + $fileCount.html(StringUtils.format(Strings.FILTER_FILE_COUNT, filtered.length, files.length, context.label)); + } else { + $fileCount.html(StringUtils.format(Strings.FILTER_FILE_COUNT_ALL, files.length, context.label)); + } + }); + } + + if (context) { + $editField.on("input", _.debounce(updateFileCount, 400)); + updateFileCount(); + } else { + $fileCount.hide(); + } + + return dialog.getPromise(); + } + + /** * Marks the filter picker's currently selected item as most-recently used, and returns the corresponding * 'compiled' filter object ready for use with filterPath(). @@ -170,9 +236,10 @@ define(function (require, exports, module) { * client should call commitDropdown() when the UI containing the filter picker is confirmed (which updates the MRU * order) and then use the returned filter object as needed. * + * @param {?{label:string, promise:$.Promise}} context Info on files filter will apply to - see editFilter() * @return {!jQueryObject} Picker UI. To retrieve the selected value, use commitPicker(). */ - function createFilterPicker() { + function createFilterPicker(context) { var $picker = $("
"), $button = $picker.find("button"); @@ -208,7 +275,7 @@ define(function (require, exports, module) { updatePicker(); $button.click(function () { - editFilter(getLastFilter()) + editFilter(getLastFilter(), context) .done(function (buttonId) { if (buttonId === Dialogs.DIALOG_BTN_OK) { updatePicker(); @@ -220,43 +287,6 @@ define(function (require, exports, module) { } - /** - * Returns false if the given path matches any of the exclusion globs in the given filter. Returns true - * if the path does not match any of the globs. If filtering many paths at once, use filterFileList() - * for much better performance. - * - * @param {!string} compiledFilter 'Compiled' filter object as returned by compile() - * @param {!string} fullPath - * @return {boolean} - */ - function filterPath(compiledFilter, fullPath) { - if (!compiledFilter) { - return true; - } - - var re = new RegExp(compiledFilter); - return !fullPath.match(re); - } - - /** - * Returns a copy of 'files' filtered to just those that don't match any of the exclusion globs in the filter. - * - * @param {!string} compiledFilter 'Compiled' filter object as returned by compile() - * @param {!Array.} files - * @return {!Array.} - */ - function filterFileList(compiledFilter, files) { - if (!compiledFilter) { - return files; - } - - var re = new RegExp(compiledFilter); - return files.filter(function (f) { - return !f.fullPath.match(re); - }); - } - - exports.createFilterPicker = createFilterPicker; exports.commitPicker = commitPicker; exports.getLastFilter = getLastFilter; diff --git a/src/search/FindInFiles.js b/src/search/FindInFiles.js index 376917419d3..a203ea792df 100644 --- a/src/search/FindInFiles.js +++ b/src/search/FindInFiles.js @@ -79,6 +79,9 @@ define(function (require, exports, module) { FIND_IN_FILE_MAX = 300, UPDATE_TIMEOUT = 400; + /** @const @type {!Object} Token used to indicate a specific reason for zero search results */ + var ZERO_FILES_TO_SEARCH = {}; + /** * Map of all the last search results * @type {Object., collapsed: boolean}>} @@ -346,8 +349,9 @@ define(function (require, exports, module) { /** * @private * Shows the results in a table and adds the necessary event listeners + * @param {?Object} zeroFilesToken The 'ZERO_FILES_TO_SEARCH' token, if no results found for this reason */ - function _showSearchResults() { + function _showSearchResults(zeroFilesToken) { if (!$.isEmptyObject(searchResults)) { var count = _countFilesMatches(); @@ -580,7 +584,13 @@ define(function (require, exports, module) { .addClass("no-results") .removeAttr("disabled") .get(0).select(); - $(".modal-bar .no-results-message").show(); + if (zeroFilesToken === ZERO_FILES_TO_SEARCH) { + $(".modal-bar .error") + .show() + .html(StringUtils.format(Strings.FIND_IN_FILES_ZERO_FILES, _labelForScope(currentScope))); + } else { + $(".modal-bar .no-results-message").show(); + } } } } @@ -731,22 +741,35 @@ define(function (require, exports, module) { return !language.isBinary(); } + /** + * Finds all candidate files to search in currentScope's subtree that are not binary content. Does NOT apply + * currentFilter yet. + */ + function getCandidateFiles() { + function filter(file) { + return _subtreeFilter(file, currentScope) && _isReadableText(file); + } + + return ProjectManager.getAllFiles(filter, 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, the filter and part of the scope are taken care of in bulk instead, so _subtreeFilter() - * is just called directly. + * 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 (currentScope) { if (!_subtreeFilter(file, currentScope)) { return false; } } else { // Still need to make sure it's within project or working set - // In the initial search, this is covered by getAllFiles() + // In getCandidateFiles(), this is covered by the baseline getAllFiles() itself if (file.fullPath.indexOf(ProjectManager.getProjectRoot().fullPath) !== 0) { var inWorkingSet = DocumentManager.getWorkingSet().some(function (wsFile) { return wsFile.fullPath === file.fullPath; @@ -756,11 +779,11 @@ define(function (require, exports, module) { } } } - // In the initial search, this is passed as a getAllFiles() filter if (!_isReadableText(file)) { return false; } - // In the initial search, this is covered by the filterFileList() pass + + // Replicate the filtering filterFileList() does return FileFilters.filterPath(currentFilter, file.fullPath); } @@ -791,31 +814,38 @@ define(function (require, exports, module) { } }; - function _doSearchInOneFile(addMatches, file) { + /** + * 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. + */ + function _doSearchInOneFile(file) { var result = new $.Deferred(); - - if (!_subtreeFilter(file, currentScope)) { - result.resolve(); - } else { - DocumentManager.getDocumentText(file) - .done(function (text) { - addMatches(file.fullPath, text, currentQueryExpr); - }) - .always(function () { - // Always resolve. If there is an error, this file - // is skipped and we move on to the next file. - result.resolve(); - }); - } + + DocumentManager.getDocumentText(file) + .done(function (text) { + var foundMatches = _addSearchMatches(file.fullPath, text, currentQueryExpr); + result.resolve(foundMatches); + }) + .fail(function () { + // Always resolve. If there is an error, this file + // is skipped and we move on to the next file. + result.resolve(); + }); + return result.promise(); } - + /** * @private * Executes the Find in Files search inside the 'currentScope' * @param {string} query String to be searched + * @param {!$.Promise} candidateFilesPromise Promise from getCandidateFiles(), which was called earlier */ - function _doSearch(query) { + function _doSearch(query, candidateFilesPromise) { currentQuery = query; currentQueryExpr = _getQueryRegExp(query); @@ -828,18 +858,21 @@ define(function (require, exports, module) { var scopeName = currentScope ? currentScope.fullPath : ProjectManager.getProjectRoot().fullPath, perfTimer = PerfUtils.markStart("FindIn: " + scopeName + " - " + query); - ProjectManager.getAllFiles(_isReadableText, true) + candidateFilesPromise .then(function (fileListResult) { // Filter out files/folders that match user's current exclusion filter fileListResult = FileFilters.filterFileList(currentFilter, fileListResult); - var doSearch = _doSearchInOneFile.bind(undefined, _addSearchMatches); - return Async.doInParallel(fileListResult, doSearch); + if (fileListResult.length) { + return Async.doInParallel(fileListResult, _doSearchInOneFile); + } else { + return ZERO_FILES_TO_SEARCH; + } }) - .done(function () { + .done(function (zeroFilesToken) { // Done searching all files: show results _sortResultFiles(); - _showSearchResults(); + _showSearchResults(zeroFilesToken); StatusBar.hideBusyIndicator(); PerfUtils.addMeasurement(perfTimer); @@ -929,6 +962,7 @@ define(function (require, exports, module) { }; var $searchField = $("input#find-what"), + candidateFilesPromise = getCandidateFiles(), // used for eventual search, and in exclusions editor UI filterPicker; function handleQueryChange() { @@ -956,8 +990,14 @@ define(function (require, exports, module) { } else if (event.keyCode === KeyEvent.DOM_VK_RETURN) { StatusBar.showBusyIndicator(true); that.getDialogTextField().attr("disabled", "disabled"); - currentFilter = FileFilters.commitPicker(filterPicker); - _doSearch(query); + + if (filterPicker) { + currentFilter = FileFilters.commitPicker(filterPicker); + } else { + // Single-file scope: don't use any file filters + currentFilter = null; + } + _doSearch(query, candidateFilesPromise); } } }) @@ -971,8 +1011,16 @@ define(function (require, exports, module) { handleQueryChange(); // re-validate regexp if needed }); - filterPicker = FileFilters.createFilterPicker(); - this.modalBar.getRoot().find("#find-group").append(filterPicker); + // Show file-exclusion UI *unless* search scope is just a single file + if (!scope || scope.isDirectory) { + var exclusionsContext = { + label: _labelForScope(scope), + promise: candidateFilesPromise + }; + + filterPicker = FileFilters.createFilterPicker(exclusionsContext); + this.modalBar.getRoot().find("#find-group").append(filterPicker); + } // Initial UI state (including prepopulated initialString passed into template) FindReplace._updateSearchBarFromPrefs(); @@ -1099,12 +1147,6 @@ define(function (require, exports, module) { var addedFiles = [], deferred = new $.Deferred(); - var doSearch = _doSearchInOneFile.bind(undefined, function () { - if (_addSearchMatches.apply(undefined, arguments)) { - resultsChanged = true; - } - }); - // gather up added files var visitor = function (child) { // Replicate filtering that getAllFiles() does @@ -1127,7 +1169,12 @@ define(function (require, exports, module) { } // find additional matches in all added files - Async.doInParallel(addedFiles, doSearch).always(deferred.resolve); + Async.doInParallel(addedFiles, function (file) { + return _doSearchInOneFile(file) + .done(function (foundMatches) { + resultsChanged = resultsChanged || foundMatches; + }); + }).always(deferred.resolve); }); return deferred.promise(); diff --git a/src/styles/brackets.less b/src/styles/brackets.less index b6beb0d4a3a..bfaf0ef64ea 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -1210,12 +1210,19 @@ a, img { // File exclusion filter editor dialog textarea.exclusions-editor { display: block; - width: 547px; + width: 100%; height: 160px; + box-sizing: border-box; // needed for width: 100% since it has padding margin-top: 12px; margin-bottom: 0; .code-font(); } +.exclusions-filecount { + margin: 12px 0 -20px 0; + padding: 4px 6px; + background-color: @tc-highlight; + border-radius: @tc-control-border-radius; +} // Search result highlighting - CodeMirror highlighting is pretty constrained. Highlights are