adobe/brackets

View on GitHub
src/search/FileFilters.js

Summary

Maintainability
C
1 day
Test Coverage
/*
 * Copyright (c) 2014 - 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.
 *
 */

/**
 * 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"),
        Mustache           = require("thirdparty/mustache/mustache"),
        Dialogs            = require("widgets/Dialogs"),
        DropdownButton     = require("widgets/DropdownButton").DropdownButton,
        StringUtils        = require("utils/StringUtils"),
        Strings            = require("strings"),
        PreferencesManager = require("preferences/PreferencesManager"),
        FindUtils          = require("search/FindUtils"),
        EditFilterTemplate = require("text!htmlContent/edit-filter-dialog.html"),
        FilterNameTemplate = require("text!htmlContent/filter-name.html");

    /**
     * Constant: first filter index in the filter dropdown list
     * @type {number}
     */
    var FIRST_FILTER_INDEX = 3;

    /**
     * Constant: max number of characters for the filter name
     * @type {number}
     */
    var FILTER_NAME_CHARACTER_MAX = 20;

    /**
     * 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 ..."
     * @type {?{label:string, promise:$.Promise}}
     */
    var _context = null;

    /**
     * @type {DropdownButton}
     */
    var _picker  = null;

    /**
     * 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.
     * @param {Array.<string>} filter
     * @return {string} Condensed form of filter set if `filter` is a valid array.
     *                  Otherwise, return an empty string.
     */
    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(", ");
    }

    /**
     * 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;
    }

    /**
     * Find the index of a filter set in the list of saved filter sets.
     * @param {Array.<{name: string, patterns: Array.<string>}>} filterSets
     * @return {{name: string, patterns: Array.<string>}} filter
     */
    function _getFilterIndex(filterSets, filter) {
        var index = -1;

        if (!filter || !filterSets.length) {
            return index;
        }

        return _.findIndex(filterSets, _.partial(_.isEqual, filter));
    }

    /**
     * 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 {?{name: string, patterns: Array.<string>}}
     */
    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;
    }

    /**
     * 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);
        }
    }

    /**
     * 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.
     *
     * @param {{name: string, patterns: Array.<string>}=} filter
     * @param {number=} index The index of the filter set in the list of saved filter sets or -1 if it is a new one
     */
    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();
    }


    /**
     * 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.<string>} 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("|");
    }


    /**
     * 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.<File>} files
     * @return {!Array.<File>}
     */
    function filterFileList(compiledFilter, files) {
        if (!compiledFilter) {
            return files;
        }

        var re = new RegExp(compiledFilter);
        return files.filter(function (f) {
            return !re.test(f.fullPath);
        });
    }

    /**
     * Returns a copy of 'file path' strings that 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.<string>} An array with a list of full file paths that matches atleast one of the filter.
     * @return {!Array.<string>}
     */
    function getPathsMatchingFilter(compiledFilter, filePaths) {
        if (!compiledFilter) {
            return filePaths;
        }

        var re = new RegExp(compiledFilter);
        return filePaths.filter(function (f) {
            return f.match(re);
        });
    }


    /**
     * 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.
     * @param {!{name: string, patterns: Array.<string>}} filter
     * @param {number} index The index of the filter set to be edited or created. The value is -1 if it is for a new one
     *          to be created.
     * @return {!$.Promise} Dialog box promise
     */
    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();
    }


    /**
     * 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 = getActiveFilter();
        return (filter && filter.patterns.length) ? compile(filter.patterns) : "";
    }

    /**
     * Remove the target item from the filter dropdown list and update dropdown button
     * and dropdown list UI.
     * @param {!Event} e Mouse events
     */
    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.
     * @param {!Event} e Mouse events
     */
    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.
     * @param {!Event>} event listRendered event triggered when the dropdown is open
     * @param {!jQueryObject} $dropdown the jQuery DOM node of the dropdown list
     */
    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);
    }

    /**
     * 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.
     *
     * @param {?{label:string, promise:$.Promise}} context Info on files that filter will apply to.
     *      This will be saved as _context for later use in creating a new filter or editing an
     *      existing filter in Edit Filter dialog.
     * @return {!jQueryObject} Picker UI. To retrieve the selected value, use commitPicker().
     */
    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;
    }

    /**
     * Allows unit tests to open the file filter dropdown list.
     */
    function showDropdown() {
        if (_picker) {
            _picker.showDropdown();
        }
    }

    /**
     * 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;
});