summaryrefslogtreecommitdiffstats
path: root/devtools/client/projecteditor
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /devtools/client/projecteditor
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/client/projecteditor')
-rw-r--r--devtools/client/projecteditor/chrome/content/projecteditor-loader.js176
-rw-r--r--devtools/client/projecteditor/chrome/content/projecteditor-loader.xul26
-rw-r--r--devtools/client/projecteditor/chrome/content/projecteditor-test.xul18
-rw-r--r--devtools/client/projecteditor/chrome/content/projecteditor.xul87
-rw-r--r--devtools/client/projecteditor/lib/editors.js303
-rw-r--r--devtools/client/projecteditor/lib/helpers/event.js86
-rw-r--r--devtools/client/projecteditor/lib/helpers/file-picker.js116
-rw-r--r--devtools/client/projecteditor/lib/helpers/l10n.js26
-rw-r--r--devtools/client/projecteditor/lib/helpers/moz.build12
-rw-r--r--devtools/client/projecteditor/lib/helpers/prompts.js33
-rw-r--r--devtools/client/projecteditor/lib/helpers/readdir.js89
-rw-r--r--devtools/client/projecteditor/lib/moz.build19
-rw-r--r--devtools/client/projecteditor/lib/plugins/app-manager/app-project-editor.js56
-rw-r--r--devtools/client/projecteditor/lib/plugins/app-manager/moz.build10
-rw-r--r--devtools/client/projecteditor/lib/plugins/app-manager/plugin.js77
-rw-r--r--devtools/client/projecteditor/lib/plugins/core.js83
-rw-r--r--devtools/client/projecteditor/lib/plugins/delete/delete.js67
-rw-r--r--devtools/client/projecteditor/lib/plugins/delete/moz.build9
-rw-r--r--devtools/client/projecteditor/lib/plugins/dirty/dirty.js47
-rw-r--r--devtools/client/projecteditor/lib/plugins/dirty/moz.build9
-rw-r--r--devtools/client/projecteditor/lib/plugins/image-view/image-editor.js50
-rw-r--r--devtools/client/projecteditor/lib/plugins/image-view/moz.build10
-rw-r--r--devtools/client/projecteditor/lib/plugins/image-view/plugin.js28
-rw-r--r--devtools/client/projecteditor/lib/plugins/logging/logging.js29
-rw-r--r--devtools/client/projecteditor/lib/plugins/logging/moz.build9
-rw-r--r--devtools/client/projecteditor/lib/plugins/moz.build21
-rw-r--r--devtools/client/projecteditor/lib/plugins/new/moz.build9
-rw-r--r--devtools/client/projecteditor/lib/plugins/new/new.js80
-rw-r--r--devtools/client/projecteditor/lib/plugins/rename/moz.build9
-rw-r--r--devtools/client/projecteditor/lib/plugins/rename/rename.js74
-rw-r--r--devtools/client/projecteditor/lib/plugins/save/moz.build9
-rw-r--r--devtools/client/projecteditor/lib/plugins/save/save.js93
-rw-r--r--devtools/client/projecteditor/lib/plugins/status-bar/moz.build9
-rw-r--r--devtools/client/projecteditor/lib/plugins/status-bar/plugin.js105
-rw-r--r--devtools/client/projecteditor/lib/project.js246
-rw-r--r--devtools/client/projecteditor/lib/projecteditor.js816
-rw-r--r--devtools/client/projecteditor/lib/shells.js243
-rw-r--r--devtools/client/projecteditor/lib/stores/base.js58
-rw-r--r--devtools/client/projecteditor/lib/stores/local.js215
-rw-r--r--devtools/client/projecteditor/lib/stores/moz.build11
-rw-r--r--devtools/client/projecteditor/lib/stores/resource.js398
-rw-r--r--devtools/client/projecteditor/lib/tree.js593
-rw-r--r--devtools/client/projecteditor/moz.build9
-rw-r--r--devtools/client/projecteditor/test/.eslintrc.js6
-rw-r--r--devtools/client/projecteditor/test/browser.ini31
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_app_options.js87
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_confirm_unsaved.js60
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_contextmenu_01.js27
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_contextmenu_02.js66
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_delete_file.js85
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_editing_01.js70
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_editors_image.js74
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_external_change.js84
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_immediate_destroy.js93
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_init.js18
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_menubar_01.js28
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_menubar_02.js123
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_new_file.js13
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_rename_file_01.js19
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_rename_file_02.js26
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_saveall.js64
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_stores.js16
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_tree_selection_01.js98
-rw-r--r--devtools/client/projecteditor/test/browser_projecteditor_tree_selection_02.js76
-rw-r--r--devtools/client/projecteditor/test/head.js391
-rw-r--r--devtools/client/projecteditor/test/helper_edits.js53
-rw-r--r--devtools/client/projecteditor/test/helper_homepage.html1
67 files changed, 6082 insertions, 0 deletions
diff --git a/devtools/client/projecteditor/chrome/content/projecteditor-loader.js b/devtools/client/projecteditor/chrome/content/projecteditor-loader.js
new file mode 100644
index 000000000..adee8f143
--- /dev/null
+++ b/devtools/client/projecteditor/chrome/content/projecteditor-loader.js
@@ -0,0 +1,176 @@
+var Cu = Components.utils;
+const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {});
+const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm", {});
+const {NetUtil} = Cu.import("resource://gre/modules/NetUtil.jsm", {});
+const promise = require("promise");
+const ProjectEditor = require("devtools/client/projecteditor/lib/projecteditor");
+
+const SAMPLE_PATH = buildTempDirectoryStructure();
+const SAMPLE_NAME = "DevTools Content Application Name";
+const SAMPLE_PROJECT_URL = "data:text/html;charset=utf-8,<body><h1>Project Overview</h1></body>";
+const SAMPLE_ICON = "chrome://devtools/skin/images/tool-debugger.svg";
+
+/**
+ * Create a workspace for working on projecteditor, available at
+ * chrome://devtools/content/projecteditor/chrome/content/projecteditor-loader.xul.
+ * This emulates the integration points that the app manager uses.
+ */
+var appManagerEditor;
+
+// Log a message to the project overview URL to make development easier
+function log(msg) {
+ if (!appManagerEditor) {
+ return;
+ }
+
+ let doc = appManagerEditor.iframe.contentDocument;
+ let el = doc.createElement("p");
+ el.textContent = msg;
+ doc.body.appendChild(el);
+}
+
+document.addEventListener("DOMContentLoaded", function onDOMReady(e) {
+ document.removeEventListener("DOMContentLoaded", onDOMReady, false);
+ let iframe = document.getElementById("projecteditor-iframe");
+ window.projecteditor = ProjectEditor.ProjectEditor(iframe);
+
+ projecteditor.on("onEditorCreated", (editor, a) => {
+ log("editor created: " + editor);
+ if (editor.label === "app-manager") {
+ appManagerEditor = editor;
+ appManagerEditor.on("load", function foo() {
+ appManagerEditor.off("load", foo);
+ log("Working on: " + SAMPLE_PATH);
+ });
+ }
+ });
+ projecteditor.on("onEditorDestroyed", (editor) => {
+ log("editor destroyed: " + editor);
+ });
+ projecteditor.on("onEditorSave", (editor, resource) => {
+ log("editor saved: " + editor, resource.path);
+ });
+ projecteditor.on("onTreeSelected", (resource) => {
+ log("tree selected: " + resource.path);
+ });
+ projecteditor.on("onEditorLoad", (editor) => {
+ log("editor loaded: " + editor);
+ });
+ projecteditor.on("onEditorActivated", (editor) => {
+ log("editor focused: " + editor);
+ });
+ projecteditor.on("onEditorDeactivated", (editor) => {
+ log("editor blur: " + editor);
+ });
+ projecteditor.on("onEditorChange", (editor) => {
+ log("editor changed: " + editor);
+ });
+ projecteditor.on("onCommand", (cmd) => {
+ log("Command: " + cmd);
+ });
+
+ projecteditor.loaded.then(() => {
+ projecteditor.setProjectToAppPath(SAMPLE_PATH, {
+ name: SAMPLE_NAME,
+ iconUrl: SAMPLE_ICON,
+ projectOverviewURL: SAMPLE_PROJECT_URL,
+ validationStatus: "valid"
+ }).then(() => {
+ let allResources = projecteditor.project.allResources();
+ console.log("All resources have been loaded", allResources, allResources.map(r=>r.basename).join("|"));
+ });
+
+ });
+
+}, false);
+
+/**
+ * Build a temporary directory as a workspace for this loader
+ * https://developer.mozilla.org/en-US/Add-ons/Code_snippets/File_I_O
+ */
+function buildTempDirectoryStructure() {
+
+ // First create (and remove) the temp dir to discard any changes
+ let TEMP_DIR = FileUtils.getDir("TmpD", ["ProjectEditor"], true);
+ TEMP_DIR.remove(true);
+
+ // Now rebuild our fake project.
+ TEMP_DIR = FileUtils.getDir("TmpD", ["ProjectEditor"], true);
+
+ FileUtils.getDir("TmpD", ["ProjectEditor", "css"], true);
+ FileUtils.getDir("TmpD", ["ProjectEditor", "data"], true);
+ FileUtils.getDir("TmpD", ["ProjectEditor", "img", "icons"], true);
+ FileUtils.getDir("TmpD", ["ProjectEditor", "js"], true);
+
+ let htmlFile = FileUtils.getFile("TmpD", ["ProjectEditor", "index.html"]);
+ htmlFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ writeToFile(htmlFile, [
+ "<!DOCTYPE html>",
+ '<html lang="en">',
+ " <head>",
+ ' <meta charset="utf-8" />',
+ " <title>ProjectEditor Temp File</title>",
+ ' <link rel="stylesheet" href="style.css" />',
+ " </head>",
+ ' <body id="home">',
+ " <p>ProjectEditor Temp File</p>",
+ " </body>",
+ "</html>"].join("\n")
+ );
+
+ let readmeFile = FileUtils.getFile("TmpD", ["ProjectEditor", "README.md"]);
+ readmeFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ writeToFile(readmeFile, [
+ "## Readme"
+ ].join("\n")
+ );
+
+ let licenseFile = FileUtils.getFile("TmpD", ["ProjectEditor", "LICENSE"]);
+ licenseFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ writeToFile(licenseFile, [
+ "/* 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/. */"
+ ].join("\n")
+ );
+
+ let cssFile = FileUtils.getFile("TmpD", ["ProjectEditor", "css", "styles.css"]);
+ cssFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ writeToFile(cssFile, [
+ "body {",
+ " background: red;",
+ "}"
+ ].join("\n")
+ );
+
+ FileUtils.getFile("TmpD", ["ProjectEditor", "js", "script.js"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+
+ FileUtils.getFile("TmpD", ["ProjectEditor", "img", "fake.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ FileUtils.getFile("TmpD", ["ProjectEditor", "img", "icons", "16x16.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ FileUtils.getFile("TmpD", ["ProjectEditor", "img", "icons", "32x32.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ FileUtils.getFile("TmpD", ["ProjectEditor", "img", "icons", "128x128.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ FileUtils.getFile("TmpD", ["ProjectEditor", "img", "icons", "vector.svg"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+
+ return TEMP_DIR.path;
+}
+
+
+// https://developer.mozilla.org/en-US/Add-ons/Code_snippets/File_I_O#Writing_to_a_file
+function writeToFile(file, data) {
+
+ let defer = promise.defer();
+ var ostream = FileUtils.openSafeFileOutputStream(file);
+
+ var converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"].
+ createInstance(Components.interfaces.nsIScriptableUnicodeConverter);
+ converter.charset = "UTF-8";
+ var istream = converter.convertToInputStream(data);
+
+ // The last argument (the callback) is optional.
+ NetUtil.asyncCopy(istream, ostream, function (status) {
+ if (!Components.isSuccessCode(status)) {
+ // Handle error!
+ console.log("ERROR WRITING TEMP FILE", status);
+ }
+ });
+}
diff --git a/devtools/client/projecteditor/chrome/content/projecteditor-loader.xul b/devtools/client/projecteditor/chrome/content/projecteditor-loader.xul
new file mode 100644
index 000000000..84db8ea48
--- /dev/null
+++ b/devtools/client/projecteditor/chrome/content/projecteditor-loader.xul
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+<!DOCTYPE window [
+<!ENTITY % toolboxDTD SYSTEM "chrome://devtools/locale/toolbox.dtd" >
+ %toolboxDTD;
+]>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+<script type="application/javascript;version=1.8" src="projecteditor-loader.js"></script>
+
+ <commandset id="toolbox-commandset">
+ <command id="projecteditor-cmd-close" oncommand="window.close();"/>
+ </commandset>
+
+ <keyset id="projecteditor-keyset">
+ <key id="projecteditor-key-close"
+ key="&closeCmd.key;"
+ command="projecteditor-cmd-close"
+ modifiers="accel"/>
+ </keyset>
+
+ <iframe id="projecteditor-iframe" flex="1" forceOwnRefreshDriver=""></iframe>
+</window>
diff --git a/devtools/client/projecteditor/chrome/content/projecteditor-test.xul b/devtools/client/projecteditor/chrome/content/projecteditor-test.xul
new file mode 100644
index 000000000..ee2be12f0
--- /dev/null
+++ b/devtools/client/projecteditor/chrome/content/projecteditor-test.xul
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+<?xul-overlay href="chrome://global/content/editMenuOverlay.xul"?>
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml">
+
+ <script type="application/javascript" src="chrome://global/content/globalOverlay.js"></script>
+
+ <commandset id="mainCommandSet">
+ <commandset id="editMenuCommands"/>
+ </commandset>
+ <menubar></menubar>
+ <iframe id='projecteditor-iframe' flex="1"></iframe>
+</window>
diff --git a/devtools/client/projecteditor/chrome/content/projecteditor.xul b/devtools/client/projecteditor/chrome/content/projecteditor.xul
new file mode 100644
index 000000000..795fe9fab
--- /dev/null
+++ b/devtools/client/projecteditor/chrome/content/projecteditor.xul
@@ -0,0 +1,87 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+<?xml-stylesheet href="chrome://devtools/skin/light-theme.css" type="text/css"?>
+<?xml-stylesheet href="chrome://devtools/skin/projecteditor/projecteditor.css" type="text/css"?>
+<?xml-stylesheet href="chrome://devtools/content/debugger/debugger.css" type="text/css"?>
+<?xml-stylesheet href="resource://devtools/client/themes/common.css" type="text/css"?>
+<?xml-stylesheet href="chrome://devtools/skin/markup.css" type="text/css"?>
+
+<?xul-overlay href="chrome://global/content/editMenuOverlay.xul"?>
+
+<!DOCTYPE window [
+<!ENTITY % scratchpadDTD SYSTEM "chrome://devtools/locale/scratchpad.dtd" >
+ %scratchpadDTD;
+<!ENTITY % editMenuStrings SYSTEM "chrome://global/locale/editMenuOverlay.dtd">
+%editMenuStrings;
+<!ENTITY % sourceEditorStrings SYSTEM "chrome://devtools/locale/sourceeditor.dtd">
+%sourceEditorStrings;
+]>
+
+<page xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" class="theme-body theme-light">
+
+ <script type="application/javascript" src="chrome://global/content/globalOverlay.js"/>
+
+ <commandset id="projecteditor-commandset" />
+ <commandset id="editMenuCommands"/>
+ <keyset id="projecteditor-keyset" />
+ <keyset id="editMenuKeys"/>
+
+ <!-- Eventually we want to let plugins declare their own menu items.
+ Wait unti app manager lands to deal with this integration point.
+ -->
+ <menubar id="projecteditor-menubar">
+ <menu id="file-menu" label="&fileMenu.label;" accesskey="&fileMenu.accesskey;">
+ <menupopup id="file-menu-popup" />
+ </menu>
+
+ <menu id="edit-menu" label="&editMenu.label;"
+ accesskey="&editMenu.accesskey;">
+ <menupopup id="edit-menu-popup">
+ <menuitem id="menu_undo"/>
+ <menuitem id="menu_redo"/>
+ <menuseparator/>
+ <menuitem id="menu_cut"/>
+ <menuitem id="menu_copy"/>
+ <menuitem id="menu_paste"/>
+ </menupopup>
+ </menu>
+ </menubar>
+
+ <popupset>
+ <menupopup id="context-menu-popup">
+ </menupopup>
+ <menupopup id="texteditor-context-popup">
+ <menuitem id="cMenu_cut"/>
+ <menuitem id="cMenu_copy"/>
+ <menuitem id="cMenu_paste"/>
+ <menuitem id="cMenu_delete"/>
+ <menuseparator/>
+ <menuitem id="cMenu_selectAll"/>
+ </menupopup>
+ </popupset>
+
+ <deck id="main-deck" flex="1">
+ <vbox flex="1" id="source-deckitem">
+ <hbox id="sources-body" flex="1">
+ <vbox width="250" id="sources">
+ <vbox flex="1">
+ </vbox>
+ <toolbar id="project-toolbar" class="devtools-toolbar" hidden="true"></toolbar>
+ </vbox>
+ <splitter id="source-editor-splitter" class="devtools-side-splitter"/>
+ <vbox id="shells" flex="4">
+ <toolbar id="projecteditor-toolbar" class="devtools-toolbar">
+ <hbox id="plugin-toolbar-left"/>
+ <spacer flex="1"/>
+ <hbox id="plugin-toolbar-right"/>
+ </toolbar>
+ <box id="shells-deck-container" flex="4"></box>
+ <toolbar id="projecteditor-toolbar-bottom" class="devtools-toolbar">
+ </toolbar>
+ </vbox>
+ </hbox>
+ </vbox>
+ </deck>
+</page>
diff --git a/devtools/client/projecteditor/lib/editors.js b/devtools/client/projecteditor/lib/editors.js
new file mode 100644
index 000000000..7d0150cf7
--- /dev/null
+++ b/devtools/client/projecteditor/lib/editors.js
@@ -0,0 +1,303 @@
+/* -*- 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 { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const { EventTarget } = require("sdk/event/target");
+const { emit } = require("sdk/event/core");
+const promise = require("promise");
+const Editor = require("devtools/client/sourceeditor/editor");
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+/**
+ * ItchEditor is extended to implement an editor, which is the main view
+ * that shows up when a file is selected. This object should not be used
+ * directly - use TextEditor for a basic code editor.
+ */
+var ItchEditor = Class({
+ extends: EventTarget,
+
+ /**
+ * A boolean specifying if the toolbar above the editor should be hidden.
+ */
+ hidesToolbar: false,
+
+ /**
+ * A boolean specifying whether the editor can be edited / saved.
+ * For instance, a 'save' doesn't make sense on an image.
+ */
+ isEditable: false,
+
+ toString: function () {
+ return this.label || "";
+ },
+
+ emit: function (name, ...args) {
+ emit(this, name, ...args);
+ },
+
+ /* Does the editor not have any unsaved changes? */
+ isClean: function () {
+ return true;
+ },
+
+ /**
+ * Initialize the editor with a single host. This should be called
+ * by objects extending this object with:
+ * ItchEditor.prototype.initialize.apply(this, arguments)
+ */
+ initialize: function (host) {
+ this.host = host;
+ this.doc = host.document;
+ this.label = "";
+ this.elt = this.doc.createElement("vbox");
+ this.elt.setAttribute("flex", "1");
+ this.elt.editor = this;
+ this.toolbar = this.doc.querySelector("#projecteditor-toolbar");
+ this.projectEditorKeyset = host.projectEditorKeyset;
+ this.projectEditorCommandset = host.projectEditorCommandset;
+ },
+
+ /**
+ * Sets the visibility of the element that shows up above the editor
+ * based on the this.hidesToolbar property.
+ */
+ setToolbarVisibility: function () {
+ if (this.hidesToolbar) {
+ this.toolbar.setAttribute("hidden", "true");
+ } else {
+ this.toolbar.removeAttribute("hidden");
+ }
+ },
+
+
+ /**
+ * Load a single resource into the editor.
+ *
+ * @param Resource resource
+ * The single file / item that is being dealt with (see stores/base)
+ * @returns Promise
+ * A promise that is resolved once the editor has loaded the contents
+ * of the resource.
+ */
+ load: function (resource) {
+ return promise.resolve();
+ },
+
+ /**
+ * Clean up the editor. This can have different meanings
+ * depending on the type of editor.
+ */
+ destroy: function () {
+
+ },
+
+ /**
+ * Give focus to the editor. This can have different meanings
+ * depending on the type of editor.
+ *
+ * @returns Promise
+ * A promise that is resolved once the editor has been focused.
+ */
+ focus: function () {
+ return promise.resolve();
+ }
+});
+exports.ItchEditor = ItchEditor;
+
+/**
+ * The main implementation of the ItchEditor class. The TextEditor is used
+ * when editing any sort of plain text file, and can be created with different
+ * modes for syntax highlighting depending on the language.
+ */
+var TextEditor = Class({
+ extends: ItchEditor,
+
+ isEditable: true,
+
+ /**
+ * Extra keyboard shortcuts to use with the editor. Shortcuts defined
+ * within projecteditor should be triggered when they happen in the editor, and
+ * they would usually be swallowed without registering them.
+ * See "devtools/sourceeditor/editor" for more information.
+ */
+ get extraKeys() {
+ let extraKeys = {};
+
+ // Copy all of the registered keys into extraKeys object, to notify CodeMirror
+ // that it should be ignoring these keys
+ [...this.projectEditorKeyset.querySelectorAll("key")].forEach((key) => {
+ let keyUpper = key.getAttribute("key").toUpperCase();
+ let toolModifiers = key.getAttribute("modifiers");
+ let modifiers = {
+ alt: toolModifiers.includes("alt"),
+ shift: toolModifiers.includes("shift")
+ };
+
+ // On the key press, we will dispatch the event within projecteditor.
+ extraKeys[Editor.accel(keyUpper, modifiers)] = () => {
+ let doc = this.projectEditorCommandset.ownerDocument;
+ let event = doc.createEvent("Event");
+ event.initEvent("command", true, true);
+ let command = this.projectEditorCommandset.querySelector("#" + key.getAttribute("command"));
+ command.dispatchEvent(event);
+ };
+ });
+
+ return extraKeys;
+ },
+
+ isClean: function () {
+ if (!this.editor.isAppended()) {
+ return true;
+ }
+ return this.editor.getText() === this._savedResourceContents;
+ },
+
+ initialize: function (document, mode = Editor.modes.text) {
+ ItchEditor.prototype.initialize.apply(this, arguments);
+ this.label = mode.name;
+ this.editor = new Editor({
+ mode: mode,
+ lineNumbers: true,
+ extraKeys: this.extraKeys,
+ themeSwitching: false,
+ autocomplete: true,
+ contextMenu: this.host.textEditorContextMenuPopup
+ });
+
+ // Trigger a few editor specific events on `this`.
+ this.editor.on("change", (...args) => {
+ this.emit("change", ...args);
+ });
+ this.editor.on("cursorActivity", (...args) => {
+ this.emit("cursorActivity", ...args);
+ });
+ this.editor.on("focus", (...args) => {
+ this.emit("focus", ...args);
+ });
+ this.editor.on("saveRequested", (...args) => {
+ this.emit("saveRequested", ...args);
+ });
+
+ this.appended = this.editor.appendTo(this.elt);
+ },
+
+ /**
+ * Clean up the editor. This can have different meanings
+ * depending on the type of editor.
+ */
+ destroy: function () {
+ this.editor.destroy();
+ this.editor = null;
+ },
+
+ /**
+ * Load a single resource into the text editor.
+ *
+ * @param Resource resource
+ * The single file / item that is being dealt with (see stores/base)
+ * @returns Promise
+ * A promise that is resolved once the text editor has loaded the
+ * contents of the resource.
+ */
+ load: function (resource) {
+ // Wait for the editor.appendTo and resource.load before proceeding.
+ // They can run in parallel.
+ return promise.all([
+ resource.load(),
+ this.appended
+ ]).then(([resourceContents])=> {
+ if (!this.editor) {
+ return;
+ }
+ this._savedResourceContents = resourceContents;
+ this.editor.setText(resourceContents);
+ this.editor.clearHistory();
+ this.editor.setClean();
+ this.emit("load");
+ }, console.error);
+ },
+
+ /**
+ * Save the resource based on the current state of the editor
+ *
+ * @param Resource resource
+ * The single file / item to be saved
+ * @returns Promise
+ * A promise that is resolved once the resource has been
+ * saved.
+ */
+ save: function (resource) {
+ let newText = this.editor.getText();
+ return resource.save(newText).then(() => {
+ this._savedResourceContents = newText;
+ this.emit("save", resource);
+ });
+ },
+
+ /**
+ * Give focus to the code editor.
+ *
+ * @returns Promise
+ * A promise that is resolved once the editor has been focused.
+ */
+ focus: function () {
+ return this.appended.then(() => {
+ if (this.editor) {
+ this.editor.focus();
+ }
+ });
+ }
+});
+
+/**
+ * Wrapper for TextEditor using JavaScript syntax highlighting.
+ */
+function JSEditor(host) {
+ return TextEditor(host, Editor.modes.js);
+}
+
+/**
+ * Wrapper for TextEditor using CSS syntax highlighting.
+ */
+function CSSEditor(host) {
+ return TextEditor(host, Editor.modes.css);
+}
+
+/**
+ * Wrapper for TextEditor using HTML syntax highlighting.
+ */
+function HTMLEditor(host) {
+ return TextEditor(host, Editor.modes.html);
+}
+
+/**
+ * Get the type of editor that can handle a particular resource.
+ * @param Resource resource
+ * The single file that is going to be opened.
+ * @returns Type:Editor
+ * The type of editor that can handle this resource. The
+ * return value is a constructor function.
+ */
+function EditorTypeForResource(resource) {
+ const categoryMap = {
+ "txt": TextEditor,
+ "html": HTMLEditor,
+ "xml": HTMLEditor,
+ "css": CSSEditor,
+ "js": JSEditor,
+ "json": JSEditor
+ };
+ return categoryMap[resource.contentCategory] || TextEditor;
+}
+
+exports.TextEditor = TextEditor;
+exports.JSEditor = JSEditor;
+exports.CSSEditor = CSSEditor;
+exports.HTMLEditor = HTMLEditor;
+exports.EditorTypeForResource = EditorTypeForResource;
diff --git a/devtools/client/projecteditor/lib/helpers/event.js b/devtools/client/projecteditor/lib/helpers/event.js
new file mode 100644
index 000000000..74b4adb04
--- /dev/null
+++ b/devtools/client/projecteditor/lib/helpers/event.js
@@ -0,0 +1,86 @@
+/* -*- 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/. */
+
+/**
+ * This file wraps EventEmitter objects to provide functions to forget
+ * all events bound on a certain object.
+ */
+
+const { Class } = require("sdk/core/heritage");
+
+/**
+ * The Scope object is used to keep track of listeners.
+ * This object is not exported.
+ */
+var Scope = Class({
+ on: function (target, event, handler) {
+ this.listeners = this.listeners || [];
+ this.listeners.push({
+ target: target,
+ event: event,
+ handler: handler
+ });
+ target.on(event, handler);
+ },
+
+ off: function (t, e, h) {
+ if (!this.listeners) return;
+ this.listeners = this.listeners.filter(({ target, event, handler }) => {
+ return !(target === t && event === e && handler === h);
+ });
+ target.off(event, handler);
+ },
+
+ clear: function (clearTarget) {
+ if (!this.listeners) return;
+ this.listeners = this.listeners.filter(({ target, event, handler }) => {
+ if (target === clearTarget) {
+ target.off(event, handler);
+ return false;
+ }
+ return true;
+ });
+ },
+
+ destroy: function () {
+ if (!this.listeners) return;
+ this.listeners.forEach(({ target, event, handler }) => {
+ target.off(event, handler);
+ });
+ this.listeners = undefined;
+ }
+});
+
+var scopes = new WeakMap();
+function scope(owner) {
+ if (!scopes.has(owner)) {
+ let scope = new Scope(owner);
+ scopes.set(owner, scope);
+ return scope;
+ }
+ return scopes.get(owner);
+}
+exports.scope = scope;
+
+exports.on = function on(owner, target, event, handler) {
+ if (!target) return;
+ scope(owner).on(target, event, handler);
+};
+
+exports.off = function off(owner, target, event, handler) {
+ if (!target) return;
+ scope(owner).off(target, event, handler);
+};
+
+exports.forget = function forget(owner, target) {
+ scope(owner).clear(target);
+};
+
+exports.done = function done(owner) {
+ scope(owner).destroy();
+ scopes.delete(owner);
+};
+
diff --git a/devtools/client/projecteditor/lib/helpers/file-picker.js b/devtools/client/projecteditor/lib/helpers/file-picker.js
new file mode 100644
index 000000000..1dab0f001
--- /dev/null
+++ b/devtools/client/projecteditor/lib/helpers/file-picker.js
@@ -0,0 +1,116 @@
+/* -*- 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/. */
+
+/**
+ * This file contains helper functions for showing OS-specific
+ * file and folder pickers.
+ */
+
+const { Cu, Cc, Ci } = require("chrome");
+const { FileUtils } = Cu.import("resource://gre/modules/FileUtils.jsm", {});
+const promise = require("promise");
+const { merge } = require("sdk/util/object");
+const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n");
+
+/**
+ * Show a file / folder picker.
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIFilePicker
+ *
+ * @param object options
+ * Additional options for setting the source. Supported options:
+ * - directory: string, The path to default opening
+ * - defaultName: string, The filename including extension that
+ * should be suggested to the user as a default
+ * - window: DOMWindow, The filename including extension that
+ * should be suggested to the user as a default
+ * - title: string, The filename including extension that
+ * should be suggested to the user as a default
+ * - mode: int, The type of picker to open.
+ *
+ * @return promise
+ * A promise that is resolved with the full path
+ * after the file has been picked.
+ */
+function showPicker(options) {
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ if (options.directory) {
+ try {
+ fp.displayDirectory = FileUtils.File(options.directory);
+ } catch (ex) {
+ console.warn(ex);
+ }
+ }
+
+ if (options.defaultName) {
+ fp.defaultString = options.defaultName;
+ }
+
+ fp.init(options.window, options.title, options.mode);
+ let deferred = promise.defer();
+ fp.open({
+ done: function (res) {
+ if (res === Ci.nsIFilePicker.returnOK || res === Ci.nsIFilePicker.returnReplace) {
+ deferred.resolve(fp.file.path);
+ } else {
+ deferred.reject();
+ }
+ }
+ });
+ return deferred.promise;
+}
+exports.showPicker = showPicker;
+
+/**
+ * Show a save dialog
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIFilePicker
+ *
+ * @param object options
+ * Additional options as specified in showPicker
+ *
+ * @return promise
+ * A promise that is resolved when the save dialog has closed
+ */
+function showSave(options) {
+ return showPicker(merge({
+ title: getLocalizedString("projecteditor.selectFileLabel"),
+ mode: Ci.nsIFilePicker.modeSave
+ }, options));
+}
+exports.showSave = showSave;
+
+/**
+ * Show a file open dialog
+ *
+ * @param object options
+ * Additional options as specified in showPicker
+ *
+ * @return promise
+ * A promise that is resolved when the file has been opened
+ */
+function showOpen(options) {
+ return showPicker(merge({
+ title: getLocalizedString("projecteditor.openFileLabel"),
+ mode: Ci.nsIFilePicker.modeOpen
+ }, options));
+}
+exports.showOpen = showOpen;
+
+/**
+ * Show a folder open dialog
+ *
+ * @param object options
+ * Additional options as specified in showPicker
+ *
+ * @return promise
+ * A promise that is resolved when the folder has been opened
+ */
+function showOpenFolder(options) {
+ return showPicker(merge({
+ title: getLocalizedString("projecteditor.openFolderLabel"),
+ mode: Ci.nsIFilePicker.modeGetFolder
+ }, options));
+}
+exports.showOpenFolder = showOpenFolder;
diff --git a/devtools/client/projecteditor/lib/helpers/l10n.js b/devtools/client/projecteditor/lib/helpers/l10n.js
new file mode 100644
index 000000000..b2b315ff8
--- /dev/null
+++ b/devtools/client/projecteditor/lib/helpers/l10n.js
@@ -0,0 +1,26 @@
+/* -*- 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/. */
+
+"use strict";
+
+/**
+ * This file contains helper functions for internationalizing projecteditor strings
+ */
+
+const { LocalizationHelper } = require("devtools/shared/l10n");
+const ITCHPAD_STRINGS_URI = "devtools/client/locales/projecteditor.properties";
+const L10N = new LocalizationHelper(ITCHPAD_STRINGS_URI);
+
+function getLocalizedString(name) {
+ try {
+ return L10N.getStr(name);
+ } catch (ex) {
+ console.log("Error reading '" + name + "'");
+ throw new Error("l10n error with " + name);
+ }
+}
+
+exports.getLocalizedString = getLocalizedString;
diff --git a/devtools/client/projecteditor/lib/helpers/moz.build b/devtools/client/projecteditor/lib/helpers/moz.build
new file mode 100644
index 000000000..c2e14fce6
--- /dev/null
+++ b/devtools/client/projecteditor/lib/helpers/moz.build
@@ -0,0 +1,12 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'event.js',
+ 'file-picker.js',
+ 'l10n.js',
+ 'prompts.js',
+)
diff --git a/devtools/client/projecteditor/lib/helpers/prompts.js b/devtools/client/projecteditor/lib/helpers/prompts.js
new file mode 100644
index 000000000..0df6af304
--- /dev/null
+++ b/devtools/client/projecteditor/lib/helpers/prompts.js
@@ -0,0 +1,33 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 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/. */
+
+/**
+ * This file contains helper functions for showing user prompts.
+ * See https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPromptService
+ */
+
+const { Cu, Cc, Ci } = require("chrome");
+const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n");
+const prompts = Cc["@mozilla.org/embedcomp/prompt-service;1"]
+ .getService(Ci.nsIPromptService);
+
+/**
+ * Show a prompt.
+ *
+ * @param string title
+ * The title to the dialog
+ * @param string message
+ * The message to display
+ *
+ * @return bool
+ * Whether the user has confirmed the action
+ */
+function confirm(title, message) {
+ var result = prompts.confirm(null, title || "Title of this Dialog", message || "Are you sure?");
+ return result;
+}
+exports.confirm = confirm;
+
diff --git a/devtools/client/projecteditor/lib/helpers/readdir.js b/devtools/client/projecteditor/lib/helpers/readdir.js
new file mode 100644
index 000000000..054730faf
--- /dev/null
+++ b/devtools/client/projecteditor/lib/helpers/readdir.js
@@ -0,0 +1,89 @@
+/* -*- 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/. */
+
+importScripts("resource://gre/modules/osfile.jsm");
+
+/**
+ * This file is meant to be loaded in a worker using:
+ * new ChromeWorker("chrome://devtools/content/projecteditor/lib/helpers/readdir.js");
+ *
+ * Read a local directory inside of a web woker
+ *
+ * @param {string} path
+ * window to inspect
+ * @param {RegExp|string} ignore
+ * A pattern to ignore certain files. This is
+ * called with file.name.match(ignore).
+ * @param {Number} maxDepth
+ * How many directories to recurse before stopping.
+ * Directories with depth > maxDepth will be ignored.
+ */
+function readDir(path, ignore, maxDepth = Infinity) {
+ let ret = {};
+
+ let set = new Set();
+
+ let info = OS.File.stat(path);
+ set.add({
+ path: path,
+ name: info.name,
+ isDir: info.isDir,
+ isSymLink: info.isSymLink,
+ depth: 0
+ });
+
+ for (let info of set) {
+ let children = [];
+
+ if (info.isDir && !info.isSymLink) {
+ if (info.depth > maxDepth) {
+ continue;
+ }
+
+ let iterator = new OS.File.DirectoryIterator(info.path);
+ try {
+ for (let child in iterator) {
+ if (ignore && child.name.match(ignore)) {
+ continue;
+ }
+
+ children.push(child.path);
+ set.add({
+ path: child.path,
+ name: child.name,
+ isDir: child.isDir,
+ isSymLink: child.isSymLink,
+ depth: info.depth + 1
+ });
+ }
+ } finally {
+ iterator.close();
+ }
+ }
+
+ ret[info.path] = {
+ name: info.name,
+ isDir: info.isDir,
+ isSymLink: info.isSymLink,
+ depth: info.depth,
+ children: children,
+ };
+ }
+
+ return ret;
+}
+
+onmessage = function (event) {
+ try {
+ let {path, ignore, depth} = event.data;
+ let message = readDir(path, ignore, depth);
+ postMessage(message);
+ } catch (ex) {
+ console.log(ex);
+ }
+};
+
+
diff --git a/devtools/client/projecteditor/lib/moz.build b/devtools/client/projecteditor/lib/moz.build
new file mode 100644
index 000000000..91b88ed91
--- /dev/null
+++ b/devtools/client/projecteditor/lib/moz.build
@@ -0,0 +1,19 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DIRS += [
+ 'helpers',
+ 'plugins',
+ 'stores',
+]
+
+DevToolsModules(
+ 'editors.js',
+ 'project.js',
+ 'projecteditor.js',
+ 'shells.js',
+ 'tree.js',
+)
diff --git a/devtools/client/projecteditor/lib/plugins/app-manager/app-project-editor.js b/devtools/client/projecteditor/lib/plugins/app-manager/app-project-editor.js
new file mode 100644
index 000000000..9a66770b0
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/app-manager/app-project-editor.js
@@ -0,0 +1,56 @@
+/* -*- 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 { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const promise = require("promise");
+const { ItchEditor } = require("devtools/client/projecteditor/lib/editors");
+
+var AppProjectEditor = Class({
+ extends: ItchEditor,
+
+ hidesToolbar: true,
+
+ initialize: function (host) {
+ ItchEditor.prototype.initialize.apply(this, arguments);
+ this.appended = promise.resolve();
+ this.host = host;
+ this.label = "app-manager";
+ },
+
+ destroy: function () {
+ this.elt.remove();
+ this.elt = null;
+ },
+
+ load: function (resource) {
+ let {appManagerOpts} = this.host.project;
+
+ // Only load the frame the first time it is selected
+ if (!this.iframe || this.iframe.getAttribute("src") !== appManagerOpts.projectOverviewURL) {
+
+ this.elt.textContent = "";
+ let iframe = this.iframe = this.elt.ownerDocument.createElement("iframe");
+ let iframeLoaded = this.iframeLoaded = promise.defer();
+
+ iframe.addEventListener("load", function onLoad() {
+ iframe.removeEventListener("load", onLoad);
+ iframeLoaded.resolve();
+ });
+
+ iframe.setAttribute("flex", "1");
+ iframe.setAttribute("src", appManagerOpts.projectOverviewURL);
+ this.elt.appendChild(iframe);
+
+ }
+
+ promise.all([this.iframeLoaded.promise, this.appended]).then(() => {
+ this.emit("load");
+ });
+ }
+});
+
+exports.AppProjectEditor = AppProjectEditor;
diff --git a/devtools/client/projecteditor/lib/plugins/app-manager/moz.build b/devtools/client/projecteditor/lib/plugins/app-manager/moz.build
new file mode 100644
index 000000000..8aae52725
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/app-manager/moz.build
@@ -0,0 +1,10 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'app-project-editor.js',
+ 'plugin.js',
+)
diff --git a/devtools/client/projecteditor/lib/plugins/app-manager/plugin.js b/devtools/client/projecteditor/lib/plugins/app-manager/plugin.js
new file mode 100644
index 000000000..82bbab34b
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/app-manager/plugin.js
@@ -0,0 +1,77 @@
+const { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const { EventTarget } = require("sdk/event/target");
+const { emit } = require("sdk/event/core");
+const promise = require("promise");
+var { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core");
+const { AppProjectEditor } = require("./app-project-editor");
+const OPTION_URL = "chrome://devtools/skin/images/tool-options.svg";
+const Services = require("Services");
+const Strings = Services.strings.createBundle("chrome://devtools/locale/webide.properties");
+
+var AppManagerRenderer = Class({
+ extends: Plugin,
+
+ isAppManagerProject: function () {
+ return !!this.host.project.appManagerOpts;
+ },
+ editorForResource: function (resource) {
+ if (!resource.parent && this.isAppManagerProject()) {
+ return AppProjectEditor;
+ }
+ },
+ getUI: function (parent) {
+ let doc = parent.ownerDocument;
+ if (parent.childElementCount == 0) {
+ let image = doc.createElement("image");
+ let optionImage = doc.createElement("image");
+ let flexElement = doc.createElement("div");
+ let nameLabel = doc.createElement("span");
+ let statusElement = doc.createElement("div");
+
+ image.className = "project-image";
+ optionImage.className = "project-options";
+ optionImage.setAttribute("src", OPTION_URL);
+ nameLabel.className = "project-name-label";
+ statusElement.className = "project-status";
+ flexElement.className = "project-flex";
+
+ parent.appendChild(image);
+ parent.appendChild(nameLabel);
+ parent.appendChild(flexElement);
+ parent.appendChild(statusElement);
+ parent.appendChild(optionImage);
+ }
+
+ return {
+ image: parent.querySelector(".project-image"),
+ nameLabel: parent.querySelector(".project-name-label"),
+ statusElement: parent.querySelector(".project-status")
+ };
+ },
+ onAnnotate: function (resource, editor, elt) {
+ if (resource.parent || !this.isAppManagerProject()) {
+ return;
+ }
+
+ let {appManagerOpts} = this.host.project;
+ let doc = elt.ownerDocument;
+
+ let {image, nameLabel, statusElement} = this.getUI(elt);
+ let name = appManagerOpts.name || resource.basename;
+ let url = appManagerOpts.iconUrl || "icon-sample.png";
+ let status = appManagerOpts.validationStatus || "unknown";
+ let tooltip = Strings.formatStringFromName("status_tooltip",
+ [Strings.GetStringFromName("status_" + status)], 1);
+
+ nameLabel.textContent = name;
+ image.setAttribute("src", url);
+ statusElement.setAttribute("status", status);
+ statusElement.setAttribute("tooltiptext", tooltip);
+
+ return true;
+ }
+});
+
+exports.AppManagerRenderer = AppManagerRenderer;
+registerPlugin(AppManagerRenderer);
diff --git a/devtools/client/projecteditor/lib/plugins/core.js b/devtools/client/projecteditor/lib/plugins/core.js
new file mode 100644
index 000000000..933eda043
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/core.js
@@ -0,0 +1,83 @@
+/* -*- 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/. */
+
+// This is the core plugin API.
+
+const { Class } = require("sdk/core/heritage");
+
+var Plugin = Class({
+ initialize: function (host) {
+ this.host = host;
+ this.init(host);
+ },
+
+ destroy: function (host) { },
+
+ init: function (host) {},
+
+ showForCategories: function (elt, categories) {
+ this._showFor = this._showFor || [];
+ let set = new Set(categories);
+ this._showFor.push({
+ elt: elt,
+ categories: new Set(categories)
+ });
+ if (this.host.currentEditor) {
+ this.onEditorActivated(this.host.currentEditor);
+ } else {
+ elt.classList.add("plugin-hidden");
+ }
+ },
+
+ priv: function (item) {
+ if (!this._privData) {
+ this._privData = new WeakMap();
+ }
+ if (!this._privData.has(item)) {
+ this._privData.set(item, {});
+ }
+ return this._privData.get(item);
+ },
+ onTreeSelected: function (resource) {},
+
+
+ // Editor state lifetime...
+ onEditorCreated: function (editor) {},
+ onEditorDestroyed: function (editor) {},
+
+ onEditorActivated: function (editor) {
+ if (this._showFor) {
+ let category = editor.category;
+ for (let item of this._showFor) {
+ if (item.categories.has(category)) {
+ item.elt.classList.remove("plugin-hidden");
+ } else {
+ item.elt.classList.add("plugin-hidden");
+ }
+ }
+ }
+ },
+ onEditorDeactivated: function (editor) {
+ if (this._showFor) {
+ for (let item of this._showFor) {
+ item.elt.classList.add("plugin-hidden");
+ }
+ }
+ },
+
+ onEditorLoad: function (editor) {},
+ onEditorSave: function (editor) {},
+ onEditorChange: function (editor) {},
+ onEditorCursorActivity: function (editor) {},
+});
+exports.Plugin = Plugin;
+
+function registerPlugin(constr) {
+ exports.registeredPlugins.push(constr);
+}
+exports.registerPlugin = registerPlugin;
+
+exports.registeredPlugins = [];
diff --git a/devtools/client/projecteditor/lib/plugins/delete/delete.js b/devtools/client/projecteditor/lib/plugins/delete/delete.js
new file mode 100644
index 000000000..b28d6a0ef
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/delete/delete.js
@@ -0,0 +1,67 @@
+/* -*- 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 { Class } = require("sdk/core/heritage");
+const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core");
+const { confirm } = require("devtools/client/projecteditor/lib/helpers/prompts");
+const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n");
+
+var DeletePlugin = Class({
+ extends: Plugin,
+ shouldConfirm: true,
+
+ init: function (host) {
+ this.host.addCommand(this, {
+ id: "cmd-delete"
+ });
+ this.contextMenuItem = this.host.createMenuItem({
+ parent: this.host.contextMenuPopup,
+ label: getLocalizedString("projecteditor.deleteLabel"),
+ command: "cmd-delete"
+ });
+ },
+
+ confirmDelete: function (resource) {
+ let deletePromptMessage = resource.isDir ?
+ getLocalizedString("projecteditor.deleteFolderPromptMessage") :
+ getLocalizedString("projecteditor.deleteFilePromptMessage");
+ return !this.shouldConfirm || confirm(
+ getLocalizedString("projecteditor.deletePromptTitle"),
+ deletePromptMessage
+ );
+ },
+
+ onContextMenuOpen: function (resource) {
+ // Do not allow deletion of the top level items in the tree. In the
+ // case of the Web IDE in particular this can leave the UI in a weird
+ // state. If we'd like to add ability to delete the project folder from
+ // the tree in the future, then the UI could be cleaned up by listening
+ // to the ProjectTree's "resource-removed" event.
+ if (!resource.parent) {
+ this.contextMenuItem.setAttribute("hidden", "true");
+ } else {
+ this.contextMenuItem.removeAttribute("hidden");
+ }
+ },
+
+ onCommand: function (cmd) {
+ if (cmd === "cmd-delete") {
+ let tree = this.host.projectTree;
+ let resource = tree.getSelectedResource();
+
+ if (!this.confirmDelete(resource)) {
+ return;
+ }
+
+ resource.delete().then(() => {
+ this.host.project.refresh();
+ });
+ }
+ }
+});
+
+exports.DeletePlugin = DeletePlugin;
+registerPlugin(DeletePlugin);
diff --git a/devtools/client/projecteditor/lib/plugins/delete/moz.build b/devtools/client/projecteditor/lib/plugins/delete/moz.build
new file mode 100644
index 000000000..4b1d00466
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/delete/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'delete.js',
+)
diff --git a/devtools/client/projecteditor/lib/plugins/dirty/dirty.js b/devtools/client/projecteditor/lib/plugins/dirty/dirty.js
new file mode 100644
index 000000000..f976c626f
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/dirty/dirty.js
@@ -0,0 +1,47 @@
+/* -*- 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 { Class } = require("sdk/core/heritage");
+const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core");
+const { emit } = require("sdk/event/core");
+
+var DirtyPlugin = Class({
+ extends: Plugin,
+
+ onEditorSave: function (editor) { this.onEditorChange(editor); },
+ onEditorLoad: function (editor) { this.onEditorChange(editor); },
+
+ onEditorChange: function (editor) {
+ // Only run on a TextEditor
+ if (!editor || !editor.editor) {
+ return;
+ }
+
+ // Dont' force a refresh unless the dirty state has changed...
+ let priv = this.priv(editor);
+ let clean = editor.isClean();
+ if (priv.isClean !== clean) {
+ let resource = editor.shell.resource;
+ emit(resource, "label-change", resource);
+ priv.isClean = clean;
+ }
+ },
+
+ onAnnotate: function (resource, editor, elt) {
+ // Only run on a TextEditor
+ if (!editor || !editor.editor) {
+ return;
+ }
+
+ if (!editor.isClean()) {
+ elt.textContent = "*" + resource.displayName;
+ return true;
+ }
+ }
+});
+exports.DirtyPlugin = DirtyPlugin;
+
+registerPlugin(DirtyPlugin);
diff --git a/devtools/client/projecteditor/lib/plugins/dirty/moz.build b/devtools/client/projecteditor/lib/plugins/dirty/moz.build
new file mode 100644
index 000000000..b86c5a9af
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/dirty/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'dirty.js',
+)
diff --git a/devtools/client/projecteditor/lib/plugins/image-view/image-editor.js b/devtools/client/projecteditor/lib/plugins/image-view/image-editor.js
new file mode 100644
index 000000000..668fcbeb2
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/image-view/image-editor.js
@@ -0,0 +1,50 @@
+/* -*- 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 { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const promise = require("promise");
+const { ItchEditor } = require("devtools/client/projecteditor/lib/editors");
+
+var ImageEditor = Class({
+ extends: ItchEditor,
+
+ initialize: function () {
+ ItchEditor.prototype.initialize.apply(this, arguments);
+ this.label = "image";
+ this.appended = promise.resolve();
+ },
+
+ load: function (resource) {
+ this.elt.innerHTML = "";
+ let image = this.image = this.doc.createElement("image");
+ image.className = "editor-image";
+ image.setAttribute("src", resource.uri);
+
+ let box1 = this.doc.createElement("box");
+ box1.appendChild(image);
+
+ let box2 = this.doc.createElement("box");
+ box2.setAttribute("flex", 1);
+
+ this.elt.appendChild(box1);
+ this.elt.appendChild(box2);
+
+ this.appended.then(() => {
+ this.emit("load");
+ });
+ },
+
+ destroy: function () {
+ if (this.image) {
+ this.image.remove();
+ this.image = null;
+ }
+ }
+
+});
+
+exports.ImageEditor = ImageEditor;
diff --git a/devtools/client/projecteditor/lib/plugins/image-view/moz.build b/devtools/client/projecteditor/lib/plugins/image-view/moz.build
new file mode 100644
index 000000000..d67370e5b
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/image-view/moz.build
@@ -0,0 +1,10 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'image-editor.js',
+ 'plugin.js',
+)
diff --git a/devtools/client/projecteditor/lib/plugins/image-view/plugin.js b/devtools/client/projecteditor/lib/plugins/image-view/plugin.js
new file mode 100644
index 000000000..626ea3c9a
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/image-view/plugin.js
@@ -0,0 +1,28 @@
+/* -*- 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 { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const promise = require("promise");
+const { ImageEditor } = require("./image-editor");
+const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core");
+
+var ImageEditorPlugin = Class({
+ extends: Plugin,
+
+ editorForResource: function (node) {
+ if (node.contentCategory === "image") {
+ return ImageEditor;
+ }
+ },
+
+ init: function (host) {
+
+ }
+});
+
+exports.ImageEditorPlugin = ImageEditorPlugin;
+registerPlugin(ImageEditorPlugin);
diff --git a/devtools/client/projecteditor/lib/plugins/logging/logging.js b/devtools/client/projecteditor/lib/plugins/logging/logging.js
new file mode 100644
index 000000000..cd5757b72
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/logging/logging.js
@@ -0,0 +1,29 @@
+/* -*- 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/. */
+
+var { Class } = require("sdk/core/heritage");
+var { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core");
+
+var LoggingPlugin = Class({
+ extends: Plugin,
+
+ // Editor state lifetime...
+ onEditorCreated: function (editor) { console.log("editor created: " + editor); },
+ onEditorDestroyed: function (editor) { console.log("editor destroyed: " + editor);},
+
+ onEditorSave: function (editor) { console.log("editor saved: " + editor); },
+ onEditorLoad: function (editor) { console.log("editor loaded: " + editor); },
+
+ onEditorActivated: function (editor) { console.log("editor activated: " + editor);},
+ onEditorDeactivated: function (editor) { console.log("editor deactivated: " + editor);},
+
+ onEditorChange: function (editor) { console.log("editor changed: " + editor);},
+
+ onCommand: function (cmd) { console.log("Command: " + cmd); }
+});
+exports.LoggingPlugin = LoggingPlugin;
+
+registerPlugin(LoggingPlugin);
diff --git a/devtools/client/projecteditor/lib/plugins/logging/moz.build b/devtools/client/projecteditor/lib/plugins/logging/moz.build
new file mode 100644
index 000000000..5d8d98fbe
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/logging/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'logging.js',
+)
diff --git a/devtools/client/projecteditor/lib/plugins/moz.build b/devtools/client/projecteditor/lib/plugins/moz.build
new file mode 100644
index 000000000..17bff7ce0
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/moz.build
@@ -0,0 +1,21 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DIRS += [
+ 'app-manager',
+ 'delete',
+ 'dirty',
+ 'image-view',
+ 'logging',
+ 'new',
+ 'rename',
+ 'save',
+ 'status-bar',
+]
+
+DevToolsModules(
+ 'core.js',
+)
diff --git a/devtools/client/projecteditor/lib/plugins/new/moz.build b/devtools/client/projecteditor/lib/plugins/new/moz.build
new file mode 100644
index 000000000..3caacefb1
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/new/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'new.js',
+)
diff --git a/devtools/client/projecteditor/lib/plugins/new/new.js b/devtools/client/projecteditor/lib/plugins/new/new.js
new file mode 100644
index 000000000..220cb4977
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/new/new.js
@@ -0,0 +1,80 @@
+/* -*- 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 { Class } = require("sdk/core/heritage");
+const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core");
+const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n");
+
+// Handles the new command.
+var NewFile = Class({
+ extends: Plugin,
+
+ init: function () {
+ this.command = this.host.addCommand(this, {
+ id: "cmd-new",
+ key: getLocalizedString("projecteditor.new.commandkey"),
+ modifiers: "accel"
+ });
+ this.host.createMenuItem({
+ parent: this.host.fileMenuPopup,
+ label: getLocalizedString("projecteditor.newLabel"),
+ command: "cmd-new",
+ key: "key_cmd-new"
+ });
+ this.host.createMenuItem({
+ parent: this.host.contextMenuPopup,
+ label: getLocalizedString("projecteditor.newLabel"),
+ command: "cmd-new"
+ });
+ },
+
+ onCommand: function (cmd) {
+ if (cmd === "cmd-new") {
+ let tree = this.host.projectTree;
+ let resource = tree.getSelectedResource();
+ parent = resource.isDir ? resource : resource.parent;
+ sibling = resource.isDir ? null : resource;
+
+ if (!("createChild" in parent)) {
+ return;
+ }
+
+ let extension = sibling ? sibling.contentCategory : parent.store.defaultCategory;
+ let template = "untitled{1}." + extension;
+ let name = this.suggestName(parent, template);
+
+ tree.promptNew(name, parent, sibling).then(name => {
+
+ // XXX: sanitize bad file names.
+
+ // If the name is already taken, just add/increment a number.
+ if (parent.hasChild(name)) {
+ let matches = name.match(/([^\d.]*)(\d*)([^.]*)(.*)/);
+ template = matches[1] + "{1}" + matches[3] + matches[4];
+ name = this.suggestName(parent, template, parseInt(matches[2]) || 2);
+ }
+
+ return parent.createChild(name);
+ }).then(resource => {
+ tree.selectResource(resource);
+ this.host.currentEditor.focus();
+ }).then(null, console.error);
+ }
+ },
+
+ suggestName: function (parent, template, start = 1) {
+ let i = start;
+ let name;
+ do {
+ name = template.replace("\{1\}", i === 1 ? "" : i);
+ i++;
+ } while (parent.hasChild(name));
+
+ return name;
+ }
+});
+exports.NewFile = NewFile;
+registerPlugin(NewFile);
diff --git a/devtools/client/projecteditor/lib/plugins/rename/moz.build b/devtools/client/projecteditor/lib/plugins/rename/moz.build
new file mode 100644
index 000000000..2b1612452
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/rename/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'rename.js',
+)
diff --git a/devtools/client/projecteditor/lib/plugins/rename/rename.js b/devtools/client/projecteditor/lib/plugins/rename/rename.js
new file mode 100644
index 000000000..850401869
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/rename/rename.js
@@ -0,0 +1,74 @@
+/* -*- 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 { Class } = require("sdk/core/heritage");
+const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core");
+const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n");
+
+var RenamePlugin = Class({
+ extends: Plugin,
+
+ init: function (host) {
+ this.host.addCommand(this, {
+ id: "cmd-rename"
+ });
+ this.contextMenuItem = this.host.createMenuItem({
+ parent: this.host.contextMenuPopup,
+ label: getLocalizedString("projecteditor.renameLabel"),
+ command: "cmd-rename"
+ });
+ },
+
+ onContextMenuOpen: function (resource) {
+ if (resource.isRoot) {
+ this.contextMenuItem.setAttribute("hidden", "true");
+ } else {
+ this.contextMenuItem.removeAttribute("hidden");
+ }
+ },
+
+ onCommand: function (cmd) {
+ if (cmd === "cmd-rename") {
+ let tree = this.host.projectTree;
+ let resource = tree.getSelectedResource();
+ let parent = resource.parent;
+ let oldName = resource.basename;
+
+ tree.promptEdit(oldName, resource).then(name => {
+ if (name === oldName) {
+ return resource;
+ }
+ if (parent.hasChild(name)) {
+ let matches = name.match(/([^\d.]*)(\d*)([^.]*)(.*)/);
+ let template = matches[1] + "{1}" + matches[3] + matches[4];
+ name = this.suggestName(resource, template, parseInt(matches[2]) || 2);
+ }
+ return parent.rename(oldName, name);
+ }).then(resource => {
+ this.host.project.refresh();
+ tree.selectResource(resource);
+ if (!resource.isDir) {
+ this.host.currentEditor.focus();
+ }
+ }).then(null, console.error);
+ }
+ },
+
+ suggestName: function (resource, template, start = 1) {
+ let i = start;
+ let name;
+ let parent = resource.parent;
+ do {
+ name = template.replace("\{1\}", i === 1 ? "" : i);
+ i++;
+ } while (parent.hasChild(name));
+
+ return name;
+ }
+});
+
+exports.RenamePlugin = RenamePlugin;
+registerPlugin(RenamePlugin);
diff --git a/devtools/client/projecteditor/lib/plugins/save/moz.build b/devtools/client/projecteditor/lib/plugins/save/moz.build
new file mode 100644
index 000000000..66df054eb
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/save/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'save.js',
+)
diff --git a/devtools/client/projecteditor/lib/plugins/save/save.js b/devtools/client/projecteditor/lib/plugins/save/save.js
new file mode 100644
index 000000000..43b2185d2
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/save/save.js
@@ -0,0 +1,93 @@
+/* -*- 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 { Class } = require("sdk/core/heritage");
+const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core");
+const picker = require("devtools/client/projecteditor/lib/helpers/file-picker");
+const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n");
+
+// Handles the save command.
+var SavePlugin = Class({
+ extends: Plugin,
+
+ init: function (host) {
+
+ this.host.addCommand(this, {
+ id: "cmd-save",
+ key: getLocalizedString("projecteditor.save.commandkey"),
+ modifiers: "accel"
+ });
+ this.host.addCommand(this, {
+ id: "cmd-saveas",
+ key: getLocalizedString("projecteditor.save.commandkey"),
+ modifiers: "accel shift"
+ });
+ this.host.createMenuItem({
+ parent: this.host.fileMenuPopup,
+ label: getLocalizedString("projecteditor.saveLabel"),
+ command: "cmd-save",
+ key: "key_cmd-save"
+ });
+ this.host.createMenuItem({
+ parent: this.host.fileMenuPopup,
+ label: getLocalizedString("projecteditor.saveAsLabel"),
+ command: "cmd-saveas",
+ key: "key_cmd-saveas"
+ });
+ },
+
+ isCommandEnabled: function (cmd) {
+ let currentEditor = this.host.currentEditor;
+ return currentEditor.isEditable;
+ },
+
+ onCommand: function (cmd) {
+ if (cmd === "cmd-save") {
+ this.onEditorSaveRequested();
+ } else if (cmd === "cmd-saveas") {
+ this.saveAs();
+ }
+ },
+
+ saveAs: function () {
+ let editor = this.host.currentEditor;
+ let project = this.host.resourceFor(editor);
+
+ let resource;
+ picker.showSave({
+ window: this.host.window,
+ directory: project && project.parent ? project.parent.path : null,
+ defaultName: project ? project.basename : null,
+ }).then(path => {
+ return this.createResource(path);
+ }).then(res => {
+ resource = res;
+ return this.saveResource(editor, resource);
+ }).then(() => {
+ this.host.openResource(resource);
+ }).then(null, console.error);
+ },
+
+ onEditorSaveRequested: function () {
+ let editor = this.host.currentEditor;
+ let resource = this.host.resourceFor(editor);
+ if (!resource) {
+ return this.saveAs();
+ }
+
+ return this.saveResource(editor, resource);
+ },
+
+ createResource: function (path) {
+ return this.host.project.resourceFor(path, { create: true });
+ },
+
+ saveResource: function (editor, resource) {
+ return editor.save(resource);
+ }
+});
+exports.SavePlugin = SavePlugin;
+registerPlugin(SavePlugin);
diff --git a/devtools/client/projecteditor/lib/plugins/status-bar/moz.build b/devtools/client/projecteditor/lib/plugins/status-bar/moz.build
new file mode 100644
index 000000000..87ce21584
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/status-bar/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'plugin.js',
+)
diff --git a/devtools/client/projecteditor/lib/plugins/status-bar/plugin.js b/devtools/client/projecteditor/lib/plugins/status-bar/plugin.js
new file mode 100644
index 000000000..9450baef3
--- /dev/null
+++ b/devtools/client/projecteditor/lib/plugins/status-bar/plugin.js
@@ -0,0 +1,105 @@
+/* -*- 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 { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const promise = require("promise");
+const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core");
+
+/**
+ * Print information about the currently opened file
+ * and the state of the current editor
+ */
+var StatusBarPlugin = Class({
+ extends: Plugin,
+
+ init: function () {
+ this.box = this.host.createElement("hbox", {
+ parent: "#projecteditor-toolbar-bottom"
+ });
+
+ this.activeMode = this.host.createElement("label", {
+ parent: this.box,
+ class: "projecteditor-basic-display"
+ });
+
+ this.cursorPosition = this.host.createElement("label", {
+ parent: this.box,
+ class: "projecteditor-basic-display"
+ });
+
+ this.fileLabel = this.host.createElement("label", {
+ parent: "#plugin-toolbar-left",
+ class: "projecteditor-file-label"
+ });
+ },
+
+ destroy: function () {
+ },
+
+ /**
+ * Print information about the current state of the editor
+ *
+ * @param Editor editor
+ */
+ render: function (editor, resource) {
+ if (!resource || resource.isDir) {
+ this.fileLabel.textContent = "";
+ this.cursorPosition.value = "";
+ return;
+ }
+
+ this.fileLabel.textContent = resource.basename;
+ this.activeMode.value = editor.toString();
+ if (editor.editor) {
+ let cursorStart = editor.editor.getCursor("start");
+ let cursorEnd = editor.editor.getCursor("end");
+ if (cursorStart.line === cursorEnd.line && cursorStart.ch === cursorEnd.ch) {
+ this.cursorPosition.value = cursorStart.line + " " + cursorStart.ch;
+ } else {
+ this.cursorPosition.value = cursorStart.line + " " + cursorStart.ch + " | " +
+ cursorEnd.line + " " + cursorEnd.ch;
+ }
+ } else {
+ this.cursorPosition.value = "";
+ }
+ },
+
+
+ /**
+ * Print the current file name
+ *
+ * @param Resource resource
+ */
+ onTreeSelected: function (resource) {
+ if (!resource || resource.isDir) {
+ this.fileLabel.textContent = "";
+ return;
+ }
+ this.fileLabel.textContent = resource.basename;
+ },
+
+ onEditorDeactivated: function (editor) {
+ this.fileLabel.textContent = "";
+ this.cursorPosition.value = "";
+ },
+
+ onEditorChange: function (editor, resource) {
+ this.render(editor, resource);
+ },
+
+ onEditorCursorActivity: function (editor, resource) {
+ this.render(editor, resource);
+ },
+
+ onEditorActivated: function (editor, resource) {
+ this.render(editor, resource);
+ },
+
+});
+
+exports.StatusBarPlugin = StatusBarPlugin;
+registerPlugin(StatusBarPlugin);
diff --git a/devtools/client/projecteditor/lib/project.js b/devtools/client/projecteditor/lib/project.js
new file mode 100644
index 000000000..8e0a8802d
--- /dev/null
+++ b/devtools/client/projecteditor/lib/project.js
@@ -0,0 +1,246 @@
+/* -*- 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 { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const { EventTarget } = require("sdk/event/target");
+const { emit } = require("sdk/event/core");
+const { scope, on, forget } = require("devtools/client/projecteditor/lib/helpers/event");
+const prefs = require("sdk/preferences/service");
+const { LocalStore } = require("devtools/client/projecteditor/lib/stores/local");
+const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {});
+const { Task } = require("devtools/shared/task");
+const promise = require("promise");
+const { TextEncoder, TextDecoder } = require("sdk/io/buffer");
+const url = require("sdk/url");
+
+const gDecoder = new TextDecoder();
+const gEncoder = new TextEncoder();
+
+/**
+ * A Project keeps track of the opened folders using LocalStore
+ * objects. Resources are generally requested from the project,
+ * even though the Store is actually keeping track of them.
+ *
+ *
+ * This object emits the following events:
+ * - "refresh-complete": After all stores have been refreshed from disk.
+ * - "store-added": When a store has been added to the project.
+ * - "store-removed": When a store has been removed from the project.
+ * - "resource-added": When a resource has been added to one of the stores.
+ * - "resource-removed": When a resource has been removed from one of the stores.
+ */
+var Project = Class({
+ extends: EventTarget,
+
+ /**
+ * Intialize the Project.
+ *
+ * @param Object options
+ * Options to be passed into Project.load function
+ */
+ initialize: function (options) {
+ this.localStores = new Map();
+
+ this.load(options);
+ },
+
+ destroy: function () {
+ // We are removing the store because the project never gets persisted.
+ // There may need to be separate destroy functionality that doesn't remove
+ // from project if this is saved to DB.
+ this.removeAllStores();
+ },
+
+ toString: function () {
+ return "[Project] " + this.name;
+ },
+
+ /**
+ * Load a project given metadata about it.
+ *
+ * @param Object options
+ * Information about the project, containing:
+ * id: An ID (currently unused, but could be used for saving)
+ * name: The display name of the project
+ * directories: An array of path strings to load
+ */
+ load: function (options) {
+ this.id = options.id;
+ this.name = options.name || "Untitled";
+
+ let paths = new Set(options.directories.map(name => OS.Path.normalize(name)));
+
+ for (let [path, store] of this.localStores) {
+ if (!paths.has(path)) {
+ this.removePath(path);
+ }
+ }
+
+ for (let path of paths) {
+ this.addPath(path);
+ }
+ },
+
+ /**
+ * Refresh all project stores from disk
+ *
+ * @returns Promise
+ * A promise that resolves when everything has been refreshed.
+ */
+ refresh: function () {
+ return Task.spawn(function* () {
+ for (let [path, store] of this.localStores) {
+ yield store.refresh();
+ }
+ emit(this, "refresh-complete");
+ }.bind(this));
+ },
+
+
+ /**
+ * Fetch a resource from the backing storage system for the store.
+ *
+ * @param string path
+ * The path to fetch
+ * @param Object options
+ * "create": bool indicating whether to create a file if it does not exist.
+ * @returns Promise
+ * A promise that resolves with the Resource.
+ */
+ resourceFor: function (path, options) {
+ let store = this.storeContaining(path);
+ return store.resourceFor(path, options);
+ },
+
+ /**
+ * Get every resource used inside of the project.
+ *
+ * @returns Array<Resource>
+ * A list of all Resources in all Stores.
+ */
+ allResources: function () {
+ let resources = [];
+ for (let store of this.allStores()) {
+ resources = resources.concat(store.allResources());
+ }
+ return resources;
+ },
+
+ /**
+ * Get every Path used inside of the project.
+ *
+ * @returns generator-iterator<Store>
+ * A list of all Stores
+ */
+ allStores: function* () {
+ for (let [path, store] of this.localStores) {
+ yield store;
+ }
+ },
+
+ /**
+ * Get every file path used inside of the project.
+ *
+ * @returns Array<string>
+ * A list of all file paths
+ */
+ allPaths: function () {
+ return [...this.localStores.keys()];
+ },
+
+ /**
+ * Get the store that contains a path.
+ *
+ * @returns Store
+ * The store, if any. Will return null if no store
+ * contains the given path.
+ */
+ storeContaining: function (path) {
+ let containingStore = null;
+ for (let store of this.allStores()) {
+ if (store.contains(path)) {
+ // With nested projects, the final containing store will be returned.
+ containingStore = store;
+ }
+ }
+ return containingStore;
+ },
+
+ /**
+ * Add a store at the current path. If a store already exists
+ * for this path, then return it.
+ *
+ * @param string path
+ * @returns LocalStore
+ */
+ addPath: function (path) {
+ if (!this.localStores.has(path)) {
+ this.addLocalStore(new LocalStore(path));
+ }
+ return this.localStores.get(path);
+ },
+
+ /**
+ * Remove a store for a given path.
+ *
+ * @param string path
+ */
+ removePath: function (path) {
+ this.removeLocalStore(this.localStores.get(path));
+ },
+
+
+ /**
+ * Add the given Store to the project.
+ * Fires a 'store-added' event on the project.
+ *
+ * @param Store store
+ */
+ addLocalStore: function (store) {
+ store.canPair = true;
+ this.localStores.set(store.path, store);
+
+ // Originally StoreCollection.addStore
+ on(this, store, "resource-added", (resource) => {
+ emit(this, "resource-added", resource);
+ });
+ on(this, store, "resource-removed", (resource) => {
+ emit(this, "resource-removed", resource);
+ });
+
+ emit(this, "store-added", store);
+ },
+
+
+ /**
+ * Remove all of the Stores belonging to the project.
+ */
+ removeAllStores: function () {
+ for (let store of this.allStores()) {
+ this.removeLocalStore(store);
+ }
+ },
+
+ /**
+ * Remove the given Store from the project.
+ * Fires a 'store-removed' event on the project.
+ *
+ * @param Store store
+ */
+ removeLocalStore: function (store) {
+ // XXX: tree selection should be reset if active element is affected by
+ // the store being removed
+ if (store) {
+ this.localStores.delete(store.path);
+ forget(this, store);
+ emit(this, "store-removed", store);
+ store.destroy();
+ }
+ }
+});
+
+exports.Project = Project;
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;
diff --git a/devtools/client/projecteditor/lib/shells.js b/devtools/client/projecteditor/lib/shells.js
new file mode 100644
index 000000000..8004f24a2
--- /dev/null
+++ b/devtools/client/projecteditor/lib/shells.js
@@ -0,0 +1,243 @@
+/* -*- 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 { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const { EventTarget } = require("sdk/event/target");
+const { emit } = require("sdk/event/core");
+const { EditorTypeForResource } = require("devtools/client/projecteditor/lib/editors");
+const NetworkHelper = require("devtools/shared/webconsole/network-helper");
+const promise = require("promise");
+
+/**
+ * The Shell is the object that manages the editor for a single resource.
+ * It is in charge of selecting the proper Editor (text/image/plugin-defined)
+ * and instantiating / appending the editor.
+ * This object is not exported, it is just used internally by the ShellDeck.
+ *
+ * This object has a promise `editorAppended`, that will resolve once the editor
+ * is ready to be used.
+ */
+var Shell = Class({
+ extends: EventTarget,
+
+ /**
+ * @param ProjectEditor host
+ * @param Resource resource
+ */
+ initialize: function (host, resource) {
+ this.host = host;
+ this.doc = host.document;
+ this.resource = resource;
+ this.elt = this.doc.createElement("vbox");
+ this.elt.classList.add("view-project-detail");
+ this.elt.shell = this;
+
+ let constructor = this._editorTypeForResource();
+
+ this.editor = constructor(this.host);
+ this.editor.shell = this;
+ this.editorAppended = this.editor.appended;
+
+ this.editor.on("load", () => {
+ this.editorDeferred.resolve();
+ });
+ this.elt.appendChild(this.editor.elt);
+ },
+
+ /**
+ * Start loading the resource. The 'load' event happens as
+ * a result of this function, so any listeners to 'editorAppended'
+ * need to be added before calling this.
+ */
+ load: function () {
+ this.editorDeferred = promise.defer();
+ this.editorLoaded = this.editorDeferred.promise;
+ this.editor.load(this.resource);
+ },
+
+ /**
+ * Destroy the shell and its associated editor
+ */
+ destroy: function () {
+ this.editor.destroy();
+ this.resource.destroy();
+ },
+
+ /**
+ * Make sure the correct editor is selected for the resource.
+ * @returns Type:Editor
+ */
+ _editorTypeForResource: function () {
+ let resource = this.resource;
+ let constructor = EditorTypeForResource(resource);
+
+ if (this.host.plugins) {
+ this.host.plugins.forEach(plugin => {
+ if (plugin.editorForResource) {
+ let pluginEditor = plugin.editorForResource(resource);
+ if (pluginEditor) {
+ constructor = pluginEditor;
+ }
+ }
+ });
+ }
+
+ return constructor;
+ }
+});
+
+/**
+ * The ShellDeck is in charge of managing the list of active Shells for
+ * the current ProjectEditor instance (aka host).
+ *
+ * This object emits the following events:
+ * - "editor-created": When an editor is initially created
+ * - "editor-activated": When an editor is ready to use
+ * - "editor-deactivated": When an editor is ready to use
+ */
+var ShellDeck = Class({
+ extends: EventTarget,
+
+ /**
+ * @param ProjectEditor host
+ * @param Document document
+ */
+ initialize: function (host, document) {
+ this.doc = document;
+ this.host = host;
+ this.deck = this.doc.createElement("deck");
+ this.deck.setAttribute("flex", "1");
+ this.elt = this.deck;
+
+ this.shells = new Map();
+
+ this._activeShell = null;
+ },
+
+ /**
+ * Open a resource in a Shell. Will create the Shell
+ * if it doesn't exist yet.
+ *
+ * @param Resource resource
+ * The file to be opened
+ * @returns Shell
+ */
+ open: function (defaultResource) {
+ let shell = this.shellFor(defaultResource);
+ if (!shell) {
+ shell = this._createShell(defaultResource);
+ this.shells.set(defaultResource, shell);
+ }
+ this.selectShell(shell);
+ return shell;
+ },
+
+ /**
+ * Create a new Shell for a resource. Called by `open`.
+ *
+ * @returns Shell
+ */
+ _createShell: function (defaultResource) {
+ let shell = Shell(this.host, defaultResource);
+
+ shell.editorAppended.then(() => {
+ this.shells.set(shell.resource, shell);
+ emit(this, "editor-created", shell.editor);
+ if (this.currentShell === shell) {
+ this.selectShell(shell);
+ }
+
+ });
+
+ shell.load();
+ this.deck.appendChild(shell.elt);
+ return shell;
+ },
+
+ /**
+ * Remove the shell for a given resource.
+ *
+ * @param Resource resource
+ */
+ removeResource: function (resource) {
+ let shell = this.shellFor(resource);
+ if (shell) {
+ this.shells.delete(resource);
+ shell.destroy();
+ }
+ },
+
+ destroy: function () {
+ for (let [resource, shell] of this.shells.entries()) {
+ this.shells.delete(resource);
+ shell.destroy();
+ }
+ },
+
+ /**
+ * Select a given shell and open its editor.
+ * Will fire editor-deactivated on the old selected Shell (if any),
+ * and editor-activated on the new one once it is ready
+ *
+ * @param Shell shell
+ */
+ selectShell: function (shell) {
+ // Don't fire another activate if this is already the active shell
+ if (this._activeShell != shell) {
+ if (this._activeShell) {
+ emit(this, "editor-deactivated", this._activeShell.editor, this._activeShell.resource);
+ }
+ this.deck.selectedPanel = shell.elt;
+ this._activeShell = shell;
+
+ // Only reload the shell if the editor doesn't have local changes.
+ if (shell.editor.isClean()) {
+ shell.load();
+ }
+ shell.editorLoaded.then(() => {
+ // Handle case where another shell has been requested before this
+ // one is finished loading.
+ if (this._activeShell === shell) {
+ emit(this, "editor-activated", shell.editor, shell.resource);
+ }
+ });
+ }
+ },
+
+ /**
+ * Find a Shell for a Resource.
+ *
+ * @param Resource resource
+ * @returns Shell
+ */
+ shellFor: function (resource) {
+ return this.shells.get(resource);
+ },
+
+ /**
+ * The currently active Shell. Note: the editor may not yet be available
+ * on the current shell. Best to wait for the 'editor-activated' event
+ * instead.
+ *
+ * @returns Shell
+ */
+ get currentShell() {
+ return this._activeShell;
+ },
+
+ /**
+ * The currently active Editor, or null if it is not ready.
+ *
+ * @returns Editor
+ */
+ get currentEditor() {
+ let shell = this.currentShell;
+ return shell ? shell.editor : null;
+ },
+
+});
+exports.ShellDeck = ShellDeck;
diff --git a/devtools/client/projecteditor/lib/stores/base.js b/devtools/client/projecteditor/lib/stores/base.js
new file mode 100644
index 000000000..ef9495c77
--- /dev/null
+++ b/devtools/client/projecteditor/lib/stores/base.js
@@ -0,0 +1,58 @@
+/* -*- 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 { EventTarget } = require("sdk/event/target");
+const { emit } = require("sdk/event/core");
+const promise = require("promise");
+
+/**
+ * A Store object maintains a collection of Resource objects stored in a tree.
+ *
+ * The Store class should not be instantiated directly. Instead, you should
+ * use a class extending it - right now this is only a LocalStore.
+ *
+ * Events:
+ * This object emits the 'resource-added' and 'resource-removed' events.
+ */
+var Store = Class({
+ extends: EventTarget,
+
+ /**
+ * Should be called during initialize() of a subclass.
+ */
+ initStore: function () {
+ this.resources = new Map();
+ },
+
+ refresh: function () {
+ return promise.resolve();
+ },
+
+ /**
+ * Return a sorted Array of all Resources in the Store
+ */
+ allResources: function () {
+ var resources = [];
+ function addResource(resource) {
+ resources.push(resource);
+ resource.childrenSorted.forEach(addResource);
+ }
+ addResource(this.root);
+ return resources;
+ },
+
+ notifyAdd: function (resource) {
+ emit(this, "resource-added", resource);
+ },
+
+ notifyRemove: function (resource) {
+ emit(this, "resource-removed", resource);
+ }
+});
+
+exports.Store = Store;
diff --git a/devtools/client/projecteditor/lib/stores/local.js b/devtools/client/projecteditor/lib/stores/local.js
new file mode 100644
index 000000000..1f782dadf
--- /dev/null
+++ b/devtools/client/projecteditor/lib/stores/local.js
@@ -0,0 +1,215 @@
+/* -*- 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, ChromeWorker } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const { OS } = require("resource://gre/modules/osfile.jsm");
+const { emit } = require("sdk/event/core");
+const { Store } = require("devtools/client/projecteditor/lib/stores/base");
+const { Task } = require("devtools/shared/task");
+const promise = require("promise");
+const Services = require("Services");
+const { on, forget } = require("devtools/client/projecteditor/lib/helpers/event");
+const { FileResource } = require("devtools/client/projecteditor/lib/stores/resource");
+
+const CHECK_LINKED_DIRECTORY_DELAY = 5000;
+const SHOULD_LIVE_REFRESH = true;
+// XXX: Ignores should be customizable
+const IGNORE_REGEX = /(^\.)|(\~$)|(^node_modules$)/;
+
+/**
+ * A LocalStore object maintains a collection of Resource objects
+ * from the file system.
+ *
+ * This object emits the following events:
+ * - "resource-added": When a resource is added
+ * - "resource-removed": When a resource is removed
+ */
+var LocalStore = Class({
+ extends: Store,
+
+ defaultCategory: "js",
+
+ initialize: function(path) {
+ this.initStore();
+ this.path = OS.Path.normalize(path);
+ this.rootPath = this.path;
+ this.displayName = this.path;
+ this.root = this._forPath(this.path);
+ this.notifyAdd(this.root);
+ this.refreshLoop = this.refreshLoop.bind(this);
+ this.refreshLoop();
+ },
+
+ destroy: function() {
+ clearTimeout(this._refreshTimeout);
+
+ if (this._refreshDeferred) {
+ this._refreshDeferred.reject("destroy");
+ }
+ if (this.worker) {
+ this.worker.terminate();
+ }
+
+ this._refreshTimeout = null;
+ this._refreshDeferred = null;
+ this.worker = null;
+
+ if (this.root) {
+ forget(this, this.root);
+ this.root.destroy();
+ }
+ },
+
+ toString: function() { return "[LocalStore:" + this.path + "]" },
+
+ /**
+ * Return a FileResource object for the given path. If a FileInfo
+ * is provided the resource will use it, otherwise the FileResource
+ * might not have full information until the next refresh.
+ *
+ * The following parameters are passed into the FileResource constructor
+ * See resource.js for information about them
+ *
+ * @param String path
+ * @param FileInfo info
+ * @returns Resource
+ */
+ _forPath: function(path, info=null) {
+ if (this.resources.has(path)) {
+ return this.resources.get(path);
+ }
+
+ let resource = FileResource(this, path, info);
+ this.resources.set(path, resource);
+ return resource;
+ },
+
+ /**
+ * Return a promise that resolves to a fully-functional FileResource
+ * within this project. This will hit the disk for stat info.
+ * options:
+ *
+ * create: If true, a resource will be created even if the underlying
+ * file doesn't exist.
+ */
+ resourceFor: function(path, options) {
+ path = OS.Path.normalize(path);
+
+ if (this.resources.has(path)) {
+ return promise.resolve(this.resources.get(path));
+ }
+
+ if (!this.contains(path)) {
+ return promise.reject(new Error(path + " does not belong to " + this.path));
+ }
+
+ return Task.spawn(function*() {
+ let parent = yield this.resourceFor(OS.Path.dirname(path));
+
+ let info;
+ try {
+ info = yield OS.File.stat(path);
+ } catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
+ if (!options.create) {
+ throw ex;
+ }
+ }
+
+ let resource = this._forPath(path, info);
+ parent.addChild(resource);
+ return resource;
+ }.bind(this));
+ },
+
+ refreshLoop: function() {
+ // XXX: Once Bug 958280 adds a watch function, will not need to forever loop here.
+ this.refresh().then(() => {
+ if (SHOULD_LIVE_REFRESH) {
+ this._refreshTimeout = setTimeout(this.refreshLoop,
+ CHECK_LINKED_DIRECTORY_DELAY);
+ }
+ });
+ },
+
+ _refreshTimeout: null,
+ _refreshDeferred: null,
+
+ /**
+ * Refresh the directory structure.
+ */
+ refresh: function(path=this.rootPath) {
+ if (this._refreshDeferred) {
+ return this._refreshDeferred.promise;
+ }
+ this._refreshDeferred = promise.defer();
+
+ let worker = this.worker = new ChromeWorker("chrome://devtools/content/projecteditor/lib/helpers/readdir.js");
+ let start = Date.now();
+
+ worker.onmessage = evt => {
+ // console.log("Directory read finished in " + ( Date.now() - start ) +"ms", evt);
+ for (path in evt.data) {
+ let info = evt.data[path];
+ info.path = path;
+
+ let resource = this._forPath(path, info);
+ resource.info = info;
+ if (info.isDir) {
+ let newChildren = new Set();
+ for (let childPath of info.children) {
+ childInfo = evt.data[childPath];
+ newChildren.add(this._forPath(childPath, childInfo));
+ }
+ resource.setChildren(newChildren);
+ }
+ resource.info.children = null;
+ }
+
+ worker = null;
+ this._refreshDeferred.resolve();
+ this._refreshDeferred = null;
+ };
+ worker.onerror = ex => {
+ console.error(ex);
+ worker = null;
+ this._refreshDeferred.reject(ex);
+ this._refreshDeferred = null;
+ }
+ worker.postMessage({ path: this.rootPath, ignore: IGNORE_REGEX });
+ return this._refreshDeferred.promise;
+ },
+
+ /**
+ * Returns true if the given path would be a child of the store's
+ * root directory.
+ */
+ contains: function(path) {
+ path = OS.Path.normalize(path);
+ let thisPath = OS.Path.split(this.rootPath);
+ let thatPath = OS.Path.split(path)
+
+ if (!(thisPath.absolute && thatPath.absolute)) {
+ throw new Error("Contains only works with absolute paths.");
+ }
+
+ if (thisPath.winDrive && (thisPath.winDrive != thatPath.winDrive)) {
+ return false;
+ }
+
+ if (thatPath.components.length <= thisPath.components.length) {
+ return false;
+ }
+
+ for (let i = 0; i < thisPath.components.length; i++) {
+ if (thisPath.components[i] != thatPath.components[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+});
+exports.LocalStore = LocalStore;
diff --git a/devtools/client/projecteditor/lib/stores/moz.build b/devtools/client/projecteditor/lib/stores/moz.build
new file mode 100644
index 000000000..5a6becd92
--- /dev/null
+++ b/devtools/client/projecteditor/lib/stores/moz.build
@@ -0,0 +1,11 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'base.js',
+ 'local.js',
+ 'resource.js',
+)
diff --git a/devtools/client/projecteditor/lib/stores/resource.js b/devtools/client/projecteditor/lib/stores/resource.js
new file mode 100644
index 000000000..53e3e7348
--- /dev/null
+++ b/devtools/client/projecteditor/lib/stores/resource.js
@@ -0,0 +1,398 @@
+/* -*- 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/. */
+
+"use strict";
+
+const { Cc, Ci, Cu } = require("chrome");
+const { TextEncoder, TextDecoder } = require("sdk/io/buffer");
+const { Class } = require("sdk/core/heritage");
+const { EventTarget } = require("sdk/event/target");
+const { emit } = require("sdk/event/core");
+const URL = require("sdk/url");
+const promise = require("promise");
+const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {});
+const { FileUtils } = Cu.import("resource://gre/modules/FileUtils.jsm", {});
+const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+const { Task } = require("devtools/shared/task");
+
+const gDecoder = new TextDecoder();
+const gEncoder = new TextEncoder();
+
+/**
+ * A Resource is a single file-like object that can be respresented
+ * as a file for ProjectEditor.
+ *
+ * The Resource class is not exported, and should not be instantiated
+ * Instead, you should use the FileResource class that extends it.
+ *
+ * This object emits the following events:
+ * - "children-changed": When a child has been added or removed.
+ * See setChildren.
+ * - "deleted": When the resource has been deleted.
+ */
+var Resource = Class({
+ extends: EventTarget,
+
+ refresh: function () { return promise.resolve(this); },
+ destroy: function () { },
+ delete: function () { },
+
+ setURI: function (uri) {
+ if (typeof (uri) === "string") {
+ uri = URL.URL(uri);
+ }
+ this.uri = uri;
+ },
+
+ /**
+ * Is there more than 1 child Resource?
+ */
+ get hasChildren() { return this.children && this.children.size > 0; },
+
+ /**
+ * Is this Resource the root (top level for the store)?
+ */
+ get isRoot() {
+ return !this.parent;
+ },
+
+ /**
+ * Sorted array of children for display
+ */
+ get childrenSorted() {
+ if (!this.hasChildren) {
+ return [];
+ }
+
+ return [...this.children].sort((a, b)=> {
+ // Put directories above files.
+ if (a.isDir !== b.isDir) {
+ return b.isDir;
+ }
+ return a.basename.toLowerCase() > b.basename.toLowerCase();
+ });
+ },
+
+ /**
+ * Set the children set of this Resource, and notify of any
+ * additions / removals that happened in the change.
+ */
+ setChildren: function (newChildren) {
+ let oldChildren = this.children || new Set();
+ let change = false;
+
+ for (let child of oldChildren) {
+ if (!newChildren.has(child)) {
+ change = true;
+ child.parent = null;
+ this.store.notifyRemove(child);
+ }
+ }
+
+ for (let child of newChildren) {
+ if (!oldChildren.has(child)) {
+ change = true;
+ child.parent = this;
+ this.store.notifyAdd(child);
+ }
+ }
+
+ this.children = newChildren;
+ if (change) {
+ emit(this, "children-changed", this);
+ }
+ },
+
+ /**
+ * Add a resource to children set and notify of the change.
+ *
+ * @param Resource resource
+ */
+ addChild: function (resource) {
+ this.children = this.children || new Set();
+
+ resource.parent = this;
+ this.children.add(resource);
+ this.store.notifyAdd(resource);
+ emit(this, "children-changed", this);
+ return resource;
+ },
+
+ /**
+ * Checks if current object has child with specific name.
+ *
+ * @param string name
+ */
+ hasChild: function (name) {
+ for (let child of this.children) {
+ if (child.basename === name) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Remove a resource to children set and notify of the change.
+ *
+ * @param Resource resource
+ */
+ removeChild: function (resource) {
+ resource.parent = null;
+ this.children.remove(resource);
+ this.store.notifyRemove(resource);
+ emit(this, "children-changed", this);
+ return resource;
+ },
+
+ /**
+ * Return a set with children, children of children, etc -
+ * gathered recursively.
+ *
+ * @returns Set<Resource>
+ */
+ allDescendants: function () {
+ let set = new Set();
+
+ function addChildren(item) {
+ if (!item.children) {
+ return;
+ }
+
+ for (let child of item.children) {
+ set.add(child);
+ }
+ }
+
+ addChildren(this);
+ for (let item of set) {
+ addChildren(item);
+ }
+
+ return set;
+ },
+});
+
+/**
+ * A FileResource is an implementation of Resource for a File System
+ * backing. This is exported, and should be used instead of Resource.
+ */
+var FileResource = Class({
+ extends: Resource,
+
+ /**
+ * @param Store store
+ * @param String path
+ * @param FileInfo info
+ * https://developer.mozilla.org/en-US/docs/JavaScript_OS.File/OS.File.Info
+ */
+ initialize: function (store, path, info) {
+ this.store = store;
+ this.path = path;
+
+ this.setURI(URL.URL(URL.fromFilename(path)));
+ this._lastReadModification = undefined;
+
+ this.info = info;
+ this.parent = null;
+ },
+
+ toString: function () {
+ return "[FileResource:" + this.path + "]";
+ },
+
+ destroy: function () {
+ if (this._refreshDeferred) {
+ this._refreshDeferred.reject();
+ }
+ this._refreshDeferred = null;
+ },
+
+ /**
+ * Fetch and cache information about this particular file.
+ * https://developer.mozilla.org/en-US/docs/JavaScript_OS.File/OS.File_for_the_main_thread#OS.File.stat
+ *
+ * @returns Promise
+ * Resolves once the File.stat has finished.
+ */
+ refresh: function () {
+ if (this._refreshDeferred) {
+ return this._refreshDeferred.promise;
+ }
+ this._refreshDeferred = promise.defer();
+ OS.File.stat(this.path).then(info => {
+ this.info = info;
+ if (this._refreshDeferred) {
+ this._refreshDeferred.resolve(this);
+ this._refreshDeferred = null;
+ }
+ });
+ return this._refreshDeferred.promise;
+ },
+
+ /**
+ * Return the trailing name component of this Resource
+ */
+ get basename() {
+ return this.path.replace(/\/+$/, "").replace(/\\/g, "/").replace(/.*\//, "");
+ },
+
+ /**
+ * A string to be used when displaying this Resource in views
+ */
+ get displayName() {
+ return this.basename + (this.isDir ? "/" : "");
+ },
+
+ /**
+ * Is this FileResource a directory? Rather than checking children
+ * here, we use this.info. So this could return a false negative
+ * if there was no info passed in on constructor and the first
+ * refresh hasn't yet finished.
+ */
+ get isDir() {
+ if (!this.info) { return false; }
+ return this.info.isDir && !this.info.isSymLink;
+ },
+
+ /**
+ * Read the file as a string asynchronously.
+ *
+ * @returns Promise
+ * Resolves with the text of the file.
+ */
+ load: function () {
+ return OS.File.read(this.path).then(bytes => {
+ return gDecoder.decode(bytes);
+ });
+ },
+
+ /**
+ * Delete the file from the filesystem
+ *
+ * @returns Promise
+ * Resolves when the file is deleted
+ */
+ delete: function () {
+ emit(this, "deleted", this);
+ if (this.isDir) {
+ return OS.File.removeDir(this.path);
+ } else {
+ return OS.File.remove(this.path);
+ }
+ },
+
+ /**
+ * Add a text file as a child of this FileResource.
+ * This instance must be a directory.
+ *
+ * @param string name
+ * The filename (path will be generated based on this.path).
+ * string initial
+ * The content to write to the new file.
+ * @returns Promise
+ * Resolves with the new FileResource once it has
+ * been written to disk.
+ * Rejected if this is not a directory.
+ */
+ createChild: function (name, initial = "") {
+ if (!this.isDir) {
+ return promise.reject(new Error("Cannot add child to a regular file"));
+ }
+
+ let newPath = OS.Path.join(this.path, name);
+
+ let buffer = initial ? gEncoder.encode(initial) : "";
+ return OS.File.writeAtomic(newPath, buffer, {
+ noOverwrite: true
+ }).then(() => {
+ return this.store.refresh();
+ }).then(() => {
+ let resource = this.store.resources.get(newPath);
+ if (!resource) {
+ throw new Error("Error creating " + newPath);
+ }
+ return resource;
+ });
+ },
+
+ /**
+ * Rename the file from the filesystem
+ *
+ * @returns Promise
+ * Resolves with the renamed FileResource.
+ */
+ rename: function (oldName, newName) {
+ let oldPath = OS.Path.join(this.path, oldName);
+ let newPath = OS.Path.join(this.path, newName);
+
+ return OS.File.move(oldPath, newPath).then(() => {
+ return this.store.refresh();
+ }).then(() => {
+ let resource = this.store.resources.get(newPath);
+ if (!resource) {
+ throw new Error("Error creating " + newPath);
+ }
+ return resource;
+ });
+ },
+
+ /**
+ * Write a string to this file.
+ *
+ * @param string content
+ * @returns Promise
+ * Resolves once it has been written to disk.
+ * Rejected if there is an error
+ */
+ save: Task.async(function* (content) {
+ // XXX: writeAtomic was losing permissions after saving on OSX
+ // return OS.File.writeAtomic(this.path, buffer, { tmpPath: this.path + ".tmp" });
+ let buffer = gEncoder.encode(content);
+ let path = this.path;
+ let file = yield OS.File.open(path, {truncate: true});
+ yield file.write(buffer);
+ yield file.close();
+ }),
+
+ /**
+ * Attempts to get the content type from the file.
+ */
+ get contentType() {
+ if (this._contentType) {
+ return this._contentType;
+ }
+ if (this.isDir) {
+ return "x-directory/normal";
+ }
+ try {
+ this._contentType = mimeService.getTypeFromFile(new FileUtils.File(this.path));
+ } catch (ex) {
+ if (ex.name !== "NS_ERROR_NOT_AVAILABLE" &&
+ ex.name !== "NS_ERROR_FAILURE") {
+ console.error(ex, this.path);
+ }
+ this._contentType = null;
+ }
+ return this._contentType;
+ },
+
+ /**
+ * A string used when determining the type of Editor to open for this.
+ * See editors.js -> EditorTypeForResource.
+ */
+ get contentCategory() {
+ const NetworkHelper = require("devtools/shared/webconsole/network-helper");
+ let category = NetworkHelper.mimeCategoryMap[this.contentType];
+ // Special treatment for manifest.webapp.
+ if (!category && this.basename === "manifest.webapp") {
+ return "json";
+ }
+ return category || "txt";
+ }
+});
+
+exports.FileResource = FileResource;
diff --git a/devtools/client/projecteditor/lib/tree.js b/devtools/client/projecteditor/lib/tree.js
new file mode 100644
index 000000000..50597804d
--- /dev/null
+++ b/devtools/client/projecteditor/lib/tree.js
@@ -0,0 +1,593 @@
+/* -*- 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 { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const { emit } = require("sdk/event/core");
+const { EventTarget } = require("sdk/event/target");
+const { merge } = require("sdk/util/object");
+const promise = require("promise");
+const { InplaceEditor } = require("devtools/client/shared/inplace-editor");
+const { on, forget } = require("devtools/client/projecteditor/lib/helpers/event");
+const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {});
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * ResourceContainer is used as the view of a single Resource in
+ * the tree. It is not exported.
+ */
+var ResourceContainer = Class({
+ /**
+ * @param ProjectTreeView tree
+ * @param Resource resource
+ */
+ initialize: function (tree, resource) {
+ this.tree = tree;
+ this.resource = resource;
+ this.elt = null;
+ this.expander = null;
+ this.children = null;
+
+ let doc = tree.doc;
+
+ this.elt = doc.createElementNS(HTML_NS, "li");
+ this.elt.classList.add("child");
+
+ this.line = doc.createElementNS(HTML_NS, "div");
+ this.line.classList.add("child");
+ this.line.classList.add("entry");
+ this.line.setAttribute("theme", "dark");
+ this.line.setAttribute("tabindex", "0");
+
+ this.elt.appendChild(this.line);
+
+ this.highlighter = doc.createElementNS(HTML_NS, "span");
+ this.highlighter.classList.add("highlighter");
+ this.line.appendChild(this.highlighter);
+
+ this.expander = doc.createElementNS(HTML_NS, "span");
+ this.expander.className = "arrow expander";
+ this.expander.setAttribute("open", "");
+ this.line.appendChild(this.expander);
+
+ this.label = doc.createElementNS(HTML_NS, "span");
+ this.label.className = "file-label";
+ this.line.appendChild(this.label);
+
+ this.line.addEventListener("contextmenu", (ev) => {
+ this.select();
+ this.openContextMenu(ev);
+ }, false);
+
+ this.children = doc.createElementNS(HTML_NS, "ul");
+ this.children.classList.add("children");
+
+ this.elt.appendChild(this.children);
+
+ this.line.addEventListener("click", (evt) => {
+ this.select();
+ this.toggleExpansion();
+ evt.stopPropagation();
+ }, false);
+ this.expander.addEventListener("click", (evt) => {
+ this.toggleExpansion();
+ this.select();
+ evt.stopPropagation();
+ }, true);
+
+ if (!this.resource.isRoot) {
+ this.expanded = false;
+ }
+ this.update();
+ },
+
+ toggleExpansion: function () {
+ if (!this.resource.isRoot) {
+ this.expanded = !this.expanded;
+ } else {
+ this.expanded = true;
+ }
+ },
+
+ destroy: function () {
+ this.elt.remove();
+ this.expander.remove();
+ this.highlighter.remove();
+ this.children.remove();
+ this.label.remove();
+ this.elt = this.expander = this.highlighter = this.children = this.label = null;
+ },
+
+ /**
+ * Open the context menu when right clicking on the view.
+ * XXX: We could pass this to plugins to allow themselves
+ * to be register/remove items from the context menu if needed.
+ *
+ * @param Event e
+ */
+ openContextMenu: function (ev) {
+ ev.preventDefault();
+ let popup = this.tree.options.contextMenuPopup;
+ popup.openPopupAtScreen(ev.screenX, ev.screenY, true);
+ },
+
+ /**
+ * Update the view based on the current state of the Resource.
+ */
+ update: function () {
+ let visible = this.tree.options.resourceVisible ?
+ this.tree.options.resourceVisible(this.resource) :
+ true;
+
+ this.elt.hidden = !visible;
+
+ this.tree.options.resourceFormatter(this.resource, this.label);
+
+ this.expander.style.visibility = this.resource.hasChildren ? "visible" : "hidden";
+
+ },
+
+ /**
+ * Select this view in the ProjectTreeView.
+ */
+ select: function () {
+ this.tree.selectContainer(this);
+ },
+
+ /**
+ * @returns Boolean
+ * Is this view currently selected
+ */
+ get selected() {
+ return this.line.classList.contains("selected");
+ },
+
+ /**
+ * Set the selected state in the UI.
+ */
+ set selected(v) {
+ if (v) {
+ this.line.classList.add("selected");
+ } else {
+ this.line.classList.remove("selected");
+ }
+ },
+
+ /**
+ * @returns Boolean
+ * Are any children visible.
+ */
+ get expanded() {
+ return !this.elt.classList.contains("tree-collapsed");
+ },
+
+ /**
+ * Set the visiblity state of children.
+ */
+ set expanded(v) {
+ if (v) {
+ this.elt.classList.remove("tree-collapsed");
+ this.expander.setAttribute("open", "");
+ } else {
+ this.expander.removeAttribute("open");
+ this.elt.classList.add("tree-collapsed");
+ }
+ }
+});
+
+/**
+ * TreeView is a view managing a list of children.
+ * It is not to be instantiated directly - only extended.
+ * Use ProjectTreeView instead.
+ */
+var TreeView = Class({
+ extends: EventTarget,
+
+ /**
+ * @param Document document
+ * @param Object options
+ * - contextMenuPopup: a <menupopup> element
+ * - resourceFormatter: a function(Resource, DOMNode)
+ * that renders the resource into the view
+ * - resourceVisible: a function(Resource) -> Boolean
+ * that determines if the resource should show up.
+ */
+ initialize: function (doc, options) {
+ this.doc = doc;
+ this.options = merge({
+ resourceFormatter: function (resource, elt) {
+ elt.textContent = resource.toString();
+ }
+ }, options);
+ this.models = new Set();
+ this.roots = new Set();
+ this._containers = new Map();
+ this.elt = this.doc.createElementNS(HTML_NS, "div");
+ this.elt.tree = this;
+ this.elt.className = "sources-tree";
+ this.elt.setAttribute("with-arrows", "true");
+ this.elt.setAttribute("theme", "dark");
+ this.elt.setAttribute("flex", "1");
+
+ this.children = this.doc.createElementNS(HTML_NS, "ul");
+ this.elt.appendChild(this.children);
+
+ this.resourceChildrenChanged = this.resourceChildrenChanged.bind(this);
+ this.removeResource = this.removeResource.bind(this);
+ this.updateResource = this.updateResource.bind(this);
+ },
+
+ destroy: function () {
+ this._destroyed = true;
+ this.elt.remove();
+ },
+
+ /**
+ * Helper function to create DOM elements for promptNew and promptEdit
+ */
+ createInputContainer: function () {
+ let inputholder = this.doc.createElementNS(HTML_NS, "div");
+ inputholder.className = "child entry";
+
+ let expander = this.doc.createElementNS(HTML_NS, "span");
+ expander.className = "arrow expander";
+ expander.setAttribute("invisible", "");
+ inputholder.appendChild(expander);
+
+ let placeholder = this.doc.createElementNS(HTML_NS, "div");
+ placeholder.className = "child";
+ inputholder.appendChild(placeholder);
+
+ return {inputholder, placeholder};
+ },
+
+ /**
+ * Prompt the user to create a new file in the tree.
+ *
+ * @param string initial
+ * The suggested starting file name
+ * @param Resource parent
+ * @param Resource sibling
+ * Which resource to put this next to. If not set,
+ * it will be put in front of all other children.
+ *
+ * @returns Promise
+ * Resolves once the prompt has been successful,
+ * Rejected if it is cancelled
+ */
+ promptNew: function (initial, parent, sibling = null) {
+ let deferred = promise.defer();
+
+ let parentContainer = this._containers.get(parent);
+ let item = this.doc.createElement("li");
+ item.className = "child";
+
+ let {inputholder, placeholder} = this.createInputContainer();
+ item.appendChild(inputholder);
+
+ let children = parentContainer.children;
+ sibling = sibling ? this._containers.get(sibling).elt : null;
+ parentContainer.children.insertBefore(item, sibling ? sibling.nextSibling : children.firstChild);
+
+ new InplaceEditor({
+ element: placeholder,
+ initial: initial,
+ preserveTextStyles: true,
+ start: editor => {
+ editor.input.select();
+ },
+ done: function (val, commit) {
+ if (commit) {
+ deferred.resolve(val);
+ } else {
+ deferred.reject(val);
+ }
+ parentContainer.line.focus();
+ },
+ destroy: () => {
+ item.parentNode.removeChild(item);
+ },
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Prompt the user to rename file in the tree.
+ *
+ * @param string initial
+ * The suggested starting file name
+ * @param resource
+ *
+ * @returns Promise
+ * Resolves once the prompt has been successful,
+ * Rejected if it is cancelled
+ */
+ promptEdit: function (initial, resource) {
+ let deferred = promise.defer();
+ let item = this._containers.get(resource).elt;
+ let originalText = item.childNodes[0];
+
+ let {inputholder, placeholder} = this.createInputContainer();
+ item.insertBefore(inputholder, originalText);
+
+ item.removeChild(originalText);
+
+ new InplaceEditor({
+ element: placeholder,
+ initial: initial,
+ preserveTextStyles: true,
+ start: editor => {
+ editor.input.select();
+ },
+ done: function (val, commit) {
+ if (val === initial) {
+ item.insertBefore(originalText, inputholder);
+ }
+
+ item.removeChild(inputholder);
+
+ if (commit) {
+ deferred.resolve(val);
+ } else {
+ deferred.reject(val);
+ }
+ },
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Add a new Store into the TreeView
+ *
+ * @param Store model
+ */
+ addModel: function (model) {
+ if (this.models.has(model)) {
+ // Requesting to add a model that already exists
+ return;
+ }
+ this.models.add(model);
+ let placeholder = this.doc.createElementNS(HTML_NS, "li");
+ placeholder.style.display = "none";
+ this.children.appendChild(placeholder);
+ this.roots.add(model.root);
+ model.root.refresh().then(root => {
+ if (this._destroyed || !this.models.has(model)) {
+ // model may have been removed during the initial refresh.
+ // In this case, do not import the resource or add to DOM, just leave it be.
+ return;
+ }
+ let container = this.importResource(root);
+ container.line.classList.add("entry-group-title");
+ container.line.setAttribute("theme", "dark");
+ this.selectContainer(container);
+
+ this.children.insertBefore(container.elt, placeholder);
+ this.children.removeChild(placeholder);
+ });
+ },
+
+ /**
+ * Remove a Store from the TreeView
+ *
+ * @param Store model
+ */
+ removeModel: function (model) {
+ this.models.delete(model);
+ this.removeResource(model.root);
+ },
+
+
+ /**
+ * Get the ResourceContainer. Used for testing the view.
+ *
+ * @param Resource resource
+ * @returns ResourceContainer
+ */
+ getViewContainer: function (resource) {
+ return this._containers.get(resource);
+ },
+
+ /**
+ * Select a ResourceContainer in the tree.
+ *
+ * @param ResourceContainer container
+ */
+ selectContainer: function (container) {
+ if (this.selectedContainer === container) {
+ return;
+ }
+ if (this.selectedContainer) {
+ this.selectedContainer.selected = false;
+ }
+ this.selectedContainer = container;
+ container.selected = true;
+ emit(this, "selection", container.resource);
+ },
+
+ /**
+ * Select a Resource in the tree.
+ *
+ * @param Resource resource
+ */
+ selectResource: function (resource) {
+ this.selectContainer(this._containers.get(resource));
+ },
+
+ /**
+ * Get the currently selected Resource
+ *
+ * @param Resource resource
+ */
+ getSelectedResource: function () {
+ return this.selectedContainer.resource;
+ },
+
+ /**
+ * Insert a Resource into the view.
+ * Makes a new ResourceContainer if needed
+ *
+ * @param Resource resource
+ */
+ importResource: function (resource) {
+ if (!resource) {
+ return null;
+ }
+
+ if (this._containers.has(resource)) {
+ return this._containers.get(resource);
+ }
+ var container = ResourceContainer(this, resource);
+ this._containers.set(resource, container);
+ this._updateChildren(container);
+
+ on(this, resource, "children-changed", this.resourceChildrenChanged);
+ on(this, resource, "label-change", this.updateResource);
+ on(this, resource, "deleted", this.removeResource);
+
+ return container;
+ },
+
+ /**
+ * Remove a Resource (including children) from the view.
+ *
+ * @param Resource resource
+ */
+ removeResource: function (resource) {
+ let toRemove = resource.allDescendants();
+ toRemove.add(resource);
+ for (let remove of toRemove) {
+ this._removeResource(remove);
+ }
+ },
+
+ /**
+ * Remove an individual Resource (but not children) from the view.
+ *
+ * @param Resource resource
+ */
+ _removeResource: function (resource) {
+ forget(this, resource);
+ if (this._containers.get(resource)) {
+ this._containers.get(resource).destroy();
+ this._containers.delete(resource);
+ }
+ emit(this, "resource-removed", resource);
+ },
+
+ /**
+ * Listener for when a resource has new children.
+ * This can happen as files are being loaded in from FileSystem, for example.
+ *
+ * @param Resource resource
+ */
+ resourceChildrenChanged: function (resource) {
+ this.updateResource(resource);
+ this._updateChildren(this._containers.get(resource));
+ },
+
+ /**
+ * Listener for when a label in the view has been updated.
+ * For example, the 'dirty' plugin marks changed files with an '*'
+ * next to the filename, and notifies with this event.
+ *
+ * @param Resource resource
+ */
+ updateResource: function (resource) {
+ let container = this._containers.get(resource);
+ container.update();
+ },
+
+ /**
+ * Build necessary ResourceContainers for a Resource and its
+ * children, then append them into the view.
+ *
+ * @param ResourceContainer container
+ */
+ _updateChildren: function (container) {
+ let resource = container.resource;
+ let fragment = this.doc.createDocumentFragment();
+ if (resource.children) {
+ for (let child of resource.childrenSorted) {
+ let childContainer = this.importResource(child);
+ fragment.appendChild(childContainer.elt);
+ }
+ }
+
+ while (container.children.firstChild) {
+ container.children.firstChild.remove();
+ }
+
+ container.children.appendChild(fragment);
+ },
+});
+
+/**
+ * ProjectTreeView is the implementation of TreeView
+ * that is exported. This is the class that is to be used
+ * directly.
+ */
+var ProjectTreeView = Class({
+ extends: TreeView,
+
+ /**
+ * See TreeView.initialize
+ *
+ * @param Document document
+ * @param Object options
+ */
+ initialize: function (document, options) {
+ TreeView.prototype.initialize.apply(this, arguments);
+ },
+
+ destroy: function () {
+ this.forgetProject();
+ TreeView.prototype.destroy.apply(this, arguments);
+ },
+
+ /**
+ * Remove current project and empty the tree
+ */
+ forgetProject: function () {
+ if (this.project) {
+ forget(this, this.project);
+ for (let store of this.project.allStores()) {
+ this.removeModel(store);
+ }
+ }
+ },
+
+ /**
+ * Show a project in the tree
+ *
+ * @param Project project
+ * The project to render into a tree
+ */
+ setProject: function (project) {
+ this.forgetProject();
+ this.project = project;
+ if (this.project) {
+ on(this, project, "store-added", this.addModel.bind(this));
+ on(this, project, "store-removed", this.removeModel.bind(this));
+ on(this, project, "project-saved", this.refresh.bind(this));
+ this.refresh();
+ }
+ },
+
+ /**
+ * Refresh the tree with all of the current project stores
+ */
+ refresh: function () {
+ for (let store of this.project.allStores()) {
+ this.addModel(store);
+ }
+ }
+});
+
+exports.ProjectTreeView = ProjectTreeView;
diff --git a/devtools/client/projecteditor/moz.build b/devtools/client/projecteditor/moz.build
new file mode 100644
index 000000000..049493833
--- /dev/null
+++ b/devtools/client/projecteditor/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DIRS += ['lib']
+
+BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
diff --git a/devtools/client/projecteditor/test/.eslintrc.js b/devtools/client/projecteditor/test/.eslintrc.js
new file mode 100644
index 000000000..8d15a76d9
--- /dev/null
+++ b/devtools/client/projecteditor/test/.eslintrc.js
@@ -0,0 +1,6 @@
+"use strict";
+
+module.exports = {
+ // Extend from the shared list of defined globals for mochitests.
+ "extends": "../../../.eslintrc.mochitests.js"
+};
diff --git a/devtools/client/projecteditor/test/browser.ini b/devtools/client/projecteditor/test/browser.ini
new file mode 100644
index 000000000..e7fdc7ae5
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser.ini
@@ -0,0 +1,31 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+ head.js
+ helper_homepage.html
+ helper_edits.js
+
+[browser_projecteditor_app_options.js]
+[browser_projecteditor_confirm_unsaved.js]
+[browser_projecteditor_contextmenu_01.js]
+skip-if = asan # Bug 1083140
+[browser_projecteditor_contextmenu_02.js]
+skip-if = true # Bug 1173950
+[browser_projecteditor_delete_file.js]
+skip-if = e10s # Frequent failures in e10s - Bug 1020027
+[browser_projecteditor_rename_file_01.js]
+[browser_projecteditor_rename_file_02.js]
+[browser_projecteditor_editing_01.js]
+[browser_projecteditor_editors_image.js]
+[browser_projecteditor_external_change.js]
+[browser_projecteditor_immediate_destroy.js]
+[browser_projecteditor_init.js]
+[browser_projecteditor_menubar_01.js]
+[browser_projecteditor_menubar_02.js]
+skip-if = true # Bug 1173950
+[browser_projecteditor_new_file.js]
+[browser_projecteditor_saveall.js]
+[browser_projecteditor_stores.js]
+[browser_projecteditor_tree_selection_01.js]
+[browser_projecteditor_tree_selection_02.js]
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_app_options.js b/devtools/client/projecteditor/test/browser_projecteditor_app_options.js
new file mode 100644
index 000000000..aa608e205
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_app_options.js
@@ -0,0 +1,87 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that options can be changed without resetting the whole
+// editor.
+add_task(function* () {
+
+ let TEMP_PATH = buildTempDirectoryStructure();
+ let projecteditor = yield addProjectEditorTab();
+
+ let resourceBeenAdded = promise.defer();
+ projecteditor.project.once("resource-added", () => {
+ info("A resource has been added");
+ resourceBeenAdded.resolve();
+ });
+
+ info("About to set project to: " + TEMP_PATH);
+ yield projecteditor.setProjectToAppPath(TEMP_PATH, {
+ name: "Test",
+ iconUrl: "chrome://devtools/skin/images/tool-options.svg",
+ projectOverviewURL: SAMPLE_WEBAPP_URL
+ });
+
+ info("Making sure a resource has been added before continuing");
+ yield resourceBeenAdded.promise;
+
+ info("From now on, if a resource is added it should fail");
+ projecteditor.project.on("resource-added", failIfResourceAdded);
+
+ info("Getting ahold and validating the project header DOM");
+ let header = projecteditor.document.querySelector(".entry-group-title");
+ let image = header.querySelector(".project-image");
+ let nameLabel = header.querySelector(".project-name-label");
+ let statusElement = header.querySelector(".project-status");
+ is(statusElement.getAttribute("status"), "unknown", "The status starts out as unknown.");
+ is(nameLabel.textContent, "Test", "The name label has been set correctly");
+ is(image.getAttribute("src"), "chrome://devtools/skin/images/tool-options.svg", "The icon has been set correctly");
+
+ info("About to set project with new options.");
+ yield projecteditor.setProjectToAppPath(TEMP_PATH, {
+ name: "Test2",
+ iconUrl: "chrome://devtools/skin/images/tool-inspector.svg",
+ projectOverviewURL: SAMPLE_WEBAPP_URL,
+ validationStatus: "error"
+ });
+
+ info("Getting ahold of and validating the project header DOM");
+ is(statusElement.getAttribute("status"), "error", "The status has been set correctly.");
+ is(nameLabel.textContent, "Test2", "The name label has been set correctly");
+ is(image.getAttribute("src"), "chrome://devtools/skin/images/tool-inspector.svg", "The icon has been set correctly");
+
+ info("About to set project with new options.");
+ yield projecteditor.setProjectToAppPath(TEMP_PATH, {
+ name: "Test3",
+ iconUrl: "chrome://devtools/skin/images/tool-webconsole.svg",
+ projectOverviewURL: SAMPLE_WEBAPP_URL,
+ validationStatus: "warning"
+ });
+
+ info("Getting ahold of and validating the project header DOM");
+ is(statusElement.getAttribute("status"), "warning", "The status has been set correctly.");
+ is(nameLabel.textContent, "Test3", "The name label has been set correctly");
+ is(image.getAttribute("src"), "chrome://devtools/skin/images/tool-webconsole.svg", "The icon has been set correctly");
+
+ info("About to set project with new options.");
+ yield projecteditor.setProjectToAppPath(TEMP_PATH, {
+ name: "Test4",
+ iconUrl: "chrome://devtools/skin/images/tool-debugger.svg",
+ projectOverviewURL: SAMPLE_WEBAPP_URL,
+ validationStatus: "valid"
+ });
+
+ info("Getting ahold of and validating the project header DOM");
+ is(statusElement.getAttribute("status"), "valid", "The status has been set correctly.");
+ is(nameLabel.textContent, "Test4", "The name label has been set correctly");
+ is(image.getAttribute("src"), "chrome://devtools/skin/images/tool-debugger.svg", "The icon has been set correctly");
+
+ info("Test finished, cleaning up");
+ projecteditor.project.off("resource-added", failIfResourceAdded);
+});
+
+function failIfResourceAdded() {
+ ok(false, "A resource has been added, but it shouldn't have been");
+}
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_confirm_unsaved.js b/devtools/client/projecteditor/test/browser_projecteditor_confirm_unsaved.js
new file mode 100644
index 000000000..72640d243
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_confirm_unsaved.js
@@ -0,0 +1,60 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+loadHelperScript("helper_edits.js");
+
+// Test that a prompt shows up when requested if a file is unsaved.
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ ok(true, "ProjectEditor has loaded");
+
+ let resources = projecteditor.project.allResources();
+ yield selectFile(projecteditor, resources[2]);
+ let editor = projecteditor.currentEditor;
+ let originalText = editor.editor.getText();
+
+ ok(!projecteditor.hasUnsavedResources, "There are no unsaved resources");
+ ok(projecteditor.confirmUnsaved(), "When there are no unsaved changes, confirmUnsaved() is true");
+ editor.editor.setText("bar");
+ editor.editor.setText(originalText);
+ ok(!projecteditor.hasUnsavedResources, "There are no unsaved resources");
+ ok(projecteditor.confirmUnsaved(), "When an editor has changed but is still the original text, confirmUnsaved() is true");
+
+ editor.editor.setText("bar");
+
+ checkConfirmYes(projecteditor);
+ checkConfirmNo(projecteditor);
+});
+
+function checkConfirmYes(projecteditor, container) {
+ function confirmYes(aSubject) {
+ info("confirm dialog observed as expected, going to click OK");
+ Services.obs.removeObserver(confirmYes, "common-dialog-loaded");
+ Services.obs.removeObserver(confirmYes, "tabmodal-dialog-loaded");
+ aSubject.Dialog.ui.button0.click();
+ }
+
+ Services.obs.addObserver(confirmYes, "common-dialog-loaded", false);
+ Services.obs.addObserver(confirmYes, "tabmodal-dialog-loaded", false);
+
+ ok(projecteditor.hasUnsavedResources, "There are unsaved resources");
+ ok(projecteditor.confirmUnsaved(), "When there are unsaved changes, clicking OK makes confirmUnsaved() true");
+}
+
+function checkConfirmNo(projecteditor, container) {
+ function confirmNo(aSubject) {
+ info("confirm dialog observed as expected, going to click cancel");
+ Services.obs.removeObserver(confirmNo, "common-dialog-loaded");
+ Services.obs.removeObserver(confirmNo, "tabmodal-dialog-loaded");
+ aSubject.Dialog.ui.button1.click();
+ }
+
+ Services.obs.addObserver(confirmNo, "common-dialog-loaded", false);
+ Services.obs.addObserver(confirmNo, "tabmodal-dialog-loaded", false);
+
+ ok(projecteditor.hasUnsavedResources, "There are unsaved resources");
+ ok(!projecteditor.confirmUnsaved(), "When there are unsaved changes, clicking cancel makes confirmUnsaved() false");
+}
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_contextmenu_01.js b/devtools/client/projecteditor/test/browser_projecteditor_contextmenu_01.js
new file mode 100644
index 000000000..44ffe1722
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_contextmenu_01.js
@@ -0,0 +1,27 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that context menus append to the correct document.
+
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory({
+ menubar: false
+ });
+ ok(projecteditor, "ProjectEditor has loaded");
+
+ let contextMenuPopup = projecteditor.document.querySelector("#context-menu-popup");
+ let textEditorContextMenuPopup = projecteditor.document.querySelector("#texteditor-context-popup");
+ ok(contextMenuPopup, "The menu has loaded in the projecteditor document");
+ ok(textEditorContextMenuPopup, "The menu has loaded in the projecteditor document");
+
+ let projecteditor2 = yield addProjectEditorTabForTempDirectory();
+ contextMenuPopup = projecteditor2.document.getElementById("context-menu-popup");
+ textEditorContextMenuPopup = projecteditor2.document.getElementById("texteditor-context-popup");
+ ok(!contextMenuPopup, "The menu has NOT loaded in the projecteditor document");
+ ok(!textEditorContextMenuPopup, "The menu has NOT loaded in the projecteditor document");
+ ok(content.document.querySelector("#context-menu-popup"), "The menu has loaded in the specified element");
+ ok(content.document.querySelector("#texteditor-context-popup"), "The menu has loaded in the specified element");
+});
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_contextmenu_02.js b/devtools/client/projecteditor/test/browser_projecteditor_contextmenu_02.js
new file mode 100644
index 000000000..cf43b3e21
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_contextmenu_02.js
@@ -0,0 +1,66 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+loadHelperScript("helper_edits.js");
+
+// Test context menu enabled / disabled state in editor
+
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ ok(projecteditor, "ProjectEditor has loaded");
+
+ let {textEditorContextMenuPopup} = projecteditor;
+
+ // Update menu items for a clean slate, so previous tests cannot
+ // affect paste, and possibly other side effects
+ projecteditor._updateMenuItems();
+
+ let cmdDelete = textEditorContextMenuPopup.querySelector("[command=cmd_delete]");
+ let cmdSelectAll = textEditorContextMenuPopup.querySelector("[command=cmd_selectAll]");
+ let cmdCut = textEditorContextMenuPopup.querySelector("[command=cmd_cut]");
+ let cmdCopy = textEditorContextMenuPopup.querySelector("[command=cmd_copy]");
+ let cmdPaste = textEditorContextMenuPopup.querySelector("[command=cmd_paste]");
+
+ info("Opening resource");
+ let resource = projecteditor.project.allResources()[2];
+ yield selectFile(projecteditor, resource);
+ let editor = projecteditor.currentEditor;
+ editor.editor.focus();
+
+ info("Opening context menu on resource");
+ yield openContextMenuForEditor(editor, textEditorContextMenuPopup);
+
+ is(cmdDelete.getAttribute("disabled"), "true", "cmdDelete is disabled");
+ is(cmdSelectAll.getAttribute("disabled"), "", "cmdSelectAll is enabled");
+ is(cmdCut.getAttribute("disabled"), "true", "cmdCut is disabled");
+ is(cmdCopy.getAttribute("disabled"), "true", "cmdCopy is disabled");
+ is(cmdPaste.getAttribute("disabled"), "", "cmdPaste is enabled");
+
+ info("Setting a selection and repening context menu on resource");
+ yield closeContextMenuForEditor(editor, textEditorContextMenuPopup);
+ editor.editor.setSelection({line: 0, ch: 0}, {line: 0, ch: 2});
+ yield openContextMenuForEditor(editor, textEditorContextMenuPopup);
+
+ is(cmdDelete.getAttribute("disabled"), "", "cmdDelete is enabled");
+ is(cmdSelectAll.getAttribute("disabled"), "", "cmdSelectAll is enabled");
+ is(cmdCut.getAttribute("disabled"), "", "cmdCut is enabled");
+ is(cmdCopy.getAttribute("disabled"), "", "cmdCopy is enabled");
+ is(cmdPaste.getAttribute("disabled"), "", "cmdPaste is enabled");
+});
+
+function* openContextMenuForEditor(editor, contextMenu) {
+ let editorDoc = editor.editor.container.contentDocument;
+ let shown = onPopupShow(contextMenu);
+ EventUtils.synthesizeMouse(editorDoc.body, 2, 2,
+ {type: "contextmenu", button: 2}, editorDoc.defaultView);
+ yield shown;
+}
+function* closeContextMenuForEditor(editor, contextMenu) {
+ let editorDoc = editor.editor.container.contentDocument;
+ let hidden = onPopupHidden(contextMenu);
+ contextMenu.hidePopup();
+ yield hidden;
+}
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_delete_file.js b/devtools/client/projecteditor/test/browser_projecteditor_delete_file.js
new file mode 100644
index 000000000..446c1dbcb
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_delete_file.js
@@ -0,0 +1,85 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test tree selection functionality
+
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ ok(true, "ProjectEditor has loaded");
+
+ let root = [...projecteditor.project.allStores()][0].root;
+ is(root.path, TEMP_PATH, "The root store is set to the correct temp path.");
+ for (let child of root.children) {
+ yield deleteWithContextMenu(projecteditor, projecteditor.projectTree.getViewContainer(child));
+ }
+
+ yield testDeleteOnRoot(projecteditor, projecteditor.projectTree.getViewContainer(root));
+});
+
+
+function openContextMenuOn(node) {
+ EventUtils.synthesizeMouseAtCenter(
+ node,
+ {button: 2, type: "contextmenu"},
+ node.ownerDocument.defaultView
+ );
+}
+
+function* testDeleteOnRoot(projecteditor, container) {
+ let popup = projecteditor.contextMenuPopup;
+ let oncePopupShown = onPopupShow(popup);
+ openContextMenuOn(container.label);
+ yield oncePopupShown;
+
+ let deleteCommand = popup.querySelector("[command=cmd-delete]");
+ ok(deleteCommand, "Delete command exists in popup");
+ is(deleteCommand.getAttribute("hidden"), "true", "Delete command is hidden");
+}
+
+function deleteWithContextMenu(projecteditor, container) {
+ let defer = promise.defer();
+
+ let popup = projecteditor.contextMenuPopup;
+ let resource = container.resource;
+ info("Going to attempt deletion for: " + resource.path);
+
+ onPopupShow(popup).then(function () {
+ let deleteCommand = popup.querySelector("[command=cmd-delete]");
+ ok(deleteCommand, "Delete command exists in popup");
+ is(deleteCommand.getAttribute("hidden"), "", "Delete command is visible");
+ is(deleteCommand.getAttribute("disabled"), "", "Delete command is enabled");
+
+ function onConfirmShown(aSubject) {
+ info("confirm dialog observed as expected");
+ Services.obs.removeObserver(onConfirmShown, "common-dialog-loaded");
+ Services.obs.removeObserver(onConfirmShown, "tabmodal-dialog-loaded");
+
+ projecteditor.project.on("refresh-complete", function refreshComplete() {
+ projecteditor.project.off("refresh-complete", refreshComplete);
+ OS.File.stat(resource.path).then(() => {
+ ok(false, "The file was not deleted");
+ defer.resolve();
+ }, (ex) => {
+ ok(ex instanceof OS.File.Error && ex.becauseNoSuchFile, "OS.File.stat promise was rejected because the file is gone");
+ defer.resolve();
+ });
+ });
+
+ // Click the 'OK' button
+ aSubject.Dialog.ui.button0.click();
+ }
+
+ Services.obs.addObserver(onConfirmShown, "common-dialog-loaded", false);
+ Services.obs.addObserver(onConfirmShown, "tabmodal-dialog-loaded", false);
+
+ deleteCommand.click();
+ popup.hidePopup();
+ });
+
+ openContextMenuOn(container.label);
+
+ return defer.promise;
+}
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_editing_01.js b/devtools/client/projecteditor/test/browser_projecteditor_editing_01.js
new file mode 100644
index 000000000..c7ff1c0be
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_editing_01.js
@@ -0,0 +1,70 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+thisTestLeaksUncaughtRejectionsAndShouldBeFixed("destroy");
+
+loadHelperScript("helper_edits.js");
+
+// Test ProjectEditor basic functionality
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ let TEMP_PATH = projecteditor.project.allPaths()[0];
+
+ is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly.");
+
+ ok(projecteditor.currentEditor, "There is an editor for projecteditor");
+ let resources = projecteditor.project.allResources();
+
+ for (let data of helperEditData) {
+ info("Processing " + data.path);
+ let resource = resources.filter(r=>r.basename === data.basename)[0];
+ yield selectFile(projecteditor, resource);
+ yield testEditFile(projecteditor, getTempFile(data.path).path, data.newContent);
+ }
+});
+
+function* testEditFile(projecteditor, filePath, newData) {
+ info("Testing file editing for: " + filePath);
+
+ let initialData = yield getFileData(filePath);
+ let editor = projecteditor.currentEditor;
+ let resource = projecteditor.resourceFor(editor);
+ let viewContainer = projecteditor.projectTree.getViewContainer(resource);
+ let originalTreeLabel = viewContainer.label.textContent;
+
+ is(resource.path, filePath, "Resource path is set correctly");
+ is(editor.editor.getText(), initialData, "Editor is loaded with correct file contents");
+
+ info("Setting text in the editor and doing checks before saving");
+
+ editor.editor.undo();
+ editor.editor.undo();
+ is(editor.editor.getText(), initialData, "Editor is still loaded with correct contents after undo");
+
+ editor.editor.setText(newData);
+ is(editor.editor.getText(), newData, "Editor has been filled with new data");
+ is(viewContainer.label.textContent, "*" + originalTreeLabel, "Label is marked as changed");
+
+ info("Saving the editor and checking to make sure the file gets saved on disk");
+
+ editor.save(resource);
+
+ let savedResource = yield onceEditorSave(projecteditor);
+
+ is(viewContainer.label.textContent, originalTreeLabel, "Label is unmarked as changed");
+ is(savedResource.path, filePath, "The saved resouce path matches the original file path");
+ is(savedResource, resource, "The saved resource is the same as the original resource");
+
+ let savedData = yield getFileData(filePath);
+ is(savedData, newData, "Data has been correctly saved to disk");
+
+ info("Finished checking saving for " + filePath);
+
+}
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_editors_image.js b/devtools/client/projecteditor/test/browser_projecteditor_editors_image.js
new file mode 100644
index 000000000..0b19cb5d1
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_editors_image.js
@@ -0,0 +1,74 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+thisTestLeaksUncaughtRejectionsAndShouldBeFixed("destroy");
+
+loadHelperScript("helper_edits.js");
+
+// Test ProjectEditor image editor functionality
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ let TEMP_PATH = projecteditor.project.allPaths()[0];
+
+ is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly.");
+
+ ok(projecteditor.currentEditor, "There is an editor for projecteditor");
+ let resources = projecteditor.project.allResources();
+
+ let helperImageData = [
+ {
+ basename: "16x16.png",
+ path: "img/icons/16x16.png"
+ },
+ {
+ basename: "32x32.png",
+ path: "img/icons/32x32.png"
+ },
+ {
+ basename: "128x128.png",
+ path: "img/icons/128x128.png"
+ },
+ ];
+
+ for (let data of helperImageData) {
+ info("Processing " + data.path);
+ let resource = resources.filter(r=>r.basename === data.basename)[0];
+ yield selectFile(projecteditor, resource);
+ yield testEditor(projecteditor, getTempFile(data.path).path);
+ }
+});
+
+function* testEditor(projecteditor, filePath) {
+ info("Testing file editing for: " + filePath);
+
+ let editor = projecteditor.currentEditor;
+ let resource = projecteditor.resourceFor(editor);
+
+ is(resource.path, filePath, "Resource path is set correctly");
+
+ let images = editor.elt.querySelectorAll("image");
+ is(images.length, 1, "There is one image inside the editor");
+ is(images[0], editor.image, "The image property is set correctly with the DOM");
+ is(editor.image.getAttribute("src"), resource.uri, "The image has the resource URL");
+
+ info("Selecting another resource, then reselecting this one");
+ projecteditor.projectTree.selectResource(resource.store.root);
+ yield onceEditorActivated(projecteditor);
+ projecteditor.projectTree.selectResource(resource);
+ yield onceEditorActivated(projecteditor);
+
+ editor = projecteditor.currentEditor;
+ images = editor.elt.querySelectorAll("image");
+ ok(images.length, 1, "There is one image inside the editor");
+ is(images[0], editor.image, "The image property is set correctly with the DOM");
+ is(editor.image.getAttribute("src"), resource.uri, "The image has the resource URL");
+
+ info("Finished checking saving for " + filePath);
+}
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_external_change.js b/devtools/client/projecteditor/test/browser_projecteditor_external_change.js
new file mode 100644
index 000000000..12d90a869
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_external_change.js
@@ -0,0 +1,84 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+loadHelperScript("helper_edits.js");
+
+// Test ProjectEditor reaction to external changes (made outside of the)
+// editor.
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ let TEMP_PATH = projecteditor.project.allPaths()[0];
+
+ is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly.");
+
+ ok(projecteditor.currentEditor, "There is an editor for projecteditor");
+ let resources = projecteditor.project.allResources();
+
+ for (let data of helperEditData) {
+ info("Processing " + data.path);
+ let resource = resources.filter(r=>r.basename === data.basename)[0];
+ yield selectFile(projecteditor, resource);
+ yield testChangeFileExternally(projecteditor, getTempFile(data.path).path, data.newContent);
+ yield testChangeUnsavedFileExternally(projecteditor, getTempFile(data.path).path, data.newContent + "[changed]");
+ }
+});
+
+function* testChangeUnsavedFileExternally(projecteditor, filePath, newData) {
+ info("Testing file external changes for: " + filePath);
+
+ let editor = projecteditor.currentEditor;
+ let resource = projecteditor.resourceFor(editor);
+ let initialData = yield getFileData(filePath);
+
+ is(resource.path, filePath, "Resource path is set correctly");
+ is(editor.editor.getText(), initialData, "Editor is loaded with correct file contents");
+
+ info("Editing but not saving file in project editor");
+ ok(editor.isClean(), "Editor is clean");
+ editor.editor.setText("foobar");
+ ok(!editor.isClean(), "Editor is dirty");
+
+ info("Editor has been selected, writing to file externally");
+ yield writeToFile(resource.path, newData);
+
+ info("Selecting another resource, then reselecting this one");
+ projecteditor.projectTree.selectResource(resource.store.root);
+ yield onceEditorActivated(projecteditor);
+ projecteditor.projectTree.selectResource(resource);
+ yield onceEditorActivated(projecteditor);
+
+ editor = projecteditor.currentEditor;
+ info("Checking to make sure the editor is now populated correctly");
+ is(editor.editor.getText(), "foobar", "Editor has not been updated with new file contents");
+
+ info("Finished checking saving for " + filePath);
+}
+
+function* testChangeFileExternally(projecteditor, filePath, newData) {
+ info("Testing file external changes for: " + filePath);
+
+ let editor = projecteditor.currentEditor;
+ let resource = projecteditor.resourceFor(editor);
+ let initialData = yield getFileData(filePath);
+
+ is(resource.path, filePath, "Resource path is set correctly");
+ is(editor.editor.getText(), initialData, "Editor is loaded with correct file contents");
+
+ info("Editor has been selected, writing to file externally");
+ yield writeToFile(resource.path, newData);
+
+ info("Selecting another resource, then reselecting this one");
+ projecteditor.projectTree.selectResource(resource.store.root);
+ yield onceEditorActivated(projecteditor);
+ projecteditor.projectTree.selectResource(resource);
+ yield onceEditorActivated(projecteditor);
+
+ editor = projecteditor.currentEditor;
+ info("Checking to make sure the editor is now populated correctly");
+ is(editor.editor.getText(), newData, "Editor has been updated with correct file contents");
+
+ info("Finished checking saving for " + filePath);
+}
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_immediate_destroy.js b/devtools/client/projecteditor/test/browser_projecteditor_immediate_destroy.js
new file mode 100644
index 000000000..0773be55c
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_immediate_destroy.js
@@ -0,0 +1,93 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+thisTestLeaksUncaughtRejectionsAndShouldBeFixed("destroy");
+thisTestLeaksUncaughtRejectionsAndShouldBeFixed("TypeError: this.window is null");
+
+// Test that projecteditor can be destroyed in various states of loading
+// without causing any leaks or exceptions.
+
+add_task(function* () {
+
+ info("Testing tab closure when projecteditor is in various states");
+ let loaderUrl = "chrome://devtools/content/projecteditor/chrome/content/projecteditor-test.xul";
+
+ yield addTab(loaderUrl).then(() => {
+ let iframe = content.document.getElementById("projecteditor-iframe");
+ ok(iframe, "Tab has placeholder iframe for projecteditor");
+
+ info("Closing the tab without doing anything");
+ gBrowser.removeCurrentTab();
+ });
+
+ yield addTab(loaderUrl).then(() => {
+ let iframe = content.document.getElementById("projecteditor-iframe");
+ ok(iframe, "Tab has placeholder iframe for projecteditor");
+
+ let projecteditor = ProjectEditor.ProjectEditor();
+ ok(projecteditor, "ProjectEditor has been initialized");
+
+ info("Closing the tab before attempting to load");
+ gBrowser.removeCurrentTab();
+ });
+
+ yield addTab(loaderUrl).then(() => {
+ let iframe = content.document.getElementById("projecteditor-iframe");
+ ok(iframe, "Tab has placeholder iframe for projecteditor");
+
+ let projecteditor = ProjectEditor.ProjectEditor();
+ ok(projecteditor, "ProjectEditor has been initialized");
+
+ projecteditor.load(iframe);
+
+ info("Closing the tab after a load is requested, but before load is finished");
+ gBrowser.removeCurrentTab();
+ });
+
+ yield addTab(loaderUrl).then(() => {
+ let iframe = content.document.getElementById("projecteditor-iframe");
+ ok(iframe, "Tab has placeholder iframe for projecteditor");
+
+ let projecteditor = ProjectEditor.ProjectEditor();
+ ok(projecteditor, "ProjectEditor has been initialized");
+
+ return projecteditor.load(iframe).then(() => {
+ info("Closing the tab after a load has been requested and finished");
+ gBrowser.removeCurrentTab();
+ });
+ });
+
+ yield addTab(loaderUrl).then(() => {
+ let iframe = content.document.getElementById("projecteditor-iframe");
+ ok(iframe, "Tab has placeholder iframe for projecteditor");
+
+ let projecteditor = ProjectEditor.ProjectEditor(iframe);
+ ok(projecteditor, "ProjectEditor has been initialized");
+
+ let loadedDone = promise.defer();
+ projecteditor.loaded.then(() => {
+ ok(false, "Loaded has finished after destroy() has been called");
+ loadedDone.resolve();
+ }, () => {
+ ok(true, "Loaded has been rejected after destroy() has been called");
+ loadedDone.resolve();
+ });
+
+ projecteditor.destroy();
+
+ return loadedDone.promise.then(() => {
+ gBrowser.removeCurrentTab();
+ });
+ });
+
+ finish();
+});
+
+
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_init.js b/devtools/client/projecteditor/test/browser_projecteditor_init.js
new file mode 100644
index 000000000..3ee947e0d
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_init.js
@@ -0,0 +1,18 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that projecteditor can be initialized.
+
+function test() {
+ info("Initializing projecteditor");
+ addProjectEditorTab().then((projecteditor) => {
+ ok(projecteditor, "Load callback has been called");
+ ok(projecteditor.shells, "ProjectEditor has shells");
+ ok(projecteditor.project, "ProjectEditor has a project");
+ finish();
+ });
+}
+
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_menubar_01.js b/devtools/client/projecteditor/test/browser_projecteditor_menubar_01.js
new file mode 100644
index 000000000..1641169e7
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_menubar_01.js
@@ -0,0 +1,28 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that menu bar appends to the correct document.
+
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory({
+ menubar: false
+ });
+ ok(projecteditor, "ProjectEditor has loaded");
+
+ let fileMenu = projecteditor.document.getElementById("file-menu");
+ let editMenu = projecteditor.document.getElementById("edit-menu");
+ ok(fileMenu, "The menu has loaded in the projecteditor document");
+ ok(editMenu, "The menu has loaded in the projecteditor document");
+
+ let projecteditor2 = yield addProjectEditorTabForTempDirectory();
+ let menubar = projecteditor2.menubar;
+ fileMenu = projecteditor2.document.getElementById("file-menu");
+ editMenu = projecteditor2.document.getElementById("edit-menu");
+ ok(!fileMenu, "The menu has NOT loaded in the projecteditor document");
+ ok(!editMenu, "The menu has NOT loaded in the projecteditor document");
+ ok(content.document.querySelector("#file-menu"), "The menu has loaded in the specified element");
+ ok(content.document.querySelector("#edit-menu"), "The menu has loaded in the specified element");
+});
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_menubar_02.js b/devtools/client/projecteditor/test/browser_projecteditor_menubar_02.js
new file mode 100644
index 000000000..d0d41f743
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_menubar_02.js
@@ -0,0 +1,123 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+loadHelperScript("helper_edits.js");
+
+// Test menu bar enabled / disabled state.
+
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ let menubar = projecteditor.menubar;
+
+ // Update menu items for a clean slate, so previous tests cannot
+ // affect paste, and possibly other side effects
+ projecteditor._updateMenuItems();
+
+ // let projecteditor = yield addProjectEditorTabForTempDirectory();
+ ok(projecteditor, "ProjectEditor has loaded");
+
+ let fileMenu = menubar.querySelector("#file-menu");
+ let editMenu = menubar.querySelector("#edit-menu");
+ ok(fileMenu, "The menu has loaded in the projecteditor document");
+ ok(editMenu, "The menu has loaded in the projecteditor document");
+
+ let cmdNew = fileMenu.querySelector("[command=cmd-new]");
+ let cmdSave = fileMenu.querySelector("[command=cmd-save]");
+ let cmdSaveas = fileMenu.querySelector("[command=cmd-saveas]");
+
+ let cmdUndo = editMenu.querySelector("[command=cmd_undo]");
+ let cmdRedo = editMenu.querySelector("[command=cmd_redo]");
+ let cmdCut = editMenu.querySelector("[command=cmd_cut]");
+ let cmdCopy = editMenu.querySelector("[command=cmd_copy]");
+ let cmdPaste = editMenu.querySelector("[command=cmd_paste]");
+
+ info("Checking initial state of menus");
+ yield openAndCloseMenu(fileMenu);
+ yield openAndCloseMenu(editMenu);
+
+ is(cmdNew.getAttribute("disabled"), "", "File menu item is enabled");
+ is(cmdSave.getAttribute("disabled"), "true", "File menu item is disabled");
+ is(cmdSaveas.getAttribute("disabled"), "true", "File menu item is disabled");
+
+ is(cmdUndo.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdRedo.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdCut.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdCopy.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdPaste.getAttribute("disabled"), "true", "Edit menu item is disabled");
+
+ projecteditor.menuEnabled = false;
+
+ info("Checking with menuEnabled = false");
+ yield openAndCloseMenu(fileMenu);
+ yield openAndCloseMenu(editMenu);
+
+ is(cmdNew.getAttribute("disabled"), "true", "File menu item is disabled");
+ is(cmdSave.getAttribute("disabled"), "true", "File menu item is disabled");
+ is(cmdSaveas.getAttribute("disabled"), "true", "File menu item is disabled");
+
+ is(cmdUndo.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdRedo.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdCut.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdCopy.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdPaste.getAttribute("disabled"), "true", "Edit menu item is disabled");
+
+ info("Checking with menuEnabled=true");
+ projecteditor.menuEnabled = true;
+
+ yield openAndCloseMenu(fileMenu);
+ yield openAndCloseMenu(editMenu);
+
+ is(cmdNew.getAttribute("disabled"), "", "File menu item is enabled");
+ is(cmdSave.getAttribute("disabled"), "true", "File menu item is disabled");
+ is(cmdSaveas.getAttribute("disabled"), "true", "File menu item is disabled");
+
+ is(cmdUndo.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdRedo.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdCut.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdCopy.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdPaste.getAttribute("disabled"), "true", "Edit menu item is disabled");
+
+ info("Checking with resource selected");
+ let resource = projecteditor.project.allResources()[2];
+ yield selectFile(projecteditor, resource);
+ let editor = projecteditor.currentEditor;
+
+ let onChange = promise.defer();
+
+ projecteditor.on("onEditorChange", () => {
+ info("onEditorChange has been detected");
+ onChange.resolve();
+ });
+ editor.editor.focus();
+ EventUtils.synthesizeKey("f", { }, projecteditor.window);
+
+ yield onChange;
+ yield openAndCloseMenu(fileMenu);
+ yield openAndCloseMenu(editMenu);
+
+ is(cmdNew.getAttribute("disabled"), "", "File menu item is enabled");
+ is(cmdSave.getAttribute("disabled"), "", "File menu item is enabled");
+ is(cmdSaveas.getAttribute("disabled"), "", "File menu item is enabled");
+
+ // Use editor.canUndo() to see if this is failing - the menu disabled property
+ // should be in sync with this because of isCommandEnabled in editor.js.
+ info('cmdUndo.getAttribute("disabled") is: "' + cmdUndo.getAttribute("disabled") + '"');
+ ok(editor.editor.canUndo(), "Edit menu item is enabled");
+
+ is(cmdRedo.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdCut.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdCopy.getAttribute("disabled"), "true", "Edit menu item is disabled");
+ is(cmdPaste.getAttribute("disabled"), "", "Edit menu item is enabled");
+});
+
+function* openAndCloseMenu(menu) {
+ let shown = onPopupShow(menu);
+ EventUtils.synthesizeMouseAtCenter(menu, {}, menu.ownerDocument.defaultView);
+ yield shown;
+ let hidden = onPopupHidden(menu);
+ EventUtils.synthesizeMouseAtCenter(menu, {}, menu.ownerDocument.defaultView);
+ yield hidden;
+}
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_new_file.js b/devtools/client/projecteditor/test/browser_projecteditor_new_file.js
new file mode 100644
index 000000000..aaaee0369
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_new_file.js
@@ -0,0 +1,13 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test tree selection functionality
+
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ ok(projecteditor, "ProjectEditor has loaded");
+
+});
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_rename_file_01.js b/devtools/client/projecteditor/test/browser_projecteditor_rename_file_01.js
new file mode 100644
index 000000000..914fa73cc
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_rename_file_01.js
@@ -0,0 +1,19 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test file rename functionality
+
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ ok(true, "ProjectEditor has loaded");
+
+ let root = [...projecteditor.project.allStores()][0].root;
+ is(root.path, TEMP_PATH, "The root store is set to the correct temp path.");
+ for (let child of root.children) {
+ yield renameWithContextMenu(projecteditor,
+ projecteditor.projectTree.getViewContainer(child), ".renamed");
+ }
+});
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_rename_file_02.js b/devtools/client/projecteditor/test/browser_projecteditor_rename_file_02.js
new file mode 100644
index 000000000..a2964da2a
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_rename_file_02.js
@@ -0,0 +1,26 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test file rename functionality with non ascii characters
+
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ ok(true, "ProjectEditor has loaded");
+
+ let root = [...projecteditor.project.allStores()][0].root;
+ is(root.path, TEMP_PATH, "The root store is set to the correct temp path.");
+
+ let childrenList = [];
+ for (let child of root.children) {
+ yield renameWithContextMenu(projecteditor,
+ projecteditor.projectTree.getViewContainer(child), ".ren\u0061\u0308med");
+ childrenList.push(child.basename + ".ren\u0061\u0308med");
+ }
+ for (let child of root.children) {
+ is(childrenList.indexOf(child.basename) == -1, false,
+ "Failed to update tree with non-ascii character");
+ }
+});
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_saveall.js b/devtools/client/projecteditor/test/browser_projecteditor_saveall.js
new file mode 100644
index 000000000..2468ea4fc
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_saveall.js
@@ -0,0 +1,64 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+thisTestLeaksUncaughtRejectionsAndShouldBeFixed("destroy");
+
+loadHelperScript("helper_edits.js");
+
+// Test ProjectEditor basic functionality
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ let TEMP_PATH = projecteditor.project.allPaths()[0];
+
+ is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly.");
+
+ ok(projecteditor.currentEditor, "There is an editor for projecteditor");
+ let resources = projecteditor.project.allResources();
+
+ for (let data of helperEditData) {
+ info("Processing " + data.path);
+ let resource = resources.filter(r=>r.basename === data.basename)[0];
+ yield selectFile(projecteditor, resource);
+ yield editFile(projecteditor, getTempFile(data.path).path, data.newContent);
+ }
+
+ info("Saving all resources");
+ ok(projecteditor.hasUnsavedResources, "hasUnsavedResources");
+ yield projecteditor.saveAllFiles();
+ ok(!projecteditor.hasUnsavedResources, "!hasUnsavedResources");
+ for (let data of helperEditData) {
+ let filePath = getTempFile(data.path).path;
+ info("Asserting that data at " + filePath + " has been saved");
+ let resource = resources.filter(r=>r.basename === data.basename)[0];
+ yield selectFile(projecteditor, resource);
+ let editor = projecteditor.currentEditor;
+ let savedData = yield getFileData(filePath);
+ is(savedData, data.newContent, "Data has been correctly saved to disk");
+ }
+});
+
+function* editFile(projecteditor, filePath, newData) {
+ info("Testing file editing for: " + filePath);
+
+ let initialData = yield getFileData(filePath);
+ let editor = projecteditor.currentEditor;
+ let resource = projecteditor.resourceFor(editor);
+ let viewContainer = projecteditor.projectTree.getViewContainer(resource);
+ let originalTreeLabel = viewContainer.label.textContent;
+
+ is(resource.path, filePath, "Resource path is set correctly");
+ is(editor.editor.getText(), initialData, "Editor is loaded with correct file contents");
+
+ info("Setting text in the editor");
+
+ editor.editor.setText(newData);
+ is(editor.editor.getText(), newData, "Editor has been filled with new data");
+ is(viewContainer.label.textContent, "*" + originalTreeLabel, "Label is marked as changed");
+}
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_stores.js b/devtools/client/projecteditor/test/browser_projecteditor_stores.js
new file mode 100644
index 000000000..c85a7526b
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_stores.js
@@ -0,0 +1,16 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test ProjectEditor basic functionality
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ let TEMP_PATH = projecteditor.project.allPaths()[0];
+ is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly.");
+
+ is(projecteditor.project.allPaths().length, 1, "1 path is set");
+ projecteditor.project.removeAllStores();
+ is(projecteditor.project.allPaths().length, 0, "No paths are remaining");
+});
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_tree_selection_01.js b/devtools/client/projecteditor/test/browser_projecteditor_tree_selection_01.js
new file mode 100644
index 000000000..0a98f7122
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_tree_selection_01.js
@@ -0,0 +1,98 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test tree selection functionality
+
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ let TEMP_PATH = projecteditor.project.allPaths()[0];
+
+ is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly.");
+
+ ok(projecteditor.currentEditor, "There is an editor for projecteditor");
+ let resources = projecteditor.project.allResources();
+
+ is(
+ resources.map(r=>r.basename).join("|"),
+ TEMP_FOLDER_NAME + "|css|styles.css|data|img|icons|128x128.png|16x16.png|32x32.png|vector.svg|fake.png|js|script.js|index.html|LICENSE|README.md",
+ "Resources came through in proper order"
+ );
+
+ for (let i = 0; i < resources.length; i++) {
+ yield selectFileFirstLoad(projecteditor, resources[i]);
+ }
+ for (let i = 0; i < resources.length; i++) {
+ yield selectFileSubsequentLoad(projecteditor, resources[i]);
+ }
+ for (let i = 0; i < resources.length; i++) {
+ yield selectFileSubsequentLoad(projecteditor, resources[i]);
+ }
+});
+
+function* selectFileFirstLoad(projecteditor, resource) {
+ ok(resource && resource.path, "A valid resource has been passed in for selection " + (resource && resource.path));
+ projecteditor.projectTree.selectResource(resource);
+ let container = projecteditor.projectTree.getViewContainer(resource);
+
+ if (resource.isRoot) {
+ ok(container.expanded, "The root directory is expanded by default.");
+ container.line.click();
+ ok(container.expanded, "Clicking on the line does not toggles expansion.");
+ return;
+ }
+ if (resource.isDir) {
+ ok(!container.expanded, "A directory is not expanded by default.");
+ container.line.click();
+ ok(container.expanded, "Clicking on the line toggles expansion.");
+ container.line.click();
+ ok(!container.expanded, "Clicking on the line toggles expansion.");
+ return;
+ }
+
+ let [editorCreated, editorLoaded, editorActivated] = yield promise.all([
+ onceEditorCreated(projecteditor),
+ onceEditorLoad(projecteditor),
+ onceEditorActivated(projecteditor)
+ ]);
+
+ is(editorCreated, projecteditor.currentEditor, "Editor has been created for " + resource.path);
+ is(editorActivated, projecteditor.currentEditor, "Editor has been activated for " + resource.path);
+ is(editorLoaded, projecteditor.currentEditor, "Editor has been loaded for " + resource.path);
+}
+
+function* selectFileSubsequentLoad(projecteditor, resource) {
+ ok(resource && resource.path, "A valid resource has been passed in for selection " + (resource && resource.path));
+ projecteditor.projectTree.selectResource(resource);
+
+ if (resource.isDir) {
+ return;
+ }
+
+ // Make sure text editors are focused immediately when selected.
+ let focusPromise = promise.resolve();
+ if (projecteditor.currentEditor.editor) {
+ focusPromise = onEditorFocus(projecteditor.currentEditor);
+ }
+
+ // Only activated should fire the next time
+ // (may add load() if we begin checking for changes from disk)
+ let [editorActivated] = yield promise.all([
+ onceEditorActivated(projecteditor)
+ ]);
+
+ is(editorActivated, projecteditor.currentEditor, "Editor has been activated for " + resource.path);
+
+ yield focusPromise;
+}
+
+function onEditorFocus(editor) {
+ let def = promise.defer();
+ editor.on("focus", function focus() {
+ editor.off("focus", focus);
+ def.resolve();
+ });
+ return def.promise;
+}
diff --git a/devtools/client/projecteditor/test/browser_projecteditor_tree_selection_02.js b/devtools/client/projecteditor/test/browser_projecteditor_tree_selection_02.js
new file mode 100644
index 000000000..51826e4dc
--- /dev/null
+++ b/devtools/client/projecteditor/test/browser_projecteditor_tree_selection_02.js
@@ -0,0 +1,76 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+thisTestLeaksUncaughtRejectionsAndShouldBeFixed("destroy");
+
+// Test that files get reselected in the tree when their editor
+// is focused. https://bugzilla.mozilla.org/show_bug.cgi?id=1011116.
+
+add_task(function* () {
+ let projecteditor = yield addProjectEditorTabForTempDirectory();
+ let TEMP_PATH = projecteditor.project.allPaths()[0];
+
+ is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly.");
+
+ ok(projecteditor.currentEditor, "There is an editor for projecteditor");
+ let resources = projecteditor.project.allResources();
+
+ is(
+ resources.map(r=>r.basename).join("|"),
+ TEMP_FOLDER_NAME + "|css|styles.css|data|img|icons|128x128.png|16x16.png|32x32.png|vector.svg|fake.png|js|script.js|index.html|LICENSE|README.md",
+ "Resources came through in proper order"
+ );
+
+ for (let i = 0; i < resources.length; i++) {
+ yield selectAndRefocusFile(projecteditor, resources[i]);
+ }
+});
+
+function* selectAndRefocusFile(projecteditor, resource) {
+ ok(resource && resource.path, "A valid resource has been passed in for selection " + (resource && resource.path));
+ projecteditor.projectTree.selectResource(resource);
+
+ if (resource.isDir) {
+ return;
+ }
+
+ let [editorCreated, editorLoaded, editorActivated] = yield promise.all([
+ onceEditorCreated(projecteditor),
+ onceEditorLoad(projecteditor),
+ onceEditorActivated(projecteditor)
+ ]);
+
+ if (projecteditor.currentEditor.editor) {
+ // This is a text editor. Go ahead and select a directory then refocus
+ // the editor to make sure it is reselected in tree.
+ let treeContainer = projecteditor.projectTree.getViewContainer(getDirectoryInStore(resource));
+ treeContainer.line.click();
+ EventUtils.synthesizeMouseAtCenter(treeContainer.elt, {}, treeContainer.elt.ownerDocument.defaultView);
+ let waitForTreeSelect = onTreeSelection(projecteditor);
+ projecteditor.currentEditor.focus();
+ yield waitForTreeSelect;
+
+ is(projecteditor.projectTree.getSelectedResource(), resource, "The resource gets reselected in the tree");
+ }
+}
+
+// Return a directory to select in the tree.
+function getDirectoryInStore(resource) {
+ return resource.store.root.childrenSorted.filter(r=>r.isDir)[0];
+}
+
+function onTreeSelection(projecteditor) {
+ let def = promise.defer();
+ projecteditor.projectTree.on("selection", function selection() {
+ projecteditor.projectTree.off("focus", selection);
+ def.resolve();
+ });
+ return def.promise;
+}
diff --git a/devtools/client/projecteditor/test/head.js b/devtools/client/projecteditor/test/head.js
new file mode 100644
index 000000000..d5d9ce849
--- /dev/null
+++ b/devtools/client/projecteditor/test/head.js
@@ -0,0 +1,391 @@
+/* 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/. */
+
+var Cu = Components.utils;
+const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {});
+const {TargetFactory} = require("devtools/client/framework/target");
+const {console} = Cu.import("resource://gre/modules/Console.jsm", {});
+const promise = require("promise");
+const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm", {});
+const {NetUtil} = Cu.import("resource://gre/modules/NetUtil.jsm", {});
+const ProjectEditor = require("devtools/client/projecteditor/lib/projecteditor");
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+const flags = require("devtools/shared/flags");
+
+const TEST_URL_ROOT = "http://mochi.test:8888/browser/devtools/client/projecteditor/test/";
+const SAMPLE_WEBAPP_URL = TEST_URL_ROOT + "/helper_homepage.html";
+var TEMP_PATH;
+var TEMP_FOLDER_NAME = "ProjectEditor" + (new Date().getTime());
+
+// All test are asynchronous
+waitForExplicitFinish();
+
+// Uncomment this pref to dump all devtools emitted events to the console.
+// Services.prefs.setBoolPref("devtools.dump.emit", true);
+
+// Set the testing flag and reset it when the test ends
+flags.testing = true;
+registerCleanupFunction(() => flags.testing = false);
+
+// Clear preferences that may be set during the course of tests.
+registerCleanupFunction(() => {
+ // Services.prefs.clearUserPref("devtools.dump.emit");
+ TEMP_PATH = null;
+ TEMP_FOLDER_NAME = null;
+});
+
+// Auto close the toolbox and close the test tabs when the test ends
+registerCleanupFunction(() => {
+ try {
+ let target = TargetFactory.forTab(gBrowser.selectedTab);
+ gDevTools.closeToolbox(target);
+ } catch (ex) {
+ dump(ex);
+ }
+ while (gBrowser.tabs.length > 1) {
+ gBrowser.removeCurrentTab();
+ }
+});
+
+/**
+ * Add a new test tab in the browser and load the given url.
+ * @param {String} url The url to be loaded in the new tab
+ * @return a promise that resolves to the tab object when the url is loaded
+ */
+function addTab(url) {
+ info("Adding a new tab with URL: '" + url + "'");
+ let def = promise.defer();
+
+ let tab = gBrowser.selectedTab = gBrowser.addTab(url);
+ BrowserTestUtils.browserLoaded(tab.linkedBrowser).then(function () {
+ info("URL '" + url + "' loading complete");
+ waitForFocus(() => {
+ def.resolve(tab);
+ }, content);
+ });
+
+ return def.promise;
+}
+
+/**
+ * Some tests may need to import one or more of the test helper scripts.
+ * A test helper script is simply a js file that contains common test code that
+ * is either not common-enough to be in head.js, or that is located in a separate
+ * directory.
+ * The script will be loaded synchronously and in the test's scope.
+ * @param {String} filePath The file path, relative to the current directory.
+ * Examples:
+ * - "helper_attributes_test_runner.js"
+ * - "../../../commandline/test/helpers.js"
+ */
+function loadHelperScript(filePath) {
+ let testDir = gTestPath.substr(0, gTestPath.lastIndexOf("/"));
+ Services.scriptloader.loadSubScript(testDir + "/" + filePath, this);
+}
+
+function addProjectEditorTabForTempDirectory(opts = {}) {
+ try {
+ TEMP_PATH = buildTempDirectoryStructure();
+ } catch (e) {
+ // Bug 1037292 - The test servers sometimes are unable to
+ // write to the temporary directory due to locked files
+ // or access denied errors. Try again if this failed.
+ info("Project Editor temp directory creation failed. Trying again.");
+ TEMP_PATH = buildTempDirectoryStructure();
+ }
+ let customOpts = {
+ name: "Test",
+ iconUrl: "chrome://devtools/skin/images/tool-options.svg",
+ projectOverviewURL: SAMPLE_WEBAPP_URL
+ };
+
+ info("Adding a project editor tab for editing at: " + TEMP_PATH);
+ return addProjectEditorTab(opts).then((projecteditor) => {
+ return projecteditor.setProjectToAppPath(TEMP_PATH, customOpts).then(() => {
+ return projecteditor;
+ });
+ });
+}
+
+function addProjectEditorTab(opts = {}) {
+ return addTab("chrome://devtools/content/projecteditor/chrome/content/projecteditor-test.xul").then(() => {
+ let iframe = content.document.getElementById("projecteditor-iframe");
+ if (opts.menubar !== false) {
+ opts.menubar = content.document.querySelector("menubar");
+ }
+ let projecteditor = ProjectEditor.ProjectEditor(iframe, opts);
+
+
+ ok(iframe, "Tab has placeholder iframe for projecteditor");
+ ok(projecteditor, "ProjectEditor has been initialized");
+
+ return projecteditor.loaded.then((projecteditor) => {
+ return projecteditor;
+ });
+ });
+}
+
+/**
+ * Build a temporary directory as a workspace for this loader
+ * https://developer.mozilla.org/en-US/Add-ons/Code_snippets/File_I_O
+ */
+function buildTempDirectoryStructure() {
+
+ let dirName = TEMP_FOLDER_NAME;
+ info("Building a temporary directory at " + dirName);
+
+ // First create (and remove) the temp dir to discard any changes
+ let TEMP_DIR = FileUtils.getDir("TmpD", [dirName], true);
+ TEMP_DIR.remove(true);
+
+ // Now rebuild our fake project.
+ TEMP_DIR = FileUtils.getDir("TmpD", [dirName], true);
+
+ FileUtils.getDir("TmpD", [dirName, "css"], true);
+ FileUtils.getDir("TmpD", [dirName, "data"], true);
+ FileUtils.getDir("TmpD", [dirName, "img", "icons"], true);
+ FileUtils.getDir("TmpD", [dirName, "js"], true);
+
+ let htmlFile = FileUtils.getFile("TmpD", [dirName, "index.html"]);
+ htmlFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ writeToFileSync(htmlFile, [
+ "<!DOCTYPE html>",
+ '<html lang="en">',
+ " <head>",
+ ' <meta charset="utf-8" />',
+ " <title>ProjectEditor Temp File</title>",
+ ' <link rel="stylesheet" href="style.css" />',
+ " </head>",
+ ' <body id="home">',
+ " <p>ProjectEditor Temp File</p>",
+ " </body>",
+ "</html>"].join("\n")
+ );
+
+ let readmeFile = FileUtils.getFile("TmpD", [dirName, "README.md"]);
+ readmeFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ writeToFileSync(readmeFile, [
+ "## Readme"
+ ].join("\n")
+ );
+
+ let licenseFile = FileUtils.getFile("TmpD", [dirName, "LICENSE"]);
+ licenseFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ writeToFileSync(licenseFile, [
+ "/* 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/. */"
+ ].join("\n")
+ );
+
+ let cssFile = FileUtils.getFile("TmpD", [dirName, "css", "styles.css"]);
+ cssFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ writeToFileSync(cssFile, [
+ "body {",
+ " background: red;",
+ "}"
+ ].join("\n")
+ );
+
+ FileUtils.getFile("TmpD", [dirName, "js", "script.js"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+
+ FileUtils.getFile("TmpD", [dirName, "img", "fake.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ FileUtils.getFile("TmpD", [dirName, "img", "icons", "16x16.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ FileUtils.getFile("TmpD", [dirName, "img", "icons", "32x32.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ FileUtils.getFile("TmpD", [dirName, "img", "icons", "128x128.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ FileUtils.getFile("TmpD", [dirName, "img", "icons", "vector.svg"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+
+ return TEMP_DIR.path;
+}
+
+// https://developer.mozilla.org/en-US/Add-ons/Code_snippets/File_I_O#Writing_to_a_file
+function writeToFile(file, data) {
+ if (typeof file === "string") {
+ file = new FileUtils.File(file);
+ }
+ info("Writing to file: " + file.path + " (exists? " + file.exists() + ")");
+ let defer = promise.defer();
+ var ostream = FileUtils.openSafeFileOutputStream(file);
+
+ var converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"].
+ createInstance(Components.interfaces.nsIScriptableUnicodeConverter);
+ converter.charset = "UTF-8";
+ var istream = converter.convertToInputStream(data);
+
+ // The last argument (the callback) is optional.
+ NetUtil.asyncCopy(istream, ostream, function (status) {
+ if (!Components.isSuccessCode(status)) {
+ // Handle error!
+ info("ERROR WRITING TEMP FILE", status);
+ }
+ defer.resolve();
+ });
+ return defer.promise;
+}
+
+// This is used when setting up the test.
+// You should typically use the async version of this, writeToFile.
+// https://developer.mozilla.org/en-US/Add-ons/Code_snippets/File_I_O#More
+function writeToFileSync(file, data) {
+ // file is nsIFile, data is a string
+ var foStream = Components.classes["@mozilla.org/network/file-output-stream;1"].
+ createInstance(Components.interfaces.nsIFileOutputStream);
+
+ // use 0x02 | 0x10 to open file for appending.
+ foStream.init(file, 0x02 | 0x08 | 0x20, 0o666, 0);
+ // write, create, truncate
+ // In a c file operation, we have no need to set file mode with or operation,
+ // directly using "r" or "w" usually.
+
+ // if you are sure there will never ever be any non-ascii text in data you can
+ // also call foStream.write(data, data.length) directly
+ var converter = Components.classes["@mozilla.org/intl/converter-output-stream;1"].
+ createInstance(Components.interfaces.nsIConverterOutputStream);
+ converter.init(foStream, "UTF-8", 0, 0);
+ converter.writeString(data);
+ converter.close(); // this closes foStream
+}
+
+function getTempFile(path) {
+ let parts = [TEMP_FOLDER_NAME];
+ parts = parts.concat(path.split("/"));
+ return FileUtils.getFile("TmpD", parts);
+}
+
+// https://developer.mozilla.org/en-US/Add-ons/Code_snippets/File_I_O#Writing_to_a_file
+function* getFileData(file) {
+ if (typeof file === "string") {
+ file = new FileUtils.File(file);
+ }
+ let def = promise.defer();
+
+ NetUtil.asyncFetch({
+ uri: NetUtil.newURI(file),
+ loadUsingSystemPrincipal: true
+ }, function (inputStream, status) {
+ if (!Components.isSuccessCode(status)) {
+ info("ERROR READING TEMP FILE", status);
+ }
+
+ // Detect if an empty file is loaded
+ try {
+ inputStream.available();
+ } catch (e) {
+ def.resolve("");
+ return;
+ }
+
+ var data = NetUtil.readInputStreamToString(inputStream, inputStream.available());
+ def.resolve(data);
+ });
+
+ return def.promise;
+}
+
+/**
+ * Rename the resource of the provided container using the context menu.
+ *
+ * @param {ProjectEditor} projecteditor the current project editor instance
+ * @param {Shell} container for the resource to rename
+ * @param {String} newName the name to use for renaming the resource
+ * @return {Promise} a promise that resolves when the resource has been renamed
+ */
+var renameWithContextMenu = Task.async(function* (projecteditor,
+ container, newName) {
+ let popup = projecteditor.contextMenuPopup;
+ let resource = container.resource;
+ info("Going to attempt renaming for: " + resource.path);
+
+ let waitForPopupShow = onPopupShow(popup);
+ openContextMenu(container.label);
+ yield waitForPopupShow;
+
+ let renameCommand = popup.querySelector("[command=cmd-rename]");
+ ok(renameCommand, "Rename command exists in popup");
+ is(renameCommand.getAttribute("hidden"), "", "Rename command is visible");
+ is(renameCommand.getAttribute("disabled"), "", "Rename command is enabled");
+
+ renameCommand.click();
+ popup.hidePopup();
+ let input = container.elt.childNodes[0].childNodes[1];
+ input.value = resource.basename + newName;
+
+ let waitForProjectRefresh = onceProjectRefreshed(projecteditor);
+ EventUtils.synthesizeKey("VK_RETURN", {}, projecteditor.window);
+ yield waitForProjectRefresh;
+
+ try {
+ yield OS.File.stat(resource.path + newName);
+ ok(true, "File is renamed");
+ } catch (e) {
+ ok(false, "Failed to rename file");
+ }
+});
+
+function onceEditorCreated(projecteditor) {
+ let def = promise.defer();
+ projecteditor.once("onEditorCreated", (editor) => {
+ def.resolve(editor);
+ });
+ return def.promise;
+}
+
+function onceEditorLoad(projecteditor) {
+ let def = promise.defer();
+ projecteditor.once("onEditorLoad", (editor) => {
+ def.resolve(editor);
+ });
+ return def.promise;
+}
+
+function onceEditorActivated(projecteditor) {
+ let def = promise.defer();
+ projecteditor.once("onEditorActivated", (editor) => {
+ def.resolve(editor);
+ });
+ return def.promise;
+}
+
+function onceEditorSave(projecteditor) {
+ let def = promise.defer();
+ projecteditor.once("onEditorSave", (editor, resource) => {
+ def.resolve(resource);
+ });
+ return def.promise;
+}
+
+function onceProjectRefreshed(projecteditor) {
+ return new Promise(resolve => {
+ projecteditor.project.on("refresh-complete", function refreshComplete() {
+ projecteditor.project.off("refresh-complete", refreshComplete);
+ resolve();
+ });
+ });
+}
+
+function onPopupShow(menu) {
+ let defer = promise.defer();
+ menu.addEventListener("popupshown", function onpopupshown() {
+ menu.removeEventListener("popupshown", onpopupshown);
+ defer.resolve();
+ });
+ return defer.promise;
+}
+
+function onPopupHidden(menu) {
+ let defer = promise.defer();
+ menu.addEventListener("popuphidden", function onpopuphidden() {
+ menu.removeEventListener("popuphidden", onpopuphidden);
+ defer.resolve();
+ });
+ return defer.promise;
+}
+
+function openContextMenu(node) {
+ EventUtils.synthesizeMouseAtCenter(
+ node,
+ {button: 2, type: "contextmenu"},
+ node.ownerDocument.defaultView
+ );
+}
diff --git a/devtools/client/projecteditor/test/helper_edits.js b/devtools/client/projecteditor/test/helper_edits.js
new file mode 100644
index 000000000..d8e83672b
--- /dev/null
+++ b/devtools/client/projecteditor/test/helper_edits.js
@@ -0,0 +1,53 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var helperEditData = [
+ {
+ basename: "styles.css",
+ path: "css/styles.css",
+ newContent: "body,html { color: orange; }"
+ },
+ {
+ basename: "index.html",
+ path: "index.html",
+ newContent: "<h1>Changed Content Again</h1>"
+ },
+ {
+ basename: "LICENSE",
+ path: "LICENSE",
+ newContent: "My new license"
+ },
+ {
+ basename: "README.md",
+ path: "README.md",
+ newContent: "My awesome readme"
+ },
+ {
+ basename: "script.js",
+ path: "js/script.js",
+ newContent: "alert('hi')"
+ },
+ {
+ basename: "vector.svg",
+ path: "img/icons/vector.svg",
+ newContent: "<svg></svg>"
+ },
+];
+
+function* selectFile(projecteditor, resource) {
+ ok(resource && resource.path, "A valid resource has been passed in for selection " + (resource && resource.path));
+ projecteditor.projectTree.selectResource(resource);
+
+ if (resource.isDir) {
+ return;
+ }
+
+ let [editorActivated] = yield promise.all([
+ onceEditorActivated(projecteditor)
+ ]);
+
+ is(editorActivated, projecteditor.currentEditor, "Editor has been activated for " + resource.path);
+}
diff --git a/devtools/client/projecteditor/test/helper_homepage.html b/devtools/client/projecteditor/test/helper_homepage.html
new file mode 100644
index 000000000..a4402a9bd
--- /dev/null
+++ b/devtools/client/projecteditor/test/helper_homepage.html
@@ -0,0 +1 @@
+<h1>ProjectEditor tests</h1> \ No newline at end of file