adobe/brackets

View on GitHub
src/extensibility/ExtensionManagerViewModel.js

Summary

Maintainability
F
3 days
Test Coverage
/*
 * Copyright (c) 2013 - 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.
 *
 */

/*unittests: ExtensionManager*/

define(function (require, exports, module) {
    "use strict";

    var _ = require("thirdparty/lodash");

    var ExtensionManager    = require("extensibility/ExtensionManager"),
        registry_utils      = require("extensibility/registry_utils"),
        EventDispatcher     = require("utils/EventDispatcher"),
        Strings             = require("strings"),
        PreferencesManager  = require("preferences/PreferencesManager");

    /**
     * @private
     * @type {Array}
     * A list of fields to search when trying to search for a query string in an object. Each field is
     * represented as an array of keys to recurse downward through the object. We store this here to avoid
     * doing it for each search call.
     */
    var _searchFields = [["metadata", "name"], ["metadata", "title"], ["metadata", "description"],
                         ["metadata", "author", "name"], ["metadata", "keywords"], ["owner"]];
    /**
     * The base model for the ExtensionManagerView. Keeps track of the extensions that are currently visible
     * and manages sorting/filtering them. Must be disposed with dispose() when done.
     * Events:
     * - change - triggered when the data for a given extension changes. Second parameter is the extension id.
     * - filter - triggered whenever the filtered set changes (including on initialize).
     *
     * @constructor
     */
    function ExtensionManagerViewModel() {
        this._handleStatusChange = this._handleStatusChange.bind(this);

        // Listen for extension status changes.
        ExtensionManager
            .on("statusChange." + this.source, this._handleStatusChange)
            .on("registryUpdate." + this.source, this._handleStatusChange);
    }
    EventDispatcher.makeEventDispatcher(ExtensionManagerViewModel.prototype);

    /**
     * @type {string}
     * Constant indicating that this model/view should initialize from the main extension registry.
     */
    ExtensionManagerViewModel.prototype.SOURCE_REGISTRY = "registry";

    /**
     * @type {string}
     * Constant indicating that this model/view should initialize from the main extension registry with only themes.
     */
    ExtensionManagerViewModel.prototype.SOURCE_THEMES = "themes";

    /**
     * @type {string}
     * Constant indicating that this model/view should initialize from the list of locally installed extensions.
     */
    ExtensionManagerViewModel.prototype.SOURCE_INSTALLED = "installed";

    /**
     * @type {string}
     * Constant indicating that this model/view should initialize from the list of default bundled extensions.
     */
    ExtensionManagerViewModel.prototype.SOURCE_DEFAULT = "default";

    /**
     * @type {Object}
     * The current set of extensions managed by this model. Same as ExtensionManager.extensions.
     */
    ExtensionManagerViewModel.prototype.extensions = null;

    /**
     * @type {string}
     * The current source for the model; one of the SOURCE_* keys above.
     */
    ExtensionManagerViewModel.prototype.source = null;

    /**
     * @type {Array.<Object>}
     * The list of IDs of items matching the current query and sorted with the current sort.
     */
    ExtensionManagerViewModel.prototype.filterSet = null;

    /**
     * @type {Object}
     * The list of all ids from the extension list, sorted with the current sort.
     */
    ExtensionManagerViewModel.prototype.sortedFullSet = null;

    /**
     * @private
     * @type {string}
     * The last query we filtered by. Used to optimize future searches.
     */
    ExtensionManagerViewModel.prototype._lastQuery = null;

    /**
     * @type {string}
     * Info message to display to the user when listing extensions
     */
    ExtensionManagerViewModel.prototype.infoMessage = null;

    /**
     * @type {string}
     * An optional message to display to the user
     */
    ExtensionManagerViewModel.prototype.message = null;

    /**
     * @type {number}
     * Number to show in tab's notification icon. No icon shown if 0.
     */
    ExtensionManagerViewModel.prototype.notifyCount = 0;

    /**
     * @private {$.Promise}
     * Internal use only to track when initialization fails, see usage in _updateMessage.
     */
    ExtensionManagerViewModel.prototype._initializeFromSourcePromise = null;

    /**
     * Unregisters listeners when we're done.
     */
    ExtensionManagerViewModel.prototype.dispose = function () {
        ExtensionManager.off("." + this.source);
    };

    /**
     * @private
     * Sets up the initial filtered set based on the sorted full set.
     */
    ExtensionManagerViewModel.prototype._setInitialFilter = function () {
        // Initial filtered list is the same as the sorted list.
        this.filterSet = _.clone(this.sortedFullSet);
        this.trigger("filter");
    };

    /**
     * @private
     * Re-sorts the current full set based on the source we're viewing.
     * The base implementation does nothing.
     */
    ExtensionManagerViewModel.prototype._sortFullSet = function () { };

    /**
     * Initializes the model from the source.
     */
    ExtensionManagerViewModel.prototype.initialize = function () {
        var self = this;

        this._initializeFromSourcePromise = this._initializeFromSource().always(function () {
            self._updateMessage();
        });

        return this._initializeFromSourcePromise;
    };

    /**
     * @private
     * Updates the initial set and filter as necessary when the status of an extension changes,
     * and notifies listeners of the change.
     * @param {$.Event} e The jQuery event object.
     * @param {string} id The id of the extension whose status changed.
     */
    ExtensionManagerViewModel.prototype._handleStatusChange = function (e, id) {
        this.trigger("change", id);
    };

    /**
     * @private
     * Searches for the given query in the current extension list and updates the filter set,
     * dispatching a filter event.
     * @param {string} query The string to search for.
     * @param {boolean} force If true, always filter starting with the full set, not the last
     *     query's filter.
     */
    ExtensionManagerViewModel.prototype.filter = function (query, force) {
        var self = this, initialList;
        if (!force && this._lastQuery && query.indexOf(this._lastQuery) === 0) {
            // This is the old query with some new letters added, so we know we can just
            // search in the current filter set. (This is true even if query has spaces).
            initialList = this.filterSet;
        } else {
            // This is a new query, so start with the full list.
            initialList = this.sortedFullSet;
        }

        var keywords = query.toLowerCase().split(/\s+/);

        // Takes 'extensionList' and returns a version filtered to only those that match 'keyword'
        function filterForKeyword(extensionList, word) {
            var filteredList = [];
            extensionList.forEach(function (id) {
                var entry = self._getEntry(id);
                if (entry && self._entryMatchesQuery(entry, word)) {
                    filteredList.push(id);
                }
            });
            return filteredList;
        }

        // "AND" the keywords together: successively filter down the result set by each keyword in turn
        var i, currentList = initialList;
        for (i = 0; i < keywords.length; i++) {
            currentList = filterForKeyword(currentList, keywords[i]);
        }

        this._lastQuery = query;
        this.filterSet = currentList;

        this._updateMessage();

        this.trigger("filter");
    };

    /**
     * @private
     * Updates an optional message displayed to the user along with the extension list.
     */
    ExtensionManagerViewModel.prototype._updateMessage = function () {
        if (this._initializeFromSourcePromise && this._initializeFromSourcePromise.state() === "rejected") {
            this.message = Strings.EXTENSION_MANAGER_ERROR_LOAD;
        } else if (this.filterSet && this.filterSet.length === 0) {
            this.message = this.sortedFullSet && this.sortedFullSet.length ? Strings.NO_EXTENSION_MATCHES : Strings.NO_EXTENSIONS;
        } else {
            this.message = null;
        }
    };

    /**
     * @private
     * This is to be overridden by subclasses to provide the metadata for the extension
     * with the provided `id`.
     *
     * @param {string} id of the extension
     * @return {Object?} extension metadata or null if there's no matching extension
     */
    ExtensionManagerViewModel.prototype._getEntry = function (id) {
        return null;
    };

    /**
     * @private
     * Tests if the given entry matches the query.
     * @param {Object} entry The extension entry to test.
     * @param {string} query The query to match against.
     * @return {boolean} Whether the query matches.
     */
    ExtensionManagerViewModel.prototype._entryMatchesQuery = function (entry, query) {
        return query === "" ||
            _searchFields.some(function (fieldSpec) {
                var i, cur = entry;
                for (i = 0; i < fieldSpec.length; i++) {
                    // Recurse downward through the specified fields to the leaf value.
                    cur = cur[fieldSpec[i]];
                    if (!cur) {
                        return false;
                    }
                }
                // If the leaf value is an array (like keywords), search each item, otherwise
                // just search in the string.
                if (Array.isArray(cur)) {
                    return cur.some(function (keyword) {
                        return keyword.toLowerCase().indexOf(query) !== -1;
                    });
                } else if (fieldSpec[fieldSpec.length - 1] === "owner") {
                    // Special handling: ignore the authentication source when querying,
                    // since it's not useful to search on
                    var components = cur.split(":");
                    if (components[1].toLowerCase().indexOf(query) !== -1) {
                        return true;
                    }
                } else if (cur.toLowerCase().indexOf(query) !== -1) {
                    return true;
                }
            });
    };

    ExtensionManagerViewModel.prototype._setSortedExtensionList = function (extensions, isTheme) {
        this.filterSet = this.sortedFullSet = registry_utils.sortRegistry(extensions, "registryInfo", PreferencesManager.get("extensions.sort"))
            .filter(function (entry) {
                if (!isTheme) {
                    return entry.registryInfo && !entry.registryInfo.metadata.theme;
                } else {
                    return entry.registryInfo && entry.registryInfo.metadata.theme;
                }
            })
            .map(function (entry) {
                return entry.registryInfo.metadata.name;
            });
    };

    /**
     * The model for the ExtensionManagerView that is responsible for handling registry-based extensions.
     * This extends ExtensionManagerViewModel.
     * Must be disposed with dispose() when done.
     *
     * Events:
     * - change - triggered when the data for a given extension changes. Second parameter is the extension id.
     * - filter - triggered whenever the filtered set changes (including on initialize).
     *
     * @constructor
     */
    function RegistryViewModel() {
        ExtensionManagerViewModel.call(this);
        this.infoMessage = Strings.REGISTRY_SANITY_CHECK_WARNING;
    }

    RegistryViewModel.prototype = Object.create(ExtensionManagerViewModel.prototype);
    RegistryViewModel.prototype.constructor = RegistryViewModel;

    /**
     * @type {string}
     * RegistryViewModels always have a source of SOURCE_REGISTRY.
     */
    RegistryViewModel.prototype.source = ExtensionManagerViewModel.prototype.SOURCE_REGISTRY;

    /**
     * Initializes the model from the remote extension registry.
     * @return {$.Promise} a promise that's resolved with the registry JSON data
     * or rejected if the server can't be reached.
     */
    RegistryViewModel.prototype._initializeFromSource = function () {
        var self = this;
        return ExtensionManager.downloadRegistry()
            .done(function () {
                self.extensions = ExtensionManager.extensions;

                // Sort the registry by last published date and store the sorted list of IDs.
                self._setSortedExtensionList(ExtensionManager.extensions, false);
                self._setInitialFilter();
            })
            .fail(function () {
                self.extensions = [];
                self.sortedFullSet = [];
                self.filterSet = [];
            });
    };

    /**
     * @private
     * Finds the extension metadata by id. If there is no extension matching the given id,
     * this returns `null`.
     * @param {string} id of the extension
     * @return {Object?} extension metadata or null if there's no matching extension
     */
    RegistryViewModel.prototype._getEntry = function (id) {
        var entry = this.extensions[id];
        if (entry) {
            return entry.registryInfo;
        }
        return entry;
    };

    /**
     * The model for the ExtensionManagerView that is responsible for handling previously-installed extensions.
     * This extends ExtensionManagerViewModel.
     * Must be disposed with dispose() when done.
     *
     * Events:
     * - change - triggered when the data for a given extension changes. Second parameter is the extension id.
     * - filter - triggered whenever the filtered set changes (including on initialize).
     *
     * @constructor
     */
    function InstalledViewModel() {
        ExtensionManagerViewModel.call(this);

        // when registry is downloaded, sort extensions again - those with updates will be before others
        var self = this;
        ExtensionManager.on("registryDownload." + this.source, function () {
            self._sortFullSet();
            self._setInitialFilter();
        });
    }

    InstalledViewModel.prototype = Object.create(ExtensionManagerViewModel.prototype);
    InstalledViewModel.prototype.constructor = InstalledViewModel;

    /**
     * @type {string}
     * InstalledViewModels always have a source of SOURCE_INSTALLED.
     */
    InstalledViewModel.prototype.source = ExtensionManagerViewModel.prototype.SOURCE_INSTALLED;

    /**
     * Initializes the model from the set of locally installed extensions, sorted
     * alphabetically by id (or name of the extension folder for legacy extensions).
     * @return {$.Promise} a promise that's resolved when we're done initializing.
     */
    InstalledViewModel.prototype._initializeFromSource = function () {
        var self = this;
        this.extensions = ExtensionManager.extensions;
        this.sortedFullSet = Object.keys(this.extensions)
            .filter(function (key) {
                return self.extensions[key].installInfo &&
                    self.extensions[key].installInfo.locationType !== ExtensionManager.LOCATION_DEFAULT;
            });
        this._sortFullSet();
        this._setInitialFilter();
        this._countUpdates();

        return new $.Deferred().resolve().promise();
    };

    /**
     * @private
     * Re-sorts the current full set
     */
    InstalledViewModel.prototype._sortFullSet = function () {
        var self = this;

        this.sortedFullSet = this.sortedFullSet.sort(function (key1, key2) {
            // before sorting by name, put first extensions that have updates
            var ua1 = self.extensions[key1].installInfo.updateAvailable,
                ua2 = self.extensions[key2].installInfo.updateAvailable;

            if (ua1 && !ua2) {
                return -1;
            } else if (!ua1 && ua2) {
                return 1;
            }

            var metadata1 = self.extensions[key1].installInfo.metadata,
                metadata2 = self.extensions[key2].installInfo.metadata,
                id1 = (metadata1.title || metadata1.name).toLocaleLowerCase(),
                id2 = (metadata2.title || metadata2.name).toLocaleLowerCase();

            return id1.localeCompare(id2);
        });
    };

    /**
     * @private
     * Updates notifyCount based on number of extensions with an update available
     */
    InstalledViewModel.prototype._countUpdates = function () {
        var self = this;
        this.notifyCount = 0;
        this.sortedFullSet.forEach(function (key) {
            if (self.extensions[key].installInfo.updateCompatible && !ExtensionManager.isMarkedForUpdate(key)) {
                self.notifyCount++;
            }
        });
    };

    /**
     * @private
     * Updates the initial set and filter as necessary when the status of an extension changes,
     * and notifies listeners of the change.
     * @param {$.Event} e The jQuery event object.
     * @param {string} id The id of the extension whose status changed.
     */
    InstalledViewModel.prototype._handleStatusChange = function (e, id) {
        var index = this.sortedFullSet.indexOf(id),
            refilter = false;
        if (index !== -1 && !this.extensions[id].installInfo) {
            // This was in our set, but was uninstalled. Remove it.
            this.sortedFullSet.splice(index, 1);
            this._countUpdates();  // may also affect update count
            refilter = true;
        } else if (index === -1 && this.extensions[id].installInfo) {
            // This was not in our set, but is now installed. Add it and resort.
            this.sortedFullSet.push(id);
            this._sortFullSet();
            refilter = true;
        }
        if (refilter) {
            this.filter(this._lastQuery || "", true);
        }

        if (this.extensions[id].installInfo) {
            // If our count of available updates may have been affected, re-count
            this._countUpdates();
        }

        ExtensionManagerViewModel.prototype._handleStatusChange.call(this, e, id);
    };

    /**
     * @private
     * Finds the extension metadata by id. If there is no extension matching the given id,
     * this returns `null`.
     * @param {string} id of the extension
     * @return {Object?} extension metadata or null if there's no matching extension
     */
    InstalledViewModel.prototype._getEntry = function (id) {
        var entry = this.extensions[id];
        if (entry) {
            return entry.installInfo;
        }
        return entry;
    };

    /**
     * Model for displaying default extensions that come bundled with Brackets
     */
    function DefaultViewModel() {
        ExtensionManagerViewModel.call(this);
    }

    // Inheritance setup
    DefaultViewModel.prototype = Object.create(ExtensionManagerViewModel.prototype);
    DefaultViewModel.prototype.constructor = DefaultViewModel;

    /**
     * Add SOURCE_DEFAULT to DefaultViewModel
     */
    DefaultViewModel.prototype.source = ExtensionManagerViewModel.prototype.SOURCE_DEFAULT;

    /**
     * Initializes the model from the set of default extensions, sorted alphabetically by id
     * @return {$.Promise} a promise that's resolved when we're done initializing.
     */
    DefaultViewModel.prototype._initializeFromSource = function () {
        var self = this;
        this.extensions = ExtensionManager.extensions;
        this.sortedFullSet = Object.keys(this.extensions)
            .filter(function (key) {
                return self.extensions[key].installInfo &&
                    self.extensions[key].installInfo.locationType === ExtensionManager.LOCATION_DEFAULT;
            });
        this._sortFullSet();
        this._setInitialFilter();
        return new $.Deferred().resolve().promise();
    };

    /**
     * @private
     * Re-sorts the current full set
     */
    DefaultViewModel.prototype._sortFullSet = function () {
        var self = this;
        this.sortedFullSet = this.sortedFullSet.sort(function (key1, key2) {
            var metadata1 = self.extensions[key1].installInfo.metadata,
                metadata2 = self.extensions[key2].installInfo.metadata,
                id1 = (metadata1.title || metadata1.name).toLocaleLowerCase(),
                id2 = (metadata2.title || metadata2.name).toLocaleLowerCase();
            return id1.localeCompare(id2);
        });
    };

    /**
     * @private
     * Finds the default extension metadata by id. If there is no default extension matching the given id,
     * this returns `null`.
     * @param {string} id of the theme extension
     * @return {Object?} extension metadata or null if there's no matching extension
     */
    DefaultViewModel.prototype._getEntry = function (id) {
        return this.extensions[id] ? this.extensions[id].installInfo : null;
    };

    /**
     * The model for the ExtensionManagerView that is responsible for handling registry-based theme extensions.
     * This extends ExtensionManagerViewModel.
     * Must be disposed with dispose() when done.
     *
     * Events:
     * - change - triggered when the data for a given extension changes. Second parameter is the extension id.
     * - filter - triggered whenever the filtered set changes (including on initialize).
     *
     * @constructor
     */
    function ThemesViewModel() {
        ExtensionManagerViewModel.call(this);
    }

    // Inheritance setup
    ThemesViewModel.prototype = Object.create(ExtensionManagerViewModel.prototype);
    ThemesViewModel.prototype.constructor = ThemesViewModel;

    /**
     * @type {string}
     * ThemeViewModels always have a source of SOURCE_THEMES.
     */
    ThemesViewModel.prototype.source = ExtensionManagerViewModel.prototype.SOURCE_THEMES;

    /**
     * Initializes the model from the remote extension registry.
     * @return {$.Promise} a promise that's resolved with the registry JSON data.
     */
    ThemesViewModel.prototype._initializeFromSource = function () {
        var self = this;
        return ExtensionManager.downloadRegistry()
            .done(function () {
                self.extensions = ExtensionManager.extensions;

                // Sort the registry by last published date and store the sorted list of IDs.
                self._setSortedExtensionList(ExtensionManager.extensions, true);
                self._setInitialFilter();
            })
            .fail(function () {
                self.extensions = [];
                self.sortedFullSet = [];
                self.filterSet = [];
            });
    };

    /**
     * @private
     * Finds the theme extension metadata by id. If there is no theme extension matching the given id,
     * this returns `null`.
     * @param {string} id of the theme extension
     * @return {Object?} extension metadata or null if there's no matching extension
     */
    ThemesViewModel.prototype._getEntry = function (id) {
        var entry = this.extensions[id];
        if (entry) {
            return entry.registryInfo;
        }
        return entry;
    };

    exports.RegistryViewModel = RegistryViewModel;
    exports.ThemesViewModel = ThemesViewModel;
    exports.InstalledViewModel = InstalledViewModel;
    exports.DefaultViewModel = DefaultViewModel;
});