src/extensibility/ExtensionManagerDialog.js
/*
* 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.
*
*/
define(function (require, exports, module) {
"use strict";
var _ = require("thirdparty/lodash"),
Mustache = require("thirdparty/mustache/mustache"),
Dialogs = require("widgets/Dialogs"),
DefaultDialogs = require("widgets/DefaultDialogs"),
FileSystem = require("filesystem/FileSystem"),
FileUtils = require("file/FileUtils"),
Package = require("extensibility/Package"),
Strings = require("strings"),
StringUtils = require("utils/StringUtils"),
Commands = require("command/Commands"),
CommandManager = require("command/CommandManager"),
InstallExtensionDialog = require("extensibility/InstallExtensionDialog"),
AppInit = require("utils/AppInit"),
Async = require("utils/Async"),
KeyEvent = require("utils/KeyEvent"),
ExtensionManager = require("extensibility/ExtensionManager"),
ExtensionManagerView = require("extensibility/ExtensionManagerView").ExtensionManagerView,
ExtensionManagerViewModel = require("extensibility/ExtensionManagerViewModel"),
PreferencesManager = require("preferences/PreferencesManager");
var dialogTemplate = require("text!htmlContent/extension-manager-dialog.html");
// bootstrap tabs component
require("widgets/bootstrap-tab");
var _activeTabIndex;
function _stopEvent(event) {
event.stopPropagation();
event.preventDefault();
}
/**
* @private
* Triggers changes requested by the dialog UI.
*/
function _performChanges() {
// If an extension was removed or updated, prompt the user to quit Brackets.
var hasRemovedExtensions = ExtensionManager.hasExtensionsToRemove(),
hasUpdatedExtensions = ExtensionManager.hasExtensionsToUpdate(),
hasDisabledExtensions = ExtensionManager.hasExtensionsToDisable();
if (!hasRemovedExtensions && !hasUpdatedExtensions && !hasDisabledExtensions) {
return;
}
var buttonLabel = Strings.CHANGE_AND_RELOAD;
if (hasRemovedExtensions && !hasUpdatedExtensions && !hasDisabledExtensions) {
buttonLabel = Strings.REMOVE_AND_RELOAD;
} else if (hasUpdatedExtensions && !hasRemovedExtensions && !hasDisabledExtensions) {
buttonLabel = Strings.UPDATE_AND_RELOAD;
} else if (hasDisabledExtensions && !hasRemovedExtensions && !hasUpdatedExtensions) {
buttonLabel = Strings.DISABLE_AND_RELOAD;
}
var dlg = Dialogs.showModalDialog(
DefaultDialogs.DIALOG_ID_CHANGE_EXTENSIONS,
Strings.CHANGE_AND_RELOAD_TITLE,
Strings.CHANGE_AND_RELOAD_MESSAGE,
[
{
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 : buttonLabel
}
],
false
),
$dlg = dlg.getElement();
$dlg.one("buttonClick", function (e, buttonId) {
if (buttonId === Dialogs.DIALOG_BTN_OK) {
// Disable the dialog buttons so the user can't dismiss it,
// and show a message indicating that we're doing the updates,
// in case it takes a long time.
$dlg.find(".dialog-button").prop("disabled", true);
$dlg.find(".close").hide();
$dlg.find(".dialog-message")
.text(Strings.PROCESSING_EXTENSIONS)
.append("<span class='spinner inline spin'/>");
var removeExtensionsPromise,
updateExtensionsPromise,
disableExtensionsPromise,
removeErrors,
updateErrors,
disableErrors;
removeExtensionsPromise = ExtensionManager.removeMarkedExtensions()
.fail(function (errorArray) {
removeErrors = errorArray;
});
updateExtensionsPromise = ExtensionManager.updateExtensions()
.fail(function (errorArray) {
updateErrors = errorArray;
});
disableExtensionsPromise = ExtensionManager.disableMarkedExtensions()
.fail(function (errorArray) {
disableErrors = errorArray;
});
Async.waitForAll([removeExtensionsPromise, updateExtensionsPromise, disableExtensionsPromise], true)
.always(function () {
dlg.close();
})
.done(function () {
CommandManager.execute(Commands.APP_RELOAD);
})
.fail(function () {
var ids = [],
dialogs = [];
function nextDialog() {
var dialog = dialogs.shift();
if (dialog) {
Dialogs.showModalDialog(dialog.dialog, dialog.title, dialog.message)
.done(nextDialog);
} else {
// Even in case of error condition, we still have to reload
CommandManager.execute(Commands.APP_RELOAD);
}
}
if (removeErrors) {
removeErrors.forEach(function (errorObj) {
ids.push(errorObj.item);
});
dialogs.push({
dialog: DefaultDialogs.DIALOG_ID_ERROR,
title: Strings.EXTENSION_MANAGER_REMOVE,
message: StringUtils.format(Strings.EXTENSION_MANAGER_REMOVE_ERROR, ids.join(", "))
});
}
if (updateErrors) {
// This error case should be very uncommon.
// Just let the user know that we couldn't update
// this extension and log the errors to the console.
ids.length = 0;
updateErrors.forEach(function (errorObj) {
ids.push(errorObj.item);
if (errorObj.error && errorObj.error.forEach) {
console.error("Errors for", errorObj.item);
errorObj.error.forEach(function (error) {
console.error(Package.formatError(error));
});
} else {
console.error("Error for", errorObj.item, errorObj);
}
});
dialogs.push({
dialog: DefaultDialogs.DIALOG_ID_ERROR,
title: Strings.EXTENSION_MANAGER_UPDATE,
message: StringUtils.format(Strings.EXTENSION_MANAGER_UPDATE_ERROR, ids.join(", "))
});
}
if (disableErrors) {
ids.length = 0;
disableErrors.forEach(function (errorObj) {
ids.push(errorObj.item);
});
dialogs.push({
dialog: DefaultDialogs.DIALOG_ID_ERROR,
title: Strings.EXTENSION_MANAGER_DISABLE,
message: StringUtils.format(Strings.EXTENSION_MANAGER_DISABLE_ERROR, ids.join(", "))
});
}
nextDialog();
});
} else {
dlg.close();
ExtensionManager.cleanupUpdates();
ExtensionManager.unmarkAllForRemoval();
ExtensionManager.unmarkAllForDisabling();
}
});
}
/**
* @private
* Install extensions from the local file system using the install dialog.
* @return {$.Promise}
*/
function _installUsingDragAndDrop() {
var installZips = [],
updateZips = [],
deferred = new $.Deferred(),
validatePromise;
brackets.app.getDroppedFiles(function (err, paths) {
if (err) {
// Only possible error is invalid params, silently ignore
console.error(err);
deferred.resolve();
return;
}
// Parse zip files and separate new installs vs. updates
validatePromise = Async.doInParallel_aggregateErrors(paths, function (path) {
var result = new $.Deferred();
FileSystem.resolve(path, function (err, file) {
var extension = FileUtils.getFileExtension(path),
isZip = file.isFile && (extension === "zip"),
errStr;
if (err) {
errStr = FileUtils.getFileErrorString(err);
} else if (!isZip) {
errStr = Strings.INVALID_ZIP_FILE;
}
if (errStr) {
result.reject(errStr);
return;
}
// Call validate() so that we open the local zip file and parse the
// package.json. We need the name to detect if this zip will be a
// new install or an update.
Package.validate(path, { requirePackageJSON: true }).done(function (info) {
if (info.errors.length) {
result.reject(info.errors.map(Package.formatError).join(" "));
return;
}
var extensionName = info.metadata.name,
extensionInfo = ExtensionManager.extensions[extensionName],
isUpdate = extensionInfo && !!extensionInfo.installInfo;
if (isUpdate) {
updateZips.push(file);
} else {
installZips.push(file);
}
result.resolve();
}).fail(function (err) {
result.reject(Package.formatError(err));
});
});
return result.promise();
});
validatePromise.done(function () {
var installPromise = Async.doSequentially(installZips, function (file) {
return InstallExtensionDialog.installUsingDialog(file);
});
var updatePromise = installPromise.then(function () {
return Async.doSequentially(updateZips, function (file) {
return InstallExtensionDialog.updateUsingDialog(file).done(function (result) {
ExtensionManager.updateFromDownload(result);
});
});
});
// InstallExtensionDialog displays it's own errors, always
// resolve the outer promise
updatePromise.always(deferred.resolve);
}).fail(function (errorArray) {
deferred.reject(errorArray);
});
});
return deferred.promise();
}
/**
* @private
* Show a dialog that allows the user to browse and manage extensions.
*/
function _showDialog() {
var dialog,
$dlg,
views = [],
$search,
$searchClear,
$modalDlg,
context = { Strings: Strings, showRegistry: !!brackets.config.extension_registry },
models = [];
// Load registry only if the registry URL exists
if (context.showRegistry) {
models.push(new ExtensionManagerViewModel.RegistryViewModel());
models.push(new ExtensionManagerViewModel.ThemesViewModel());
}
models.push(new ExtensionManagerViewModel.InstalledViewModel());
models.push(new ExtensionManagerViewModel.DefaultViewModel());
function updateSearchDisabled() {
var model = models[_activeTabIndex],
searchDisabled = ($search.val() === "") &&
(!model.filterSet || model.filterSet.length === 0);
$search.prop("disabled", searchDisabled);
$searchClear.prop("disabled", searchDisabled);
return searchDisabled;
}
function clearSearch() {
$search.val("");
views.forEach(function (view, index) {
view.filter("");
$modalDlg.scrollTop(0);
});
if (!updateSearchDisabled()) {
$search.focus();
}
}
// Open the dialog
dialog = Dialogs.showModalDialogUsingTemplate(Mustache.render(dialogTemplate, context));
// On dialog close: clean up listeners & models, and commit changes
dialog.done(function () {
$(window.document).off(".extensionManager");
models.forEach(function (model) {
model.dispose();
});
_performChanges();
});
// Create the view.
$dlg = dialog.getElement();
$search = $(".search", $dlg);
$searchClear = $(".search-clear", $dlg);
$modalDlg = $(".modal-body", $dlg);
function setActiveTab($tab) {
if (models[_activeTabIndex]) {
models[_activeTabIndex].scrollPos = $modalDlg.scrollTop();
}
$tab.tab("show");
if (models[_activeTabIndex]) {
$modalDlg.scrollTop(models[_activeTabIndex].scrollPos || 0);
clearSearch();
if (_activeTabIndex === 2) {
$(".ext-sort-group").hide();
} else {
$(".ext-sort-group").show();
}
}
}
// Dialog tabs
$dlg.find(".nav-tabs a")
.on("click", function (event) {
setActiveTab($(this));
});
// Navigate through tabs via Ctrl-(Shift)-Tab
// (focus may be on document.body if text in extension listing clicked - see #9511)
$(window.document).on("keyup.extensionManager", function (event) {
if (event.keyCode === KeyEvent.DOM_VK_TAB && event.ctrlKey) {
var $tabs = $(".nav-tabs a", $dlg),
tabIndex = _activeTabIndex;
if (event.shiftKey) {
tabIndex--;
} else {
tabIndex++;
}
tabIndex %= $tabs.length;
setActiveTab($tabs.eq(tabIndex));
}
});
// Update & hide/show the notification overlay on a tab's icon, based on its model's notifyCount
function updateNotificationIcon(index) {
var model = models[index],
$notificationIcon = $dlg.find(".nav-tabs li").eq(index).find(".notification");
if (model.notifyCount) {
$notificationIcon.text(model.notifyCount);
$notificationIcon.show();
} else {
$notificationIcon.hide();
}
}
// Initialize models and create a view for each model
var modelInitPromise = Async.doInParallel(models, function (model, index) {
var view = new ExtensionManagerView(),
promise = view.initialize(model),
lastNotifyCount;
promise.always(function () {
views[index] = view;
lastNotifyCount = model.notifyCount;
updateNotificationIcon(index);
});
model.on("change", function () {
if (lastNotifyCount !== model.notifyCount) {
lastNotifyCount = model.notifyCount;
updateNotificationIcon(index);
}
});
return promise;
}, true);
modelInitPromise.always(function () {
$(".spinner", $dlg).remove();
views.forEach(function (view) {
view.$el.appendTo($modalDlg);
});
// Update search UI before new tab is shown
$("a[data-toggle='tab']", $dlg).each(function (index, tabElement) {
$(tabElement).on("show", function (event) {
_activeTabIndex = index;
// Focus the search input
if (!updateSearchDisabled()) {
$dlg.find(".search").focus();
}
});
});
// Filter the views when the user types in the search field.
var searchTimeoutID;
$dlg.on("input", ".search", function (e) {
clearTimeout(searchTimeoutID);
var query = $(this).val();
searchTimeoutID = setTimeout(function () {
views[_activeTabIndex].filter(query);
$modalDlg.scrollTop(0);
}, 200);
}).on("click", ".search-clear", clearSearch);
// Sort the extension list based on the current selected sorting criteria
$dlg.on("change", ".sort-extensions", function (e) {
var sortBy = $(this).val();
PreferencesManager.set("extensions.sort", sortBy);
models.forEach(function (model, index) {
if (index <= 1) {
model._setSortedExtensionList(ExtensionManager.extensions, index === 1);
views[index].filter($(".search").val());
}
});
});
// Disable the search field when there are no items in the model
models.forEach(function (model, index) {
model.on("change", function () {
if (_activeTabIndex === index) {
updateSearchDisabled();
}
});
});
var $activeTab = $dlg.find(".nav-tabs li.active a");
if ($activeTab.length) { // If there's already a tab selected, show it
$activeTab.parent().removeClass("active"); // workaround for bootstrap-tab
$activeTab.tab("show");
} else if ($("#toolbar-extension-manager").hasClass('updatesAvailable')) {
// Open dialog to Installed tab if extension updates are available
$dlg.find(".nav-tabs a.installed").tab("show");
} else { // Otherwise show the first tab
$dlg.find(".nav-tabs a:first").tab("show");
}
// If activeTab was explicitly selected by user,
// then check for the selection
// Or if there was an update available since activeTab.length would be 0,
// then check for updatesAvailable class in toolbar-extension-manager
if (($activeTab.length && $activeTab.hasClass("installed")) || (!$activeTab.length && $("#toolbar-extension-manager").hasClass('updatesAvailable'))) {
$(".ext-sort-group").hide();
} else {
$(".ext-sort-group").show();
}
});
// Handle the 'Install from URL' button.
$(".extension-manager-dialog .install-from-url")
.click(function () {
InstallExtensionDialog.showDialog().done(ExtensionManager.updateFromDownload);
});
// Handle the drag/drop zone
var $dropzone = $("#install-drop-zone"),
$dropmask = $("#install-drop-zone-mask");
$dropzone
.on("dragover", function (event) {
_stopEvent(event);
if (!event.originalEvent.dataTransfer.files) {
return;
}
var items = event.originalEvent.dataTransfer.items,
isValidDrop = false;
isValidDrop = _.every(items, function (item) {
if (item.kind === "file") {
var entry = item.webkitGetAsEntry(),
extension = FileUtils.getFileExtension(entry.fullPath);
return entry.isFile && extension === "zip";
}
return false;
});
if (isValidDrop) {
// Set an absolute width to stabilize the button size
$dropzone.width($dropzone.width());
// Show drop styling and message
$dropzone.removeClass("drag");
$dropzone.addClass("drop");
} else {
event.originalEvent.dataTransfer.dropEffect = "none";
}
})
.on("drop", _stopEvent);
$dropmask
.on("dragover", function (event) {
_stopEvent(event);
event.originalEvent.dataTransfer.dropEffect = "copy";
})
.on("dragleave", function () {
$dropzone.removeClass("drop");
$dropzone.addClass("drag");
})
.on("drop", function (event) {
_stopEvent(event);
if (event.originalEvent.dataTransfer.files) {
// Attempt install
_installUsingDragAndDrop().fail(function (errorArray) {
var message = Strings.INSTALL_EXTENSION_DROP_ERROR;
message += "<ul class='dialog-list'>";
errorArray.forEach(function (info) {
message += "<li><span class='dialog-filename'>";
message += StringUtils.breakableUrl(info.item);
message += "</span>: " + info.error + "</li>";
});
message += "</ul>";
Dialogs.showModalDialog(
DefaultDialogs.DIALOG_ID_ERROR,
Strings.EXTENSION_MANAGER_TITLE,
message
);
}).always(function () {
$dropzone.removeClass("validating");
$dropzone.addClass("drag");
});
// While installing, show validating message
$dropzone.removeClass("drop");
$dropzone.addClass("validating");
}
});
return new $.Deferred().resolve(dialog).promise();
}
CommandManager.register(Strings.CMD_EXTENSION_MANAGER, Commands.FILE_EXTENSION_MANAGER, _showDialog);
AppInit.appReady(function () {
$("#toolbar-extension-manager").click(_showDialog);
});
// Unit tests
exports._performChanges = _performChanges;
});