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.
Constant: max number of characters for the filter name
var FILTER_NAME_CHARACTER_MAX = 20;
Constant: first filter index in the filter dropdown list
var FIRST_FILTER_INDEX = 3;
Context Info on which files the filter will be applied to. It will be initialized when createFilterPicker is called and if specified, editing UI will indicate how many files are excluded by the filter. Label should be of the form "in ..."
var _context = null;
Populate the list of dropdown menu with two filter commands and the list of saved filter sets.
function _doPopulate() {
var dropdownItems = [Strings.NEW_FILE_FILTER, Strings.CLEAR_FILE_FILTER],
filterSets = PreferencesManager.get("fileFilters") || [];
if (filterSets.length) {
dropdownItems.push("---");
// Remove all the empty exclusion sets before concatenating to the dropdownItems.
filterSets = filterSets.filter(function (filter) {
return (_getCondensedForm(filter.patterns) !== "");
});
// FIRST_FILTER_INDEX needs to stay in sync with the number of static items (plus separator)
// ie. the number of items populated so far before we concatenate with the actual filter sets.
dropdownItems = dropdownItems.concat(filterSets);
}
_picker.items = dropdownItems;
}
Get the condensed form of the filter set by joining the first two in the set with a comma separator and appending a short message with the number of filters being clipped.
function _getCondensedForm(filter) {
if (!_.isArray(filter)) {
return "";
}
// Format filter in condensed form
if (filter.length > 2) {
return filter.slice(0, 2).join(", ") + " " +
StringUtils.format(Strings.FILE_FILTER_CLIPPED_SUFFIX, filter.length - 2);
}
return filter.join(", ");
}
Find the index of a filter set in the list of saved filter sets.
function _getFilterIndex(filterSets, filter) {
var index = -1;
if (!filter || !filterSets.length) {
return index;
}
return _.findIndex(filterSets, _.partial(_.isEqual, filter));
}
Remove the target item from the filter dropdown list and update dropdown button and dropdown list UI.
function _handleDeleteFilter(e) {
// Remove the filter set from the preferences and
// clear the active filter set index from view state.
var filterSets = PreferencesManager.get("fileFilters") || [],
activeFilterIndex = PreferencesManager.getViewState("activeFileFilter"),
filterIndex = $(e.target).parent().data("index") - FIRST_FILTER_INDEX;
// Don't let the click bubble upward.
e.stopPropagation();
filterSets.splice(filterIndex, 1);
PreferencesManager.set("fileFilters", filterSets);
if (activeFilterIndex === filterIndex) {
// Removing the active filter, so clear the active filter
// both in the view state.
setActiveFilter(null);
} else if (activeFilterIndex > filterIndex) {
// Adjust the active filter index after the removal of a filter set before it.
--activeFilterIndex;
setActiveFilter(filterSets[activeFilterIndex], activeFilterIndex);
}
_updatePicker();
_doPopulate();
_picker.refresh();
}
Close filter dropdwon list and launch edit filter dialog.
function _handleEditFilter(e) {
var filterSets = PreferencesManager.get("fileFilters") || [],
filterIndex = $(e.target).parent().data("index") - FIRST_FILTER_INDEX;
// Don't let the click bubble upward.
e.stopPropagation();
// Close the dropdown first before opening the edit filter dialog
// so that it will restore focus to the DOM element that has focus
// prior to opening it.
_picker.closeDropdown();
editFilter(filterSets[filterIndex], filterIndex);
}
Set up mouse click event listeners for 'Delete' and 'Edit' buttons when the dropdown is open. Also set check mark on the active filter.
function _handleListRendered(event, $dropdown) {
var activeFilterIndex = PreferencesManager.getViewState("activeFileFilter"),
checkedItemIndex = (activeFilterIndex > -1) ? (activeFilterIndex + FIRST_FILTER_INDEX) : -1;
_picker.setChecked(checkedItemIndex, true);
$dropdown.find(".filter-trash-icon")
.on("click", _handleDeleteFilter);
$dropdown.find(".filter-edit-icon")
.on("click", _handleEditFilter);
}
Update the picker button label with the name/patterns of the selected filter or No Files Excluded if no filter is selected.
function _updatePicker() {
var filter = getActiveFilter();
if (filter && filter.patterns.length) {
var label = filter.name || _getCondensedForm(filter.patterns);
_picker.setButtonLabel(StringUtils.format(Strings.EXCLUDE_FILE_FILTER, label));
} else {
_picker.setButtonLabel(Strings.NO_FILE_FILTER);
}
}
Allows unit tests to close the file filter dropdown list.
function closeDropdown() {
if (_picker) {
_picker.closeDropdown();
}
}
// For unit tests only
exports.showDropdown = showDropdown;
exports.closeDropdown = closeDropdown;
exports.createFilterPicker = createFilterPicker;
exports.commitPicker = commitPicker;
exports.getActiveFilter = getActiveFilter;
exports.setActiveFilter = setActiveFilter;
exports.editFilter = editFilter;
exports.compile = compile;
exports.filterPath = filterPath;
exports.filterFileList = filterFileList;
exports.getPathsMatchingFilter = getPathsMatchingFilter;
});
Marks the filter picker's currently selected item as most-recently used, and returns the corresponding 'compiled' filter object ready for use with filterPath().
function commitPicker(picker) {
var filter = getActiveFilter();
return (filter && filter.patterns.length) ? compile(filter.patterns) : "";
}
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().
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("|");
}
Creates a UI element for selecting a filter, populated with a list of recently used filters, an option to edit the selected filter and another option to create a new filter. 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.
function createFilterPicker(context) {
function itemRenderer(item, index) {
if (index < FIRST_FILTER_INDEX) {
// Prefix the two filter commands with 'recent-filter-name' so that
// they also get the same margin-left as the actual filters.
return "<span class='recent-filter-name'></span>" + _.escape(item);
}
var condensedPatterns = _getCondensedForm(item.patterns),
templateVars = {
"filter-name" : _.escape(item.name || condensedPatterns),
"filter-patterns": item.name ? " - " + _.escape(condensedPatterns) : ""
};
return Mustache.render(FilterNameTemplate, templateVars);
}
_context = context;
_picker = new DropdownButton("", [], itemRenderer);
_updatePicker();
_doPopulate();
// Add 'file-filter-picker' to keep some margin space on the left of the button
_picker.$button.addClass("file-filter-picker no-focus");
// Set up mouse click event listeners for 'Delete' and 'Edit' buttons
_picker.on("listRendered", _handleListRendered);
_picker.on("select", function (event, item, itemIndex) {
if (itemIndex === 0) {
// Close the dropdown first before opening the edit filter dialog
// so that it will restore focus to the DOM element that has focus
// prior to opening it.
_picker.closeDropdown();
// Create a new filter set
editFilter({ name: "", patterns: [] }, -1);
} else if (itemIndex === 1) {
// Uncheck the prior active filter in the dropdown list.
_picker.setChecked(itemIndex, false);
// Clear the active filter
setActiveFilter(null);
_updatePicker();
} else if (itemIndex >= FIRST_FILTER_INDEX && item) {
setActiveFilter(item, itemIndex - FIRST_FILTER_INDEX);
_picker.setChecked(itemIndex, true);
_updatePicker();
}
});
return _picker.$button;
}
Opens a dialog box to edit the given filter. When editing is finished, the value of getActiveFilter() changes to reflect the edits. If the dialog was canceled, the preference is left unchanged.
function editFilter(filter, index) {
var lastFocus = window.document.activeElement;
var templateVars = {
instruction: StringUtils.format(Strings.FILE_FILTER_INSTRUCTIONS, brackets.config.glob_help_url),
Strings: Strings
};
var dialog = Dialogs.showModalDialogUsingTemplate(Mustache.render(EditFilterTemplate, templateVars)),
$nameField = dialog.getElement().find(".exclusions-name"),
$editField = dialog.getElement().find(".exclusions-editor"),
$remainingField = dialog.getElement().find(".exclusions-name-characters-remaining");
$nameField.val(filter.name);
$editField.val(filter.patterns.join("\n")).focus();
function getValue() {
var newFilter = $editField.val().split("\n");
// Remove blank lines
return newFilter.filter(function (glob) {
return glob.trim().length;
});
}
$nameField.bind('input', function () {
var remainingCharacters = FILTER_NAME_CHARACTER_MAX - $(this).val().length;
if (remainingCharacters < 0.25*FILTER_NAME_CHARACTER_MAX) {
$remainingField.show();
$remainingField.text(StringUtils.format(
Strings.FILTER_NAME_REMAINING,
remainingCharacters
));
if (remainingCharacters < 0) {
$remainingField.addClass("exclusions-name-characters-limit-reached");
} else {
$remainingField.removeClass("exclusions-name-characters-limit-reached");
}
}
else {
$remainingField.hide();
}
updatePrimaryButton();
});
dialog.done(function (buttonId) {
if (buttonId === Dialogs.DIALOG_BTN_OK) {
// Update saved filter preference
setActiveFilter({ name: $nameField.val(), patterns: getValue() }, index);
_updatePicker();
_doPopulate();
}
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));
}
});
}
// Code to enable/disable the OK button at the bottom of dialog (whether filter is empty or not)
var $primaryBtn = dialog.getElement().find(".primary");
function updatePrimaryButton() {
var trimmedValue = $editField.val().trim();
var exclusionNameLength = $nameField.val().length;
$primaryBtn.prop("disabled", !trimmedValue.length || (exclusionNameLength > FILTER_NAME_CHARACTER_MAX));
}
$editField.on("input", updatePrimaryButton);
updatePrimaryButton();
if (_context) {
$editField.on("input", _.debounce(updateFileCount, 400));
updateFileCount();
} else {
$fileCount.hide();
}
return dialog.getPromise();
}
Returns a copy of 'files' filtered to just those that don't match any of the exclusion globs in the filter.
function filterFileList(compiledFilter, files) {
if (!compiledFilter) {
return files;
}
var re = new RegExp(compiledFilter);
return files.filter(function (f) {
return !re.test(f.fullPath);
});
}
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.
function filterPath(compiledFilter, fullPath) {
if (!compiledFilter) {
return true;
}
var re = new RegExp(compiledFilter);
return !fullPath.match(re);
}
A search filter is an array of one or more glob strings. The filter must be 'compiled' via compile() before passing to filterPath()/filterFileList().
function getActiveFilter() {
var filterSets = PreferencesManager.get("fileFilters") || [],
activeFilterIndex = PreferencesManager.getViewState("activeFileFilter"),
oldFilter = PreferencesManager.getViewState("search.exclusions") || [],
activeFilter = null;
if (activeFilterIndex === undefined && oldFilter.length) {
activeFilter = { name: "", patterns: oldFilter };
activeFilterIndex = _getFilterIndex(filterSets, activeFilter);
// Migrate the old filter into the new filter storage
if (activeFilterIndex === -1) {
activeFilterIndex = filterSets.length;
filterSets.push(activeFilter);
PreferencesManager.set("fileFilters", filterSets);
}
PreferencesManager.setViewState("activeFileFilter", activeFilterIndex);
} else if (activeFilterIndex > -1 && activeFilterIndex < filterSets.length) {
activeFilter = filterSets[activeFilterIndex];
}
return activeFilter;
}
Returns a copy of 'file path' strings that match any of the exclusion globs in the filter.
function getPathsMatchingFilter(compiledFilter, filePaths) {
if (!compiledFilter) {
return filePaths;
}
var re = new RegExp(compiledFilter);
return filePaths.filter(function (f) {
return f.match(re);
});
}
Sets and save the index of the active filter. Automatically set when editFilter() is completed. If no filter is passed in, then clear the last active filter index by setting it to -1.
function setActiveFilter(filter, index) {
var filterSets = PreferencesManager.get("fileFilters") || [];
if (filter) {
if (index === -1) {
// Add a new filter set
index = filterSets.length;
filterSets.push(filter);
} else if (index > -1 && index < filterSets.length) {
// Update an existing filter set only if the filter set has some changes
if (!_.isEqual(filterSets[index], filter)) {
filterSets[index] = filter;
}
} else {
// Should not have been called with an invalid index to the available filter sets.
console.log("setActiveFilter is called with an invalid index: " + index);
return;
}
PreferencesManager.set("fileFilters", filterSets);
PreferencesManager.setViewState("activeFileFilter", index);
} else {
// Explicitly set to -1 to remove the active file filter
PreferencesManager.setViewState("activeFileFilter", -1);
}
FindUtils.notifyFileFiltersChanged();
}