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