diff options
Diffstat (limited to 'devtools/client/projecteditor/lib/projecteditor.js')
-rw-r--r-- | devtools/client/projecteditor/lib/projecteditor.js | 816 |
1 files changed, 816 insertions, 0 deletions
diff --git a/devtools/client/projecteditor/lib/projecteditor.js b/devtools/client/projecteditor/lib/projecteditor.js new file mode 100644 index 000000000..a3ef06249 --- /dev/null +++ b/devtools/client/projecteditor/lib/projecteditor.js @@ -0,0 +1,816 @@ +/* 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/. */ + +"use strict"; + +const { Cc, Ci, Cu } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +const { Project } = require("devtools/client/projecteditor/lib/project"); +const { ProjectTreeView } = require("devtools/client/projecteditor/lib/tree"); +const { ShellDeck } = require("devtools/client/projecteditor/lib/shells"); +const { Resource } = require("devtools/client/projecteditor/lib/stores/resource"); +const { registeredPlugins } = require("devtools/client/projecteditor/lib/plugins/core"); +const { EventTarget } = require("sdk/event/target"); +const { on, forget } = require("devtools/client/projecteditor/lib/helpers/event"); +const { emit } = require("sdk/event/core"); +const { merge } = require("sdk/util/object"); +const promise = require("promise"); +const { ViewHelpers } = require("devtools/client/shared/widgets/view-helpers"); +const { DOMHelpers } = require("resource://devtools/client/shared/DOMHelpers.jsm"); +const Services = require("Services"); +const { Task } = require("devtools/shared/task"); +const ITCHPAD_URL = "chrome://devtools/content/projecteditor/chrome/content/projecteditor.xul"; +const { confirm } = require("devtools/client/projecteditor/lib/helpers/prompts"); +const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n"); + +// Enabled Plugins +require("devtools/client/projecteditor/lib/plugins/dirty/dirty"); +require("devtools/client/projecteditor/lib/plugins/delete/delete"); +require("devtools/client/projecteditor/lib/plugins/new/new"); +require("devtools/client/projecteditor/lib/plugins/rename/rename"); +require("devtools/client/projecteditor/lib/plugins/save/save"); +require("devtools/client/projecteditor/lib/plugins/image-view/plugin"); +require("devtools/client/projecteditor/lib/plugins/app-manager/plugin"); +require("devtools/client/projecteditor/lib/plugins/status-bar/plugin"); + +// Uncomment to enable logging. +// require("devtools/client/projecteditor/lib/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, "saveRequested", "onEditorSaveRequested"); + 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.classList.contains("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.confirmUnsavedLabel2") + ); + } + + return true; + }, + + /** + * Save all the changes in source files. + * + * @returns Boolean + * True if there were resources to save. + */ + saveAllFiles: Task.async(function* () { + if (this.hasUnsavedResources) { + for (let resource of this.project.allResources()) { + let editor = this.editorFor(resource); + if (editor && !editor.isClean()) { + yield editor.save(resource); + } + } + + return true; + } + + return false; + }) + +}); + + +/** + * 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; |