gecko-dev/browser/devtools/projecteditor/lib/projecteditor.js

794 lines
24 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const { Cc, Ci, Cu } = require("chrome");
const { Class } = require("sdk/core/heritage");
const { Project } = require("projecteditor/project");
const { ProjectTreeView } = require("projecteditor/tree");
const { ShellDeck } = require("projecteditor/shells");
const { Resource } = require("projecteditor/stores/resource");
const { registeredPlugins } = require("projecteditor/plugins/core");
const { EventTarget } = require("sdk/event/target");
const { on, forget } = require("projecteditor/helpers/event");
const { emit } = require("sdk/event/core");
const { merge } = require("sdk/util/object");
const promise = require("projecteditor/helpers/promise");
const { ViewHelpers } = Cu.import("resource:///modules/devtools/ViewHelpers.jsm", {});
const { DOMHelpers } = Cu.import("resource:///modules/devtools/DOMHelpers.jsm");
const { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
const ITCHPAD_URL = "chrome://browser/content/devtools/projecteditor.xul";
const { confirm } = require("projecteditor/helpers/prompts");
const { getLocalizedString } = require("projecteditor/helpers/l10n");
// Enabled Plugins
require("projecteditor/plugins/dirty/dirty");
require("projecteditor/plugins/delete/delete");
require("projecteditor/plugins/new/new");
require("projecteditor/plugins/save/save");
require("projecteditor/plugins/image-view/plugin");
require("projecteditor/plugins/app-manager/plugin");
require("projecteditor/plugins/status-bar/plugin");
// Uncomment to enable logging.
// require("projecteditor/plugins/logging/logging");
/**
* This is the main class tying together an instance of the ProjectEditor.
* The frontend is contained inside of this.iframe, which loads projecteditor.xul.
*
* Usage:
* let projecteditor = new ProjectEditor(frame);
* projecteditor.loaded.then((projecteditor) => {
* // Ready to use.
* });
*
* Responsible for maintaining:
* - The list of Plugins for this instance.
* - The ShellDeck, which includes all Shells for opened Resources
* -- Shells take in a Resource, and construct the appropriate Editor
* - The Project, which includes all Stores for this instance
* -- Stores manage all Resources starting from a root directory
* --- Resources are a representation of a file on disk
* - The ProjectTreeView that builds the UI for interacting with the
* project.
*
* This object emits the following events:
* - "onEditorDestroyed": When editor is destroyed
* - "onEditorSave": When editor is saved
* - "onEditorLoad": When editor is loaded
* - "onEditorActivated": When editor is activated
* - "onEditorChange": When editor is changed
* - "onEditorCursorActivity": When there is cursor activity in a text editor
* - "onCommand": When a command happens
* - "onEditorDestroyed": When editor is destroyed
* - "onContextMenuOpen": When the context menu is opened on the project tree
*
* The events can be bound like so:
* projecteditor.on("onEditorCreated", (editor) => { });
*/
var ProjectEditor = Class({
extends: EventTarget,
/**
* Initialize ProjectEditor, and load into an iframe if specified.
*
* @param Iframe iframe
* The iframe to inject the DOM into. If this is not
* specified, then this.load(frame) will need to be called
* before accessing ProjectEditor.
* @param Object options
* - menubar: a <menubar> element to inject menus into
* - menuindex: Integer child index to insert menus
*/
initialize: function(iframe, options = {}) {
this._onTreeSelected = this._onTreeSelected.bind(this);
this._onTreeResourceRemoved = this._onTreeResourceRemoved.bind(this);
this._onEditorCreated = this._onEditorCreated.bind(this);
this._onEditorActivated = this._onEditorActivated.bind(this);
this._onEditorDeactivated = this._onEditorDeactivated.bind(this);
this._updateMenuItems = this._updateMenuItems.bind(this);
this._updateContextMenuItems = this._updateContextMenuItems.bind(this);
this.destroy = this.destroy.bind(this);
this.menubar = options.menubar || null;
this.menuindex = options.menuindex || null;
this._menuEnabled = true;
this._destroyed = false;
this._loaded = false;
this._pluginCommands = new Map();
if (iframe) {
this.load(iframe);
}
},
/**
* Load the instance inside of a specified iframe.
* This can be called more than once, and it will return the promise
* from the first call.
*
* @param Iframe iframe
* The iframe to inject the projecteditor DOM into
* @returns Promise
* A promise that is resolved once the iframe has been
* loaded.
*/
load: function(iframe) {
if (this.loaded) {
return this.loaded;
}
let deferred = promise.defer();
this.loaded = deferred.promise;
this.iframe = iframe;
let domReady = () => {
if (this._destroyed) {
deferred.reject("Error: ProjectEditor has been destroyed before loading");
return;
}
this._onLoad();
this._loaded = true;
deferred.resolve(this);
};
let domHelper = new DOMHelpers(this.iframe.contentWindow);
domHelper.onceDOMReady(domReady);
this.iframe.setAttribute("src", ITCHPAD_URL);
return this.loaded;
},
/**
* Build the projecteditor DOM inside of this.iframe.
*/
_onLoad: function() {
this.document = this.iframe.contentDocument;
this.window = this.iframe.contentWindow;
this._initCommands();
this._buildMenubar();
this._buildSidebar();
this.window.addEventListener("unload", this.destroy, false);
// Editor management
this.shells = new ShellDeck(this, this.document);
this.shells.on("editor-created", this._onEditorCreated);
this.shells.on("editor-activated", this._onEditorActivated);
this.shells.on("editor-deactivated", this._onEditorDeactivated);
let shellContainer = this.document.querySelector("#shells-deck-container");
shellContainer.appendChild(this.shells.elt);
// We are not allowing preset projects for now - rebuild a fresh one
// each time.
this.setProject(new Project({
id: "",
name: "",
directories: [],
openFiles: []
}));
this._initPlugins();
},
_buildMenubar: function() {
this.contextMenuPopup = this.document.getElementById("context-menu-popup");
this.contextMenuPopup.addEventListener("popupshowing", this._updateContextMenuItems);
this.textEditorContextMenuPopup = this.document.getElementById("texteditor-context-popup");
this.textEditorContextMenuPopup.addEventListener("popupshowing", this._updateMenuItems);
this.editMenu = this.document.getElementById("edit-menu");
this.fileMenu = this.document.getElementById("file-menu");
this.editMenuPopup = this.document.getElementById("edit-menu-popup");
this.fileMenuPopup = this.document.getElementById("file-menu-popup");
this.editMenu.addEventListener("popupshowing", this._updateMenuItems);
this.fileMenu.addEventListener("popupshowing", this._updateMenuItems);
if (this.menubar) {
let body = this.menubar.ownerDocument.body ||
this.menubar.ownerDocument.querySelector("window");
body.appendChild(this.projectEditorCommandset);
body.appendChild(this.projectEditorKeyset);
body.appendChild(this.editorCommandset);
body.appendChild(this.editorKeyset);
body.appendChild(this.contextMenuPopup);
body.appendChild(this.textEditorContextMenuPopup);
let index = this.menuindex || 0;
this.menubar.insertBefore(this.editMenu, this.menubar.children[index]);
this.menubar.insertBefore(this.fileMenu, this.menubar.children[index]);
} else {
this.document.getElementById("projecteditor-menubar").style.display = "block";
}
// Insert a controller to allow enabling and disabling of menu items.
this._commandWindow = this.editorCommandset.ownerDocument.defaultView;
this._commandController = getCommandController(this);
this._commandWindow.controllers.insertControllerAt(0, this._commandController);
},
/**
* Create the project tree sidebar that lists files.
*/
_buildSidebar: function() {
this.projectTree = new ProjectTreeView(this.document, {
resourceVisible: this.resourceVisible.bind(this),
resourceFormatter: this.resourceFormatter.bind(this),
contextMenuPopup: this.contextMenuPopup
});
on(this, this.projectTree, "selection", this._onTreeSelected);
on(this, this.projectTree, "resource-removed", this._onTreeResourceRemoved);
let sourcesBox = this.document.querySelector("#sources > vbox");
sourcesBox.appendChild(this.projectTree.elt);
},
/**
* Set up listeners for commands to dispatch to all of the plugins
*/
_initCommands: function() {
this.projectEditorCommandset = this.document.getElementById("projecteditor-commandset");
this.projectEditorKeyset = this.document.getElementById("projecteditor-keyset");
this.editorCommandset = this.document.getElementById("editMenuCommands");
this.editorKeyset = this.document.getElementById("editMenuKeys");
this.projectEditorCommandset.addEventListener("command", (evt) => {
evt.stopPropagation();
evt.preventDefault();
this.pluginDispatch("onCommand", evt.target.id, evt.target);
});
},
/**
* Initialize each plugin in registeredPlugins
*/
_initPlugins: function() {
this._plugins = [];
for (let plugin of registeredPlugins) {
try {
this._plugins.push(plugin(this));
} catch(ex) {
console.exception(ex);
}
}
this.pluginDispatch("lateInit");
},
/**
* Enable / disable necessary menu items using globalOverlay.js.
*/
_updateMenuItems: function() {
let window = this.editMenu.ownerDocument.defaultView;
let commands = ['cmd_undo', 'cmd_redo', 'cmd_delete', 'cmd_cut', 'cmd_copy', 'cmd_paste'];
commands.forEach(window.goUpdateCommand);
for (let c of this._pluginCommands.keys()) {
window.goUpdateCommand(c);
}
},
/**
* Enable / disable necessary context menu items by passing an event
* onto plugins.
*/
_updateContextMenuItems: function() {
let resource = this.projectTree.getSelectedResource();
this.pluginDispatch("onContextMenuOpen", resource);
},
/**
* Destroy all objects on the iframe unload event.
*/
destroy: function() {
this._destroyed = true;
// If been destroyed before the iframe finished loading, then
// the properties below will not exist.
if (!this._loaded) {
this.iframe.setAttribute("src", "about:blank");
return;
}
// Reset the src for the iframe so if it reused for a new ProjectEditor
// instance, the load will fire properly.
this.window.removeEventListener("unload", this.destroy, false);
this.iframe.setAttribute("src", "about:blank");
this._plugins.forEach(plugin => { plugin.destroy(); });
forget(this, this.projectTree);
this.projectTree.destroy();
this.projectTree = null;
this.shells.destroy();
this.projectEditorCommandset.remove();
this.projectEditorKeyset.remove();
this.editorCommandset.remove();
this.editorKeyset.remove();
this.contextMenuPopup.remove();
this.textEditorContextMenuPopup.remove();
this.editMenu.remove();
this.fileMenu.remove();
this._commandWindow.controllers.removeController(this._commandController);
this._commandController = null;
forget(this, this.project);
this.project.destroy();
this.project = null;
},
/**
* Set the current project viewed by the projecteditor.
*
* @param Project project
* The project to set.
*/
setProject: function(project) {
if (this.project) {
forget(this, this.project);
}
this.project = project;
this.projectTree.setProject(project);
// Whenever a store gets removed, clean up any editors that
// exist for resources within it.
on(this, project, "store-removed", (store) => {
store.allResources().forEach((resource) => {
this.shells.removeResource(resource);
});
});
},
/**
* Set the current project viewed by the projecteditor to a single path,
* used by the app manager.
*
* @param string path
* The file path to set
* @param Object opts
* Custom options used by the project.
* - name: display name for project
* - iconUrl: path to icon for project
* - validationStatus: one of 'unknown|error|warning|valid'
* - projectOverviewURL: path to load for iframe when project
* is selected in the tree.
* @param Promise
* Promise that is resolved once the project is ready to be used.
*/
setProjectToAppPath: function(path, opts = {}) {
this.project.appManagerOpts = opts;
let existingPaths = this.project.allPaths();
if (existingPaths.length !== 1 || existingPaths[0] !== path) {
// Only fully reset if this is a new path.
this.project.removeAllStores();
this.project.addPath(path);
} else {
// Otherwise, just ask for the root to be redrawn
let rootResource = this.project.localStores.get(path).root;
emit(rootResource, "label-change", rootResource);
}
return this.project.refresh();
},
/**
* Open a resource in a particular shell.
*
* @param Resource resource
* The file to be opened.
*/
openResource: function(resource) {
let shell = this.shells.open(resource);
this.projectTree.selectResource(resource);
shell.editor.focus();
},
/**
* When a node is selected in the tree, open its associated editor.
*
* @param Resource resource
* The file that has been selected
*/
_onTreeSelected: function(resource) {
// Don't attempt to open a directory that is not the root element.
if (resource.isDir && resource.parent) {
return;
}
this.pluginDispatch("onTreeSelected", resource);
this.openResource(resource);
},
/**
* When a node is removed, destroy it and its associated editor.
*
* @param Resource resource
* The resource being removed
*/
_onTreeResourceRemoved: function(resource) {
this.shells.removeResource(resource);
},
/**
* Create an xul element with options
*
* @param string type
* The tag name of the element to create.
* @param Object options
* "command": DOMNode or string ID of a command element.
* "parent": DOMNode or selector of parent to append child to.
* anything other keys are set as an attribute as the element.
* @returns DOMElement
* The element that has been created.
*/
createElement: function(type, options) {
let elt = this.document.createElement(type);
let parent;
for (let opt in options) {
if (opt === "command") {
let command = typeof(options.command) === "string" ? options.command : options.command.id;
elt.setAttribute("command", command);
} else if (opt === "parent") {
continue;
} else {
elt.setAttribute(opt, options[opt]);
}
}
if (options.parent) {
let parent = options.parent;
if (typeof(parent) === "string") {
parent = this.document.querySelector(parent);
}
parent.appendChild(elt);
}
return elt;
},
/**
* Create a "menuitem" xul element with options
*
* @param Object options
* See createElement for available options.
* @returns DOMElement
* The menuitem that has been created.
*/
createMenuItem: function(options) {
return this.createElement("menuitem", options);
},
/**
* Add a command to the projecteditor document.
* This method is meant to be used with plugins.
*
* @param Object definition
* key: a key/keycode string. Example: "f".
* id: Unique ID. Example: "find".
* modifiers: Key modifiers. Example: "accel".
* @returns DOMElement
* The command element that has been created.
*/
addCommand: function(plugin, definition) {
this._pluginCommands.set(definition.id, plugin);
let document = this.projectEditorKeyset.ownerDocument;
let command = document.createElement("command");
command.setAttribute("id", definition.id);
if (definition.key) {
let key = document.createElement("key");
key.id = "key_" + definition.id;
let keyName = definition.key;
if (keyName.startsWith("VK_")) {
key.setAttribute("keycode", keyName);
} else {
key.setAttribute("key", keyName);
}
key.setAttribute("modifiers", definition.modifiers);
key.setAttribute("command", definition.id);
this.projectEditorKeyset.appendChild(key);
}
command.setAttribute("oncommand", "void(0);"); // needed. See bug 371900
this.projectEditorCommandset.appendChild(command);
return command;
},
/**
* Get the instance of a plugin registered with a certain type.
*
* @param Type pluginType
* The type, such as SavePlugin
* @returns Plugin
* The plugin instance matching the specified type.
*/
getPlugin: function(pluginType) {
for (let plugin of this.plugins) {
if (plugin.constructor === pluginType) {
return plugin;
}
}
return null;
},
/**
* Get all plugin instances active for the current project
*
* @returns [Plugin]
*/
get plugins() {
if (!this._plugins) {
console.log("plugins requested before _plugins was set");
return [];
}
// Could filter further based on the type of project selected,
// but no need right now.
return this._plugins;
},
/**
* Dispatch an onEditorCreated event, and listen for other events specific
* to this editor instance.
*
* @param Editor editor
* The new editor instance.
*/
_onEditorCreated: function(editor) {
this.pluginDispatch("onEditorCreated", editor);
this._editorListenAndDispatch(editor, "change", "onEditorChange");
this._editorListenAndDispatch(editor, "cursorActivity", "onEditorCursorActivity");
this._editorListenAndDispatch(editor, "load", "onEditorLoad");
this._editorListenAndDispatch(editor, "save", "onEditorSave");
editor.on("focus", () => {
this.projectTree.selectResource(this.resourceFor(editor));
});
},
/**
* Dispatch an onEditorActivated event and finish setting up once the
* editor is ready to use.
*
* @param Editor editor
* The editor instance, which is now appended in the document.
* @param Resource resource
* The resource used by the editor
*/
_onEditorActivated: function(editor, resource) {
editor.setToolbarVisibility();
this.pluginDispatch("onEditorActivated", editor, resource);
},
/**
* Dispatch an onEditorDactivated event once an editor loses focus
*
* @param Editor editor
* The editor instance, which is no longer active.
* @param Resource resource
* The resource used by the editor
*/
_onEditorDeactivated: function(editor, resource) {
this.pluginDispatch("onEditorDeactivated", editor, resource);
},
/**
* Call a method on all plugins that implement the method.
* Also emits the same handler name on `this`.
*
* @param string handler
* Which function name to call on plugins.
* @param ...args args
* All remaining parameters are passed into the handler.
*/
pluginDispatch: function(handler, ...args) {
emit(this, handler, ...args);
this.plugins.forEach(plugin => {
try {
if (handler in plugin) plugin[handler](...args);
} catch(ex) {
console.error(ex);
}
})
},
/**
* Listen to an event on the editor object and dispatch it
* to all plugins that implement the associated method
*
* @param Editor editor
* Which editor to listen to
* @param string event
* Which editor event to listen for
* @param string handler
* Which plugin method to call
*/
_editorListenAndDispatch: function(editor, event, handler) {
editor.on(event, (...args) => {
this.pluginDispatch(handler, editor, this.resourceFor(editor), ...args);
});
},
/**
* Find a shell for a resource.
*
* @param Resource resource
* The file to be opened.
* @returns Shell
*/
shellFor: function(resource) {
return this.shells.shellFor(resource);
},
/**
* Returns the Editor for a given resource.
*
* @param Resource resource
* The file to check.
* @returns Editor
* Instance of the editor for this file.
*/
editorFor: function(resource) {
let shell = this.shellFor(resource);
return shell ? shell.editor : shell;
},
/**
* Returns a resource for the given editor
*
* @param Editor editor
* The editor to check
* @returns Resource
* The resource associated with this editor
*/
resourceFor: function(editor) {
if (editor && editor.shell && editor.shell.resource) {
return editor.shell.resource;
}
return null;
},
/**
* Decide whether a given resource should be hidden in the tree.
*
* @param Resource resource
* The resource in the tree
* @returns Boolean
* True if the node should be visible, false if hidden.
*/
resourceVisible: function(resource) {
return true;
},
/**
* Format the given node for display in the resource tree view.
*
* @param Resource resource
* The file to be opened.
* @param DOMNode elt
* The element in the tree to render into.
*/
resourceFormatter: function(resource, elt) {
let editor = this.editorFor(resource);
let renderedByPlugin = false;
// Allow plugins to override default templating of resource in tree.
this.plugins.forEach(plugin => {
if (!plugin.onAnnotate) {
return;
}
if (plugin.onAnnotate(resource, editor, elt)) {
renderedByPlugin = true;
}
});
// If no plugin wants to handle it, just use a string from the resource.
if (!renderedByPlugin) {
elt.textContent = resource.displayName;
}
},
get sourcesVisible() {
return this.sourceToggle.hasAttribute("pane-collapsed");
},
get currentShell() {
return this.shells.currentShell;
},
get currentEditor() {
return this.shells.currentEditor;
},
/**
* Whether or not menu items should be able to be enabled.
* Note that even if this is true, certain menu items will not be
* enabled until the correct state is achieved (for instance, the
* 'copy' menu item is only enabled when there is a selection).
* But if this is false, then nothing will be enabled.
*/
set menuEnabled(val) {
this._menuEnabled = val;
if (this._loaded) {
this._updateMenuItems();
}
},
get menuEnabled() {
return this._menuEnabled;
},
/**
* Are there any unsaved resources in the Project?
*/
get hasUnsavedResources() {
return this.project.allResources().some(resource=> {
let editor = this.editorFor(resource);
return editor && !editor.isClean();
});
},
/**
* Check with the user about navigating away with unsaved changes.
*
* @returns Boolean
* True if there are no unsaved changes
* Otherwise, ask the user to confirm and return the outcome.
*/
confirmUnsaved: function() {
if (this.hasUnsavedResources) {
return confirm(
getLocalizedString("projecteditor.confirmUnsavedTitle"),
getLocalizedString("projecteditor.confirmUnsavedLabel")
);
}
return true;
}
});
/**
* Returns a controller object that can be used for
* editor-specific commands such as find, jump to line,
* copy/paste, etc.
*/
function getCommandController(host) {
return {
supportsCommand: function (cmd) {
return host._pluginCommands.get(cmd);
},
isCommandEnabled: function (cmd) {
if (!host.menuEnabled) {
return false;
}
let plugin = host._pluginCommands.get(cmd);
if (plugin && plugin.isCommandEnabled) {
return plugin.isCommandEnabled(cmd);
}
return true;
},
doCommand: function(cmd) {
}
};
}
exports.ProjectEditor = ProjectEditor;