diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js
index 657511bb586..f3a4ee43d0b 100644
--- a/src/nls/root/strings.js
+++ b/src/nls/root/strings.js
@@ -170,6 +170,15 @@ define({
"FIND_IN_FILES_EXPAND_COLLAPSE" : "Ctrl/Cmd click to expand/collapse all",
"ERROR_FETCHING_UPDATE_INFO_TITLE" : "Error getting update info",
"ERROR_FETCHING_UPDATE_INFO_MSG" : "There was a problem getting the latest update information from the server. Please make sure you are connected to the internet and try again.",
+
+ // File exclusion filters
+ "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_LIST_PREFIX" : "except",
+ "FILE_FILTER_CLIPPED_SUFFIX" : "and {0} more",
+
/**
* ProjectManager
diff --git a/src/project/ProjectManager.js b/src/project/ProjectManager.js
index 876c1b31006..531fb39e667 100644
--- a/src/project/ProjectManager.js
+++ b/src/project/ProjectManager.js
@@ -1083,6 +1083,7 @@ define(function (require, exports, module) {
_projectRoot = rootEntry;
_projectBaseUrl = PreferencesManager.getViewState("project.baseUrl", context) || "";
+ _allFilesCachePromise = null; // invalidate getAllFiles() cache as soon as _projectRoot changes
// If this is the most current welcome project, record it. In future launches, we want
// to substitute the latest welcome project from the current build instead of using an
diff --git a/src/search/FileFilters.js b/src/search/FileFilters.js
new file mode 100644
index 00000000000..4081fb7822d
--- /dev/null
+++ b/src/search/FileFilters.js
@@ -0,0 +1,267 @@
+/*
+ * Copyright (c) 2014 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.
+ *
+ */
+
+/*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true, indent: 4, maxerr: 50 */
+/*global define, $, brackets, window */
+
+/**
+ * Utilities for managing file-set filters, as used in Find in Files.
+ * Includes both UI for selecting/editing filters, as well as the actual file-filtering implementation.
+ */
+define(function (require, exports, module) {
+ "use strict";
+
+ var _ = require("thirdparty/lodash"),
+ DefaultDialogs = require("widgets/DefaultDialogs"),
+ Dialogs = require("widgets/Dialogs"),
+ DropdownButton = require("widgets/DropdownButton").DropdownButton,
+ StringUtils = require("utils/StringUtils"),
+ Strings = require("strings"),
+ PreferencesManager = require("preferences/PreferencesManager");
+
+
+ /**
+ * A search filter is an array of one or more glob strings. The filter must be 'compiled' via compile()
+ * before passing to filterPath()/filterFileList().
+ * @return {!Array.}
+ */
+ function getLastFilter() {
+ return PreferencesManager.getViewState("search.exclusions") || [];
+ }
+
+ /**
+ * Sets the value of getLastFilter(). Automatically set when editFilter() is completed.
+ * @param {!Array.} filter
+ * @return {!Array.}
+ */
+ function setLastFilter(filter) {
+ PreferencesManager.setViewState("search.exclusions", filter);
+ }
+
+
+ /**
+ * 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_PRIMARY, id: Dialogs.DIALOG_BTN_OK, text: Strings.OK },
+ { className : Dialogs.DIALOG_BTN_CLASS_NORMAL, id: Dialogs.DIALOG_BTN_CANCEL, text: Strings.CANCEL }
+ ];
+ 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().
+ * @param {!Array.} userFilter
+ * @return {!string} 'compiled' filter that can be passed to filterPath()/filterFileList().
+ */
+ function compile(userFilter) {
+ // Automatically apply ** prefix/suffix to make writing simple substring-match filters more intuitive
+ var wrappedGlobs = userFilter.map(function (glob) {
+ // Automatic "**" prefix if not explicitly present
+ if (glob.substr(0, 2) !== "**") {
+ glob = "**" + glob;
+ }
+ // Automatic "**" suffix if not explicitly present and no "." in last path segment of filter string
+ if (glob.substr(-2, 2) !== "**") {
+ var lastSeg = glob.lastIndexOf("/");
+ if (glob.indexOf(".", lastSeg + 1) === -1) { // if no "/" present, this treats whole string as 'last segment'
+ glob += "**";
+ }
+ }
+ return glob;
+ });
+
+ // Convert to regular expression for fast matching
+ var regexStrings = wrappedGlobs.map(function (glob) {
+ var reStr = "", i;
+ for (i = 0; i < glob.length; i++) {
+ var ch = glob[i];
+ if (ch === "*") {
+ // Check for `**`
+ if (glob[i + 1] === "*") {
+ // Special case: `/**/` can collapse - that is, it shouldn't require matching both slashes
+ if (glob[i + 2] === "/" && glob[i - 1] === "/") {
+ reStr += "(.*/)?";
+ i += 2; // skip 2nd * and / after it
+ } else {
+ reStr += ".*";
+ i++; // skip 2nd *
+ }
+ } else {
+ // Single `*`
+ reStr += "[^/]*";
+ }
+ } else if (ch === "?") {
+ reStr += "[^/]"; // unlike '?' in regexp, in globs this requires exactly 1 char
+ } else {
+ // Regular char with no special meaning
+ reStr += StringUtils.regexEscape(ch);
+ }
+ }
+ return "^" + reStr + "$";
+ });
+ return regexStrings.join("|");
+ }
+
+
+ /**
+ * Marks the filter picker's currently selected item as most-recently used, and returns the corresponding
+ * 'compiled' filter object ready for use with filterPath().
+ * @param {!jQueryObject} picker UI returned from createFilterPicker()
+ * @return {!string} 'compiled' filter that can be passed to filterPath()/filterFileList().
+ */
+ function commitPicker(picker) {
+ var filter = getLastFilter();
+ return compile(filter);
+ }
+
+ /**
+ * Creates a UI element for selecting a filter, populated with a list of recently used filters and an option to
+ * edit the selected filter. The edit option is fully functional, but selecting any other item does nothing. The
+ * 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.
+ *
+ * @return {!jQueryObject} Picker UI. To retrieve the selected value, use commitPicker().
+ */
+ function createFilterPicker() {
+ var $picker = $("
"),
+ $button = $picker.find("button");
+
+ function joinBolded(segments) {
+ return segments
+ .map(function (seg) {
+ return "" + _.escape(seg) + "";
+ })
+ .join(", ");
+ }
+ function itemRenderer(filter) {
+ // Format filter in condensed form
+ if (filter.length > 2) {
+ return Strings.FILE_FILTER_LIST_PREFIX + " " + joinBolded(filter.slice(0, 2)) + " " +
+ StringUtils.format(Strings.FILE_FILTER_CLIPPED_SUFFIX, filter.length - 2);
+ } else {
+ return Strings.FILE_FILTER_LIST_PREFIX + " " + joinBolded(filter);
+ }
+ }
+
+ function updatePicker() {
+ var filter = getLastFilter();
+ if (filter.length) {
+ $button.text(Strings.EDIT_FILE_FILTER);
+ $picker.find(".filter-label").html(itemRenderer(filter))
+ .attr("title", filter.join("\n"));
+ } else {
+ $button.text(Strings.NO_FILE_FILTER);
+ $picker.find(".filter-label").html("").attr("title", "");
+ }
+ }
+
+ updatePicker();
+
+ $button.click(function () {
+ editFilter(getLastFilter())
+ .done(function (buttonId) {
+ if (buttonId === Dialogs.DIALOG_BTN_OK) {
+ updatePicker();
+ }
+ });
+ });
+
+ return $picker;
+ }
+
+
+ /**
+ * 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;
+ exports.editFilter = editFilter;
+ exports.compile = compile;
+ exports.filterPath = filterPath;
+ exports.filterFileList = filterFileList;
+});
diff --git a/src/search/FindInFiles.js b/src/search/FindInFiles.js
index 6673efc93fc..c80d47a0a5b 100644
--- a/src/search/FindInFiles.js
+++ b/src/search/FindInFiles.js
@@ -42,14 +42,15 @@
define(function (require, exports, module) {
"use strict";
- var _ = require("thirdparty/lodash");
-
- var Async = require("utils/Async"),
+ var _ = require("thirdparty/lodash"),
+ FileFilters = require("search/FileFilters"),
+ Async = require("utils/Async"),
Resizer = require("utils/Resizer"),
CommandManager = require("command/CommandManager"),
Commands = require("command/Commands"),
Strings = require("strings"),
StringUtils = require("utils/StringUtils"),
+ PreferencesManager = require("preferences/PreferencesManager"),
ProjectManager = require("project/ProjectManager"),
DocumentModule = require("document/Document"),
DocumentManager = require("document/DocumentManager"),
@@ -743,24 +744,12 @@ define(function (require, exports, module) {
return result.promise();
}
- /**
- * @private
- * Used to filter out image files when building a list of file in which to
- * search. Ideally this would filter out ALL binary files.
- * @param {FileSystemEntry} entry The entry to test
- * @return {boolean} Whether or not the entry's contents should be searched
- */
- function _findInFilesFilter(entry) {
- var language = LanguageManager.getLanguageForPath(entry.fullPath);
- return !language.isBinary();
- }
-
/**
* @private
* Executes the Find in Files search inside the 'currentScope'
* @param {string} query String to be searched
*/
- function _doSearch(query) {
+ function _doSearch(query, userFilter) {
currentQuery = query;
currentQueryExpr = _getQueryRegExp(query);
@@ -773,8 +762,21 @@ define(function (require, exports, module) {
var scopeName = currentScope ? currentScope.fullPath : ProjectManager.getProjectRoot().fullPath,
perfTimer = PerfUtils.markStart("FindIn: " + scopeName + " - " + query);
- ProjectManager.getAllFiles(_findInFilesFilter, true)
+ /**
+ * Filters out files that are known binary types (currently just image/audio; ideally we'd filter out ALL binary files).
+ * @param {FileSystemEntry} entry The entry to test
+ * @return {boolean} True if the entry's contents should be included in the file list
+ */
+ function fileFilter(entry) {
+ var language = LanguageManager.getLanguageForPath(entry.fullPath);
+ return !language.isBinary();
+ }
+
+ ProjectManager.getAllFiles(fileFilter, true)
.then(function (fileListResult) {
+ // Filter out files/folders that match user's current exclusion filter
+ fileListResult = FileFilters.filterFileList(userFilter, fileListResult);
+
var doSearch = _doSearchInOneFile.bind(undefined, _addSearchMatches);
return Async.doInParallel(fileListResult, doSearch);
})
@@ -785,6 +787,8 @@ define(function (require, exports, module) {
StatusBar.hideBusyIndicator();
PerfUtils.addMeasurement(perfTimer);
$(DocumentModule).on("documentChange.findInFiles", _documentChangeHandler);
+
+ exports._searchResults = searchResults; // for unit tests
})
.fail(function (err) {
console.log("find in files failed: ", err);
@@ -823,12 +827,14 @@ define(function (require, exports, module) {
if (this.closed) {
return;
}
-
+ this.modalBar.close(true, !suppressAnimation);
+ };
+
+ FindInFilesDialog.prototype._handleClose = function () {
// Hide error popup, since it hangs down low enough to make the slide-out look awkward
$(".modal-bar .error").hide();
this.closed = true;
- this.modalBar.close(true, !suppressAnimation);
EditorManager.focusEditor();
dialog = null;
};
@@ -852,9 +858,18 @@ define(function (require, exports, module) {
// (Any previous open FindInFiles bar instance was already handled by our caller)
FindReplace._closeFindBar();
- this.modalBar = new ModalBar(dialogHTML, false);
+ this.modalBar = new ModalBar(dialogHTML, true);
+ $(this.modalBar).on("close", this._handleClose.bind(this));
+
+ // Custom closing behavior: if in the middle of executing search, blur shouldn't close ModalBar yet. And
+ // don't close bar when opening Edit Filter dialog either.
+ var self = this;
+ this.modalBar.isLockedOpen = function () {
+ return self.getDialogTextField().attr("disabled") || $(".modal.instance .exclusions-editor").length > 0;
+ };
- var $searchField = $("input#find-what");
+ var $searchField = $("input#find-what"),
+ filterPicker;
function handleQueryChange() {
// Check the query expression on every input event. This way the user is alerted
@@ -881,17 +896,12 @@ define(function (require, exports, module) {
} else if (event.keyCode === KeyEvent.DOM_VK_RETURN) {
StatusBar.showBusyIndicator(true);
that.getDialogTextField().attr("disabled", "disabled");
- _doSearch(query);
+ var userFilter = FileFilters.commitPicker(filterPicker);
+ _doSearch(query, userFilter);
}
}
})
.bind("input", handleQueryChange)
- .blur(function () {
- if (that.getDialogTextField().attr("disabled")) {
- return;
- }
- that._close();
- })
.focus();
this.modalBar.getRoot().on("click", "#find-case-sensitive, #find-regexp", function (e) {
@@ -901,6 +911,9 @@ define(function (require, exports, module) {
handleQueryChange(); // re-validate regexp if needed
});
+ filterPicker = FileFilters.createFilterPicker();
+ this.modalBar.getRoot().find("#find-group").append(filterPicker);
+
// Initial UI state (including prepopulated initialString passed into template)
FindReplace._updateSearchBarFromPrefs();
handleQueryChange();
@@ -950,6 +963,7 @@ define(function (require, exports, module) {
currentQueryExpr = null;
currentScope = scope;
maxHitsFoundInFile = false;
+ exports._searchResults = null; // for unit tests
dialog.showDialog(initialString, scope);
}
@@ -1114,4 +1128,7 @@ define(function (require, exports, module) {
// Initialize: command handlers
CommandManager.register(Strings.CMD_FIND_IN_FILES, Commands.EDIT_FIND_IN_FILES, _doFindInFiles);
CommandManager.register(Strings.CMD_FIND_IN_SUBTREE, Commands.EDIT_FIND_IN_SUBTREE, _doFindInSubtree);
+
+ // For unit testing - updated in _doSearch() when search complete
+ exports._searchResults = null;
});
diff --git a/src/styles/brackets.less b/src/styles/brackets.less
index 2f6b1caf1c3..4570dca5958 100644
--- a/src/styles/brackets.less
+++ b/src/styles/brackets.less
@@ -1164,6 +1164,26 @@ a, img {
}
}
+// File exclusion filter (used only in Find in Files search bar, for now)
+.filter-picker {
+ display: inline-block;
+ margin-left: 8px;
+ button {
+ margin-left: 8px;
+ }
+}
+
+// File exclusion filter editor dialog
+textarea.exclusions-editor {
+ display: block;
+ width: 547px;
+ height: 160px;
+ margin-top: 12px;
+ margin-bottom: 0;
+ .code-font();
+}
+
+
// Search result highlighting - CodeMirror highlighting is pretty constrained. Highlights are
// blended on TOP of the selection color. The "current" search result is indicated by selection,
// so we want the selection visible underneath the highlight. To do this, the highlight must be
diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less
index b1e71dec43b..0f1006ea322 100644
--- a/src/styles/brackets_patterns_override.less
+++ b/src/styles/brackets_patterns_override.less
@@ -403,34 +403,31 @@ a:focus {
margin: 0;
}
-/* Stylesheet button & dropdown styles */
+/* DropdownButton widget */
-.stylesheet-button.btn-mini {
- margin-left: 8px;
- position: relative;
- top: -1px;
-}
-
-.stylesheet-button.btn-mini.btn-dropdown {
- padding-right: 24px;
+.btn-dropdown, .btn-dropdown.btn-mini {
+ position: relative; // needed to position ::after arrow
+ padding-right: 24px; // makes room for ::after arrow
}
-
-.stylesheet-button.btn-dropdown::after {
+.btn-dropdown::after {
content: "";
display: block;
height: 0px;
width: 0px;
position: absolute;
right: 6px;
- top: 7px;
+ top: 11px;
/* dropdown triangle */
border-top: 4px solid @tc-gray-component-triangle;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
}
+.btn-dropdown.btn-mini::after {
+ top: 7px;
+}
-.stylesheet-dropdown {
+.dropdownbutton-popup {
&.dropdown-menu:focus {
outline: none;
}
@@ -449,10 +446,6 @@ a:focus {
z-index: @z-index-brackets-stylesheet-menu;
}
- .stylesheet-link, .stylesheet-name {
- white-space: nowrap;
- }
-
&.dropdown-menu li a {
padding: 1px 15px 1px 15px;
}
@@ -461,14 +454,6 @@ a:focus {
display: block;
}
- .stylesheet-name {
- color: @tc-text;
- }
-
- .stylesheet-dir {
- color: @tc-quiet-text;
- }
-
&.dropdown-menu a.selected {
background: @tc-highlight;
color: @bc-black !important;
@@ -479,6 +464,26 @@ a:focus {
}
}
+/* Inline editor stylesheet-picker DropdownButton */
+
+.stylesheet-button.btn-mini {
+ margin-left: 8px;
+ top: -1px;
+}
+
+.dropdownbutton-popup {
+ .stylesheet-link, .stylesheet-name {
+ white-space: nowrap;
+ }
+
+ .stylesheet-name {
+ color: @tc-text;
+ }
+
+ .stylesheet-dir {
+ color: @tc-quiet-text;
+ }
+}
/* Dialog-related styles */
@@ -1110,6 +1115,7 @@ input[type="color"],
}
}
+
/* Tables */
.table, table {
diff --git a/src/widgets/DropdownButton.js b/src/widgets/DropdownButton.js
new file mode 100644
index 00000000000..257736ff940
--- /dev/null
+++ b/src/widgets/DropdownButton.js
@@ -0,0 +1,235 @@
+/*
+ * Copyright (c) 2014 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.
+ */
+
+
+/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */
+/*global define, $, window */
+
+/**
+ * Button that opens a dropdown list when clicked. More akin to a popup menu than a combobox. Compared to a
+ * simple