Bug 987089 - Land ProjectEditor in browser/devtools part 1;r=paul
authorBrian Grinstead <bgrinstead@mozilla.com>
Wed, 21 May 2014 16:38:17 -0500
changeset 184409 447a8fd40ae913a1c9a7b296f174a7dd58c6e2d6
parent 184342 0685ea0268b7d5714192a85fb433ddb5a16a605c
child 184410 138fa90154ec22c800767f07c7e1d7ac7441f2fa
push id43832
push usercbook@mozilla.com
push dateThu, 22 May 2014 13:45:00 +0000
treeherdermozilla-inbound@ea89f51bf2f2 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspaul
bugs987089
milestone32.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 987089 - Land ProjectEditor in browser/devtools part 1;r=paul
browser/devtools/jar.mn
browser/devtools/moz.build
browser/devtools/projecteditor/Makefile.in
browser/devtools/projecteditor/chrome/content/projecteditor-loader.js
browser/devtools/projecteditor/chrome/content/projecteditor-loader.xul
browser/devtools/projecteditor/chrome/content/projecteditor-test.html
browser/devtools/projecteditor/chrome/content/projecteditor.xul
browser/devtools/projecteditor/lib/editors.js
browser/devtools/projecteditor/lib/helpers/event.js
browser/devtools/projecteditor/lib/helpers/file-picker.js
browser/devtools/projecteditor/lib/helpers/l10n.js
browser/devtools/projecteditor/lib/helpers/promise.js
browser/devtools/projecteditor/lib/helpers/readdir.js
browser/devtools/projecteditor/lib/plugins/app-manager/lib/app-project-editor.js
browser/devtools/projecteditor/lib/plugins/app-manager/lib/plugin.js
browser/devtools/projecteditor/lib/plugins/core.js
browser/devtools/projecteditor/lib/plugins/delete/lib/delete.js
browser/devtools/projecteditor/lib/plugins/dirty/lib/dirty.js
browser/devtools/projecteditor/lib/plugins/image-view/lib/image-editor.js
browser/devtools/projecteditor/lib/plugins/image-view/lib/plugin.js
browser/devtools/projecteditor/lib/plugins/logging/lib/logging.js
browser/devtools/projecteditor/lib/plugins/new/lib/new.js
browser/devtools/projecteditor/lib/plugins/save/lib/save.js
browser/devtools/projecteditor/lib/plugins/status-bar/lib/plugin.js
browser/devtools/projecteditor/lib/project.js
browser/devtools/projecteditor/lib/projecteditor.js
browser/devtools/projecteditor/lib/shells.js
browser/devtools/projecteditor/lib/stores/base.js
browser/devtools/projecteditor/lib/stores/local.js
browser/devtools/projecteditor/lib/stores/resource.js
browser/devtools/projecteditor/lib/tree.js
browser/devtools/projecteditor/moz.build
browser/devtools/projecteditor/test/browser.ini
browser/devtools/projecteditor/test/browser_projecteditor_delete_file.js
browser/devtools/projecteditor/test/browser_projecteditor_editing_01.js
browser/devtools/projecteditor/test/browser_projecteditor_immediate_destroy.js
browser/devtools/projecteditor/test/browser_projecteditor_init.js
browser/devtools/projecteditor/test/browser_projecteditor_new_file.js
browser/devtools/projecteditor/test/browser_projecteditor_stores.js
browser/devtools/projecteditor/test/browser_projecteditor_tree_selection.js
browser/devtools/projecteditor/test/head.js
browser/devtools/projecteditor/test/helper_homepage.html
browser/devtools/projecteditor/test/moz.build
browser/locales/en-US/chrome/browser/devtools/projecteditor.properties
browser/locales/jar.mn
browser/themes/linux/jar.mn
browser/themes/osx/jar.mn
browser/themes/shared/devtools/projecteditor/file-icons-sheet@2x.png
browser/themes/shared/devtools/projecteditor/projecteditor.css
browser/themes/windows/jar.mn
toolkit/devtools/Loader.jsm
--- a/browser/devtools/jar.mn
+++ b/browser/devtools/jar.mn
@@ -2,16 +2,21 @@
 # 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/.
 
 browser.jar:
     content/browser/devtools/widgets.css                               (shared/widgets/widgets.css)
     content/browser/devtools/widgets/VariablesView.xul                 (shared/widgets/VariablesView.xul)
     content/browser/devtools/markup-view.xhtml                         (markupview/markup-view.xhtml)
     content/browser/devtools/markup-view.css                           (markupview/markup-view.css)
+    content/browser/devtools/projecteditor.xul                               (projecteditor/chrome/content/projecteditor.xul)
+    content/browser/devtools/readdir.js                                (projecteditor/lib/helpers/readdir.js)
+    content/browser/devtools/projecteditor-loader.xul                        (projecteditor/chrome/content/projecteditor-loader.xul)
+    content/browser/devtools/projecteditor-test.html                         (projecteditor/chrome/content/projecteditor-test.html)
+    content/browser/devtools/projecteditor-loader.js                         (projecteditor/chrome/content/projecteditor-loader.js)
     content/browser/devtools/netmonitor.xul                            (netmonitor/netmonitor.xul)
     content/browser/devtools/netmonitor.css                            (netmonitor/netmonitor.css)
     content/browser/devtools/netmonitor-controller.js                  (netmonitor/netmonitor-controller.js)
     content/browser/devtools/netmonitor-view.js                        (netmonitor/netmonitor-view.js)
     content/browser/devtools/NetworkPanel.xhtml                        (webconsole/NetworkPanel.xhtml)
     content/browser/devtools/webconsole.xul                            (webconsole/webconsole.xul)
 *   content/browser/devtools/scratchpad.xul                            (scratchpad/scratchpad.xul)
     content/browser/devtools/scratchpad.js                             (scratchpad/scratchpad.js)
--- a/browser/devtools/moz.build
+++ b/browser/devtools/moz.build
@@ -8,16 +8,17 @@ DIRS += [
     'app-manager',
     'canvasdebugger',
     'commandline',
     'debugger',
     'eyedropper',
     'fontinspector',
     'framework',
     'inspector',
+    'projecteditor',
     'layoutview',
     'markupview',
     'netmonitor',
     'profiler',
     'responsivedesign',
     'scratchpad',
     'shadereditor',
     'shared',
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/Makefile.in
@@ -0,0 +1,14 @@
+# 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/.
+
+projecteditor_lib_FILES = $(wildcard $(srcdir)/lib/*)
+projecteditor_lib_DEST = $(FINAL_TARGET)/modules/devtools/projecteditor
+INSTALL_TARGETS += projecteditor_lib
+
+# To copy the sample directory into modules/devtools/projecteditor
+# projecteditor_sample_FILES = $(wildcard $(srcdir)/test/samples/*)
+# projecteditor_sample_DEST = $(FINAL_TARGET)/modules/devtools/projecteditor/samples
+# INSTALL_TARGETS += projecteditor_sample
+
+include $(topsrcdir)/config/rules.mk
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/chrome/content/projecteditor-loader.js
@@ -0,0 +1,157 @@
+const Cu = Components.utils;
+const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm", {});
+const {NetUtil} = Cu.import("resource://gre/modules/NetUtil.jsm", {});
+const require = devtools.require;
+const promise = require("projecteditor/helpers/promise");
+const ProjectEditor = require("projecteditor/projecteditor");
+
+const SAMPLE_PATH = buildTempDirectoryStructure();
+const SAMPLE_NAME = "DevTools Content";
+const SAMPLE_PROJECT_URL = "http://mozilla.org";
+const SAMPLE_ICON = "chrome://browser/skin/devtools/tool-options.svg";
+
+/**
+ * Create a workspace for working on projecteditor, available at
+ * chrome://browser/content/devtools/projecteditor-loader.xul.
+ * This emulates the integration points that the app manager uses.
+ */
+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) => {
+    console.log("editor created: " + editor);
+  });
+  projecteditor.on("onEditorDestroyed", (editor) => {
+    console.log("editor destroyed: " + editor);
+  });
+  projecteditor.on("onEditorSave", (editor, resource) => {
+    console.log("editor saved: " + editor, resource.path);
+  });
+  projecteditor.on("onTreeSelected", (resource) => {
+    console.log("tree selected: " + resource.path);
+  });
+  projecteditor.on("onEditorLoad", (editor) => {
+    console.log("editor loaded: " + editor);
+  });
+  projecteditor.on("onEditorActivated", (editor) => {
+    console.log("editor focused: " + editor);
+  });
+  projecteditor.on("onEditorDeactivated", (editor) => {
+    console.log("editor blur: " + editor);
+  });
+  projecteditor.on("onEditorChange", (editor) => {
+    console.log("editor changed: " + editor);
+  });
+  projecteditor.on("onEditorCursorActivity", (editor) => {
+    console.log("editor cursor activity: " + editor);
+  });
+  projecteditor.on("onCommand", (cmd) => {
+    console.log("Command: " + cmd);
+  });
+
+  projecteditor.loaded.then(() => {
+    projecteditor.setProjectToAppPath(SAMPLE_PATH, {
+      name: SAMPLE_NAME,
+      iconUrl: SAMPLE_ICON,
+      projectOverviewURL: SAMPLE_PROJECT_URL
+    }).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);
+    }
+  });
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/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://browser/locale/devtools/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>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/chrome/content/projecteditor-test.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<head>
+  <meta charset='utf-8' />
+</head>
+<body>
+  <style type="text/css">
+    html { height: 100%; }
+    body {display: flex; padding: 0; margin: 0; min-height: 100%; }
+    iframe {flex: 1; border: 0;}
+  </style>
+  <iframe id='projecteditor-iframe'></iframe>
+</body>
+</html>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/chrome/content/projecteditor.xul
@@ -0,0 +1,88 @@
+<?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://browser/skin/devtools/light-theme.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/projecteditor/projecteditor.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/devtools/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/devtools/debugger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/common.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/devtools/markup-view.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/markup-view.css" type="text/css"?>
+
+<?xul-overlay href="chrome://global/content/editMenuOverlay.xul"?>
+
+<!DOCTYPE window [
+<!ENTITY % scratchpadDTD SYSTEM "chrome://browser/locale/devtools/scratchpad.dtd" >
+ %scratchpadDTD;
+<!ENTITY % editMenuStrings SYSTEM "chrome://global/locale/editMenuOverlay.dtd">
+%editMenuStrings;
+<!ENTITY % sourceEditorStrings SYSTEM "chrome://browser/locale/devtools/sourceeditor.dtd">
+%sourceEditorStrings;
+]>
+
+<page xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" class="theme-body">
+
+  <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"/>
+        <menuseparator/>
+        <menuitem id="menu_selectAll"/>
+        <menuseparator/>
+        <menuitem id="menu_find"/>
+        <menuitem id="menu_findAgain"/>
+      </menupopup>
+    </menu>
+  </menubar>
+
+
+  <popupset>
+    <menupopup id="directory-menu-popup">
+    </menupopup>
+  </popupset>
+
+  <deck id="main-deck" flex="1">
+    <vbox flex="1" id="source-deckitem">
+      <hbox id="sources-body" flex="1">
+        <vbox width="250">
+          <vbox id="sources" 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>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/editors.js
@@ -0,0 +1,263 @@
+/* -*- 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/. */
+
+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("projecteditor/helpers/promise");
+const Editor  = require("devtools/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,
+
+  toString: function() {
+    return this.label || "";
+  },
+
+  emit: function(name, ...args) {
+    emit(this, name, ...args);
+  },
+
+  /**
+   * Initialize the editor with a single document.  This should be called
+   * by objects extending this object with:
+   * ItchEditor.prototype.initialize.apply(this, arguments)
+   */
+  initialize: function(document) {
+    this.doc = 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");
+  },
+
+  /**
+   * 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,
+
+  /**
+   * 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.doc.querySelectorAll("#projecteditor-keyset key")].forEach((key) => {
+      let keyUpper = key.getAttribute("key").toUpperCase();
+      let toolModifiers = key.getAttribute("modifiers");
+      let modifiers = {
+        alt: toolModifiers.contains("alt"),
+        shift: toolModifiers.contains("shift")
+      };
+
+      // On the key press, we will dispatch the event within projecteditor.
+      extraKeys[Editor.accel(keyUpper, modifiers)] = () => {
+        let event = this.doc.createEvent('Event');
+        event.initEvent('command', true, true);
+        let command = this.doc.querySelector("#" + key.getAttribute("command"));
+        command.dispatchEvent(event);
+      };
+    });
+
+    return extraKeys;
+  },
+
+  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
+    });
+
+    // Trigger editor specific events on `this`
+    this.editor.on("change", (...args) => {
+      this.emit("change", ...args);
+    });
+    this.editor.on("cursorActivity", (...args) => {
+      this.emit("cursorActivity", ...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])=> {
+      this.editor.setText(resourceContents);
+      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) {
+    return resource.save(this.editor.getText()).then(() => {
+      this.editor.setClean();
+      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(() => {
+      this.editor.focus();
+    });
+  }
+});
+
+/**
+ * Wrapper for TextEditor using JavaScript syntax highlighting.
+ */
+function JSEditor(document) {
+  return TextEditor(document, Editor.modes.js);
+}
+
+/**
+ * Wrapper for TextEditor using CSS syntax highlighting.
+ */
+function CSSEditor(document) {
+  return TextEditor(document, Editor.modes.css);
+}
+
+/**
+ * Wrapper for TextEditor using HTML syntax highlighting.
+ */
+function HTMLEditor(document) {
+  return TextEditor(document, 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;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/helpers/event.js
@@ -0,0 +1,86 @@
+/* -*- 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 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);
+}
+
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/helpers/file-picker.js
@@ -0,0 +1,116 @@
+/* -*- 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 OS-specific
+ * file and folder pickers.
+ */
+
+const { Cu, Cc, Ci } = require("chrome");
+const { FileUtils } = Cu.import("resource://gre/modules/FileUtils.jsm", {});
+const promise = require("projecteditor/helpers/promise");
+const { merge } = require("sdk/util/object");
+const { getLocalizedString } = require("projecteditor/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;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/helpers/l10n.js
@@ -0,0 +1,25 @@
+/* -*- 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 internationalizing projecteditor strings
+ */
+
+const { Cu, Cc, Ci } = require("chrome");
+const { ViewHelpers } = Cu.import("resource:///modules/devtools/ViewHelpers.jsm", {});
+const ITCHPAD_STRINGS_URI = "chrome://browser/locale/devtools/projecteditor.properties";
+const L10N = new ViewHelpers.L10N(ITCHPAD_STRINGS_URI).stringBundle;
+
+function getLocalizedString (name) {
+  try {
+    return L10N.GetStringFromName(name);
+  } catch (ex) {
+    console.log("Error reading '" + name + "'");
+    throw new Error("l10n error with " + name);
+  }
+}
+
+exports.getLocalizedString = getLocalizedString;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/helpers/promise.js
@@ -0,0 +1,11 @@
+/* -*- 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 helper is a quick way to require() the Promise object from Promise.jsm.
+ */
+const { Cu } = require("chrome");
+module.exports = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/helpers/readdir.js
@@ -0,0 +1,89 @@
+/* -*- 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/. */
+
+importScripts("resource://gre/modules/osfile.jsm");
+
+/**
+ * This file is meant to be loaded in a worker using:
+ *   new ChromeWorker("chrome://browser/content/devtools/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);
+  }
+};
+
+
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/plugins/app-manager/lib/app-project-editor.js
@@ -0,0 +1,44 @@
+/* -*- 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/. */
+
+const { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const promise = require("projecteditor/helpers/promise");
+const { ItchEditor } = require("projecteditor/editors");
+
+var AppProjectEditor = Class({
+  extends: ItchEditor,
+
+  hidesToolbar: true,
+
+  initialize: function(document, 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) {
+    this.elt.textContent = "";
+    let {appManagerOpts} = this.host.project;
+    let iframe = this.iframe = this.elt.ownerDocument.createElement("iframe");
+    iframe.setAttribute("flex", "1");
+    iframe.setAttribute("src", appManagerOpts.projectOverviewURL);
+    this.elt.appendChild(iframe);
+
+    // Wait for other `appended` listeners before emitting load.
+    this.appended.then(() => {
+      this.emit("load");
+    });
+  }
+});
+
+exports.AppProjectEditor = AppProjectEditor;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/plugins/app-manager/lib/plugin.js
@@ -0,0 +1,47 @@
+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("projecteditor/helpers/promise");
+var { registerPlugin, Plugin } = require("projecteditor/plugins/core");
+const { AppProjectEditor } = require("./app-project-editor");
+
+var AppManagerRenderer = Class({
+  extends: Plugin,
+
+  isAppManagerProject: function() {
+    return !!this.host.project.appManagerOpts;
+  },
+  editorForResource: function(resource) {
+    if (!resource.parent && this.isAppManagerProject()) {
+      return AppProjectEditor;
+    }
+  },
+  onAnnotate: function(resource, editor, elt) {
+    if (resource.parent || !this.isAppManagerProject()) {
+      return;
+    }
+
+    let {appManagerOpts} = this.host.project;
+    let doc = elt.ownerDocument;
+    let image = doc.createElement("image");
+    let label = doc.createElement("label");
+
+    label.className = "project-name-label";
+    image.className = "project-image";
+
+    let name = appManagerOpts.name || resource.basename;
+    let url = appManagerOpts.iconUrl || "icon-sample.png";
+
+    label.textContent = name;
+    image.setAttribute("src", url);
+
+    elt.innerHTML = "";
+    elt.appendChild(image);
+    elt.appendChild(label);
+    return true;
+  }
+});
+
+exports.AppManagerRenderer = AppManagerRenderer;
+registerPlugin(AppManagerRenderer);
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/plugins/core.js
@@ -0,0 +1,83 @@
+/* -*- 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 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 = [];
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/plugins/delete/lib/delete.js
@@ -0,0 +1,38 @@
+/* -*- 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/. */
+
+const { Class } = require("sdk/core/heritage");
+const { registerPlugin, Plugin } = require("projecteditor/plugins/core");
+const { getLocalizedString } = require("projecteditor/helpers/l10n");
+
+var DeletePlugin = Class({
+  extends: Plugin,
+
+  init: function(host) {
+    this.host.addCommand({
+      id: "cmd-delete"
+    });
+    this.host.createMenuItem({
+      parent: "#directory-menu-popup",
+      label: getLocalizedString("projecteditor.deleteLabel"),
+      command: "cmd-delete"
+    });
+  },
+
+  onCommand: function(cmd) {
+    if (cmd === "cmd-delete") {
+      let tree = this.host.projectTree;
+      let resource = tree.getSelectedResource();
+      let parent = resource.parent;
+      tree.deleteResource(resource).then(() => {
+        this.host.project.refresh();
+      })
+    }
+  }
+});
+
+exports.DeletePlugin = DeletePlugin;
+registerPlugin(DeletePlugin);
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/plugins/dirty/lib/dirty.js
@@ -0,0 +1,43 @@
+/* -*- 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/. */
+
+const { Class } = require("sdk/core/heritage");
+const { registerPlugin, Plugin } = require("projecteditor/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.editor.isClean();
+    if (priv.isClean !== clean) {
+
+      let resource = editor.shell.resource;
+      emit(resource, "label-change", resource);
+      priv.isClean = clean;
+    }
+  },
+
+  onAnnotate: function(resource, editor, elt) {
+    if (editor && editor.editor && !editor.editor.isClean()) {
+      elt.textContent = '*' + resource.displayName;
+      return true;
+    }
+  }
+});
+exports.DirtyPlugin = DirtyPlugin;
+
+registerPlugin(DirtyPlugin);
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/plugins/image-view/lib/image-editor.js
@@ -0,0 +1,41 @@
+/* -*- 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/. */
+
+const { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const promise = require("projecteditor/helpers/promise");
+const { ItchEditor } = require("projecteditor/editors");
+
+var ImageEditor = Class({
+  extends: ItchEditor,
+
+  initialize: function(document) {
+    ItchEditor.prototype.initialize.apply(this, arguments);
+    this.label = "image";
+    this.appended = promise.resolve();
+  },
+
+  load: function(resource) {
+    let 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");
+    });
+  }
+});
+
+exports.ImageEditor = ImageEditor;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/plugins/image-view/lib/plugin.js
@@ -0,0 +1,28 @@
+/* -*- 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/. */
+
+const { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const promise = require("projecteditor/helpers/promise");
+const { ImageEditor } = require("./image-editor");
+const { registerPlugin, Plugin } = require("projecteditor/plugins/core");
+
+var ImageEditorPlugin = Class({
+  extends: Plugin,
+
+  editorForResource: function(node) {
+    if (node.contentCategory === "image") {
+      return ImageEditor;
+    }
+  },
+
+  init: function(host) {
+
+  }
+});
+
+exports.ImageEditorPlugin = ImageEditorPlugin;
+registerPlugin(ImageEditorPlugin);
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/plugins/logging/lib/logging.js
@@ -0,0 +1,29 @@
+/* -*- 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/. */
+
+var { Class } = require("sdk/core/heritage");
+var { registerPlugin, Plugin } = require("projecteditor/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);
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/plugins/new/lib/new.js
@@ -0,0 +1,90 @@
+/* -*- 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/. */
+
+const { Class } = require("sdk/core/heritage");
+const { registerPlugin, Plugin } = require("projecteditor/plugins/core");
+const { getLocalizedString } = require("projecteditor/helpers/l10n");
+
+// Handles the new command.
+var NewFile = Class({
+  extends: Plugin,
+
+  init: function(host) {
+    this.host.createMenuItem({
+      parent: "#file-menu-popup",
+      label: getLocalizedString("projecteditor.newLabel"),
+      command: "cmd-new",
+      key: "key-new"
+    });
+    this.host.createMenuItem({
+      parent: "#directory-menu-popup",
+      label: getLocalizedString("projecteditor.newLabel"),
+      command: "cmd-new"
+    });
+
+    this.command = this.host.addCommand({
+      id: "cmd-new",
+      key: getLocalizedString("projecteditor.new.commandkey"),
+      modifiers: "accel"
+    });
+  },
+
+  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 (this.hasChild(parent, 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 (this.hasChild(parent, name));
+
+    return name;
+  },
+
+  hasChild: function(resource, name) {
+    for (let child of resource.children) {
+      if (child.basename === name) {
+        return true;
+      }
+    }
+    return false;
+  }
+})
+exports.NewFile = NewFile;
+registerPlugin(NewFile);
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/plugins/save/lib/save.js
@@ -0,0 +1,89 @@
+/* -*- 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/. */
+
+const { Class } = require("sdk/core/heritage");
+const { registerPlugin, Plugin } = require("projecteditor/plugins/core");
+const picker = require("projecteditor/helpers/file-picker");
+const { getLocalizedString } = require("projecteditor/helpers/l10n");
+
+// Handles the save command.
+var SavePlugin = Class({
+  extends: Plugin,
+
+  init: function(host) {
+
+    this.host.addCommand({
+      id: "cmd-saveas",
+      key: getLocalizedString("projecteditor.save.commandkey"),
+      modifiers: "accel shift"
+    });
+    this.host.addCommand({
+      id: "cmd-save",
+      key: getLocalizedString("projecteditor.save.commandkey"),
+      modifiers: "accel"
+    });
+
+    // Wait until we can add things into the app manager menu
+    // this.host.createMenuItem({
+    //   parent: "#file-menu-popup",
+    //   label: "Save",
+    //   command: "cmd-save",
+    //   key: "key-save"
+    // });
+    // this.host.createMenuItem({
+    //   parent: "#file-menu-popup",
+    //   label: "Save As",
+    //   command: "cmd-saveas",
+    // });
+  },
+
+  onCommand: function(cmd) {
+    if (cmd === "cmd-save") {
+      this.save();
+    } 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);
+  },
+
+  save: 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);
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/plugins/status-bar/lib/plugin.js
@@ -0,0 +1,105 @@
+/* -*- 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/. */
+
+const { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const promise = require("projecteditor/helpers/promise");
+const { registerPlugin, Plugin } = require("projecteditor/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);
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/project.js
@@ -0,0 +1,239 @@
+/* -*- 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/. */
+
+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("projecteditor/helpers/event");
+const prefs = require("sdk/preferences/service");
+const { LocalStore } = require("projecteditor/stores/local");
+const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {});
+const { Task } = Cu.import("resource://gre/modules/Task.jsm", {});
+const promise = require("projecteditor/helpers/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.
+ */
+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();
+      }
+    }.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 generator-iterator<string>
+   *          A list of all file paths
+   */
+  allPaths: function*() {
+    for (let [path, store] of this.localStores) {
+      yield path;
+    }
+  },
+
+  /**
+   * 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;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/projecteditor.js
@@ -0,0 +1,594 @@
+/* -*- 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/. */
+
+const { Cc, Ci, Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const { Project } = require("projecteditor/project");
+const { ProjectTreeView } = require("projecteditor/tree");
+const { ShellDeck } = require("projecteditor/shells");
+const { Resource } = require("projecteditor/stores/resource");
+const { registeredPlugins } = require("projecteditor/plugins/core");
+const { EventTarget } = require("sdk/event/target");
+const { on, forget } = require("projecteditor/helpers/event");
+const { emit } = require("sdk/event/core");
+const { merge } = require("sdk/util/object");
+const promise = require("projecteditor/helpers/promise");
+const { ViewHelpers } = Cu.import("resource:///modules/devtools/ViewHelpers.jsm", {});
+const { DOMHelpers } = Cu.import("resource:///modules/devtools/DOMHelpers.jsm");
+const { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+const ITCHPAD_URL = "chrome://browser/content/devtools/projecteditor.xul";
+
+// Enabled Plugins
+require("projecteditor/plugins/dirty/lib/dirty");
+require("projecteditor/plugins/delete/lib/delete");
+require("projecteditor/plugins/new/lib/new");
+require("projecteditor/plugins/save/lib/save");
+require("projecteditor/plugins/image-view/lib/plugin");
+require("projecteditor/plugins/app-manager/lib/plugin");
+require("projecteditor/plugins/status-bar/lib/plugin");
+
+// Uncomment to enable logging.
+// require("projecteditor/plugins/logging/lib/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
+ *
+ * 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.
+   */
+  initialize: function(iframe) {
+    this._onTreeSelected = this._onTreeSelected.bind(this);
+    this._onEditorCreated = this._onEditorCreated.bind(this);
+    this._onEditorActivated = this._onEditorActivated.bind(this);
+    this._onEditorDeactivated = this._onEditorDeactivated.bind(this);
+    this._updateEditorMenuItems = this._updateEditorMenuItems.bind(this);
+
+    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 = () => {
+      this._onLoad();
+      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._buildSidebar();
+
+    this.window.addEventListener("unload", this.destroy.bind(this));
+
+    // 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);
+
+    let popup = this.document.querySelector("#edit-menu-popup");
+    popup.addEventListener("popupshowing", this.updateEditorMenuItems);
+
+    // We are not allowing preset projects for now - rebuild a fresh one
+    // each time.
+    this.setProject(new Project({
+      id: "",
+      name: "",
+      directories: [],
+      openFiles: []
+    }));
+
+    this._initCommands();
+    this._initPlugins();
+  },
+
+
+  /**
+   * 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)
+    });
+    this.projectTree.on("selection", this._onTreeSelected);
+
+    let sourcesBox = this.document.querySelector("#sources");
+    sourcesBox.appendChild(this.projectTree.elt);
+  },
+
+  /**
+   * Set up listeners for commands to dispatch to all of the plugins
+   */
+  _initCommands: function() {
+    this.commands = this.document.querySelector("#projecteditor-commandset");
+    this.commands.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.
+   */
+  _updateEditorMenuItems: function() {
+    this.window.goUpdateGlobalEditMenuItems();
+    this.window.goUpdateGlobalEditMenuItems();
+    let commands = ['cmd_undo', 'cmd_redo', 'cmd_delete', 'cmd_findAgain'];
+    commands.forEach(this.window.goUpdateCommand);
+  },
+
+  /**
+   * Destroy all objects on the iframe unload event.
+   */
+  destroy: function() {
+    this._plugins.forEach(plugin => { plugin.destroy(); });
+
+    this.project.allResources().forEach((resource) => {
+      let editor = this.editorFor(resource);
+      if (editor) {
+        editor.destroy();
+      }
+    });
+
+    forget(this, this.project);
+    this.project.destroy();
+    this.project = null;
+    this.projectTree.destroy();
+    this.projectTree = 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) => {
+        let editor = this.editorFor(resource);
+        if (editor) {
+          editor.destroy();
+        }
+      });
+    });
+  },
+
+  /**
+   * 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. See plugins/app-manager.
+   * @param Promise
+   *        Promise that is resolved once the project is ready to be used.
+   */
+  setProjectToAppPath: function(path, opts = {}) {
+    this.project.appManagerOpts = opts;
+    this.project.removeAllStores();
+    this.project.addPath(path);
+    return this.project.refresh();
+  },
+
+  /**
+   * Open a resource in a particular shell.
+   *
+   * @param Resource resource
+   *                 The file to be opened.
+   */
+  openResource: function(resource) {
+    this.shells.open(resource);
+    this.projectTree.selectResource(resource);
+  },
+
+  /**
+   * 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);
+  },
+
+  /**
+   * 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(definition) {
+    let command = this.document.createElement("command");
+    command.setAttribute("id", definition.id);
+    if (definition.key) {
+      let key = this.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.document.getElementById("projecteditor-keyset").appendChild(key);
+    }
+    command.setAttribute("oncommand", "void(0);"); // needed. See bug 371900
+    this.document.getElementById("projecteditor-commandset").appendChild(command);
+    return command;
+  },
+
+  /**
+   * Get the instance of a plugin registered with a certain type.
+   *
+   * @param Type pluginType
+   *             The type, such as SavePlugin
+   * @returns Plugin
+   *          The plugin instance matching the specified type.
+   */
+  getPlugin: function(pluginType) {
+    for (let plugin of this.plugins) {
+      if (plugin.constructor === pluginType) {
+        return plugin;
+      }
+    }
+    return null;
+  },
+
+  /**
+   * Get all plugin instances active for the current project
+   *
+   * @returns [Plugin]
+   */
+  get plugins() {
+    if (!this._plugins) {
+      console.log("plugins requested before _plugins was set");
+      return [];
+    }
+    // Could filter further based on the type of project selected,
+    // but no need right now.
+    return this._plugins;
+  },
+
+  /**
+   * Dispatch an onEditorCreated event, and listen for other events specific
+   * to this editor instance.
+   *
+   * @param Editor editor
+   *               The new editor instance.
+   */
+  _onEditorCreated: function(editor) {
+    this.pluginDispatch("onEditorCreated", editor);
+    this._editorListenAndDispatch(editor, "change", "onEditorChange");
+    this._editorListenAndDispatch(editor, "cursorActivity", "onEditorCursorActivity");
+    this._editorListenAndDispatch(editor, "load", "onEditorLoad");
+    this._editorListenAndDispatch(editor, "save", "onEditorSave");
+  },
+
+  /**
+   * 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) {
+    // XXX: Memory leak when console.log an Editor here
+    // console.log("DISPATCHING EVENT TO PLUGIN", 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) {
+    /// XXX: Uncommenting this line also causes memory leak.
+    // console.log("Binding listen and dispatch", editor);
+    editor.on(event, (...args) => {
+      this.pluginDispatch(handler, editor, this.resourceFor(editor), ...args);
+    });
+  },
+
+  /**
+   * Find a shell for a resource.
+   *
+   * @param Resource resource
+   *                 The file to be opened.
+   * @returns Shell
+   */
+  shellFor: function(resource) {
+    return this.shells.shellFor(resource);
+  },
+
+  /**
+   * Returns the Editor for a given resource.
+   *
+   * @param Resource resource
+   *                 The file to check.
+   * @returns Editor
+   *          Instance of the editor for this file.
+   */
+  editorFor: function(resource) {
+    let shell = this.shellFor(resource);
+    return shell ? shell.editor : shell;
+  },
+
+  /**
+   * Returns a resource for the given editor
+   *
+   * @param Editor editor
+   *               The editor to check
+   * @returns Resource
+   *          The resource associated with this editor
+   */
+  resourceFor: function(editor) {
+    if (editor && editor.shell && editor.shell.resource) {
+      return editor.shell.resource;
+    }
+    return null;
+  },
+
+  /**
+   * Decide whether a given resource should be hidden in the tree.
+   *
+   * @param Resource resource
+   *                 The resource in the tree
+   * @returns Boolean
+   *          True if the node should be visible, false if hidden.
+   */
+  resourceVisible: function(resource) {
+    return true;
+  },
+
+  /**
+   * Format the given node for display in the resource tree view.
+   *
+   * @param Resource resource
+   *                 The file to be opened.
+   * @param DOMNode elt
+   *                The element in the tree to render into.
+   */
+  resourceFormatter: function(resource, elt) {
+    let editor = this.editorFor(resource);
+    let renderedByPlugin = false;
+
+    // Allow plugins to override default templating of resource in tree.
+    this.plugins.forEach(plugin => {
+      if (!plugin.onAnnotate) {
+        return;
+      }
+      if (plugin.onAnnotate(resource, editor, elt)) {
+        renderedByPlugin = true;
+      }
+    });
+
+    // If no plugin wants to handle it, just use a string from the resource.
+    if (!renderedByPlugin) {
+      elt.textContent = resource.displayName;
+    }
+  },
+
+  get sourcesVisible() {
+    return this.sourceToggle.hasAttribute("pane-collapsed");
+  },
+
+  get currentShell() {
+    return this.shells.currentShell;
+  },
+
+  get currentEditor() {
+    return this.shells.currentEditor;
+  },
+});
+
+exports.ProjectEditor = ProjectEditor;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/shells.js
@@ -0,0 +1,210 @@
+/* -*- 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/. */
+
+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("projecteditor/editors");
+const NetworkHelper = require("devtools/toolkit/webconsole/network-helper");
+
+/**
+ * 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.shell = this;
+
+    let constructor = this._editorTypeForResource();
+
+    this.editor = constructor(this.doc, this.host);
+    this.editor.shell = this;
+    this.editorAppended = this.editor.appended;
+
+    let loadDefer = promise.defer();
+    this.editor.on("load", () => {
+      loadDefer.resolve();
+    });
+
+    this.editorLoaded = loadDefer.promise;
+
+    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.editor.load(this.resource);
+  },
+
+  /**
+   * 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;
+  },
+
+  /**
+   * 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;
+      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;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/stores/base.js
@@ -0,0 +1,58 @@
+/* -*- 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/. */
+
+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("projecteditor/helpers/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;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/stores/local.js
@@ -0,0 +1,219 @@
+/* -*- 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/. */
+
+const { Cc, Ci, Cu, ChromeWorker } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {});
+const { emit } = require("sdk/event/core");
+const { Store } = require("projecteditor/stores/base");
+const { Task } = Cu.import("resource://gre/modules/Task.jsm", {});
+const promise = require("projecteditor/helpers/promise");
+const { on, forget } = require("projecteditor/helpers/event");
+const { FileResource } = require("projecteditor/stores/resource");
+const {Services} = Cu.import("resource://gre/modules/Services.jsm");
+
+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.window = Services.appShell.hiddenDOMWindow;
+    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() {
+    if (this.window) {
+      this.window.clearTimeout(this._refreshTimeout);
+    }
+    if (this._refreshDeferred) {
+      this._refreshDeferred.reject("destroy");
+    }
+    if (this.worker) {
+      this.worker.terminate();
+    }
+
+    this._refreshTimeout = null;
+    this._refreshDeferred = null;
+    this.window = 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);
+      throw new Task.Result(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 = this.window.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://browser/content/devtools/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;
+
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/stores/resource.js
@@ -0,0 +1,340 @@
+/* -*- 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/. */
+
+"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("projecteditor/helpers/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 } = Cu.import("resource://gre/modules/Task.jsm", {});
+
+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.
+ */
+var Resource = Class({
+  extends: EventTarget,
+
+  refresh: function() { return promise.resolve(this) },
+
+  setURI: function(uri) {
+    if (typeof(uri) === "string") {
+      uri = URL.URL(uri);
+    }
+    this.uri = uri;
+  },
+
+  /**
+   * Return the trailing name component of this.uri.
+   */
+  get basename() { return this.uri.path.replace(/\/+$/, '').replace(/\\/g,'/').replace( /.*\//, '' ); },
+
+  /**
+   * Is there more than 1 child Resource?
+   */
+  get hasChildren() { return this.children && this.children.size > 0; },
+
+  /**
+   * 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;
+  },
+
+  /**
+   * 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;
+  },
+
+  /**
+   * 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);
+    });
+  },
+
+  /**
+   * 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;
+    });
+  },
+
+  /**
+   * 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: function(content) {
+    let buffer = gEncoder.encode(content);
+    let path = this.path;
+
+    // XXX: writeAtomic was losing permissions after saving on OSX
+    // return OS.File.writeAtomic(this.path, buffer, { tmpPath: this.path + ".tmp" });
+
+    return Task.spawn(function*() {
+        let pfh = yield OS.File.open(path, {truncate: true});
+        yield pfh.write(buffer);
+        yield pfh.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/toolkit/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;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/lib/tree.js
@@ -0,0 +1,557 @@
+/* -*- 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/. */
+
+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("projecteditor/helpers/promise");
+const { InplaceEditor } = require("devtools/shared/inplace-editor");
+const { on, forget } = require("projecteditor/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("side-menu-widget-item");
+    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.icon = doc.createElementNS(HTML_NS, "span");
+    this.line.appendChild(this.icon);
+
+    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) => {
+      if (!this.selected) {
+        this.select();
+        this.expanded = true;
+        evt.stopPropagation();
+      }
+    }, false);
+    this.expander.addEventListener("click", (evt) => {
+      this.expanded = !this.expanded;
+      this.select();
+      evt.stopPropagation();
+    }, true);
+
+    this.update();
+  },
+
+  destroy: function() {
+    this.elt.remove();
+    this.expander.remove();
+    this.icon.remove();
+    this.highlighter.remove();
+    this.children.remove();
+    this.label.remove();
+    this.elt = this.expander = this.icon = 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.doc.getElementById("directory-menu-popup");
+    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.icon.className = "file-icon";
+
+    let contentCategory = this.resource.contentCategory;
+    let baseName = this.resource.basename || "";
+
+    if (!this.resource.parent) {
+      this.icon.classList.add("icon-none");
+    } else if (this.resource.isDir) {
+      this.icon.classList.add("icon-folder");
+    } else if (baseName.endsWith(".manifest") || baseName.endsWith(".webapp")) {
+      this.icon.classList.add("icon-manifest");
+    } else if (contentCategory === "js") {
+      this.icon.classList.add("icon-js");
+    } else if (contentCategory === "css") {
+      this.icon.classList.add("icon-css");
+    } else if (contentCategory === "html") {
+      this.icon.classList.add("icon-html");
+    } else if (contentCategory === "image") {
+      this.icon.classList.add("icon-img");
+    } else {
+      this.icon.classList.add("icon-file");
+    }
+
+    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
+   *               - 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(document, options) {
+    this.doc = document;
+    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 = document.createElement("vbox");
+    this.elt.tree = this;
+    this.elt.className = "side-menu-widget-container sources-tree";
+    this.elt.setAttribute("with-arrows", "true");
+    this.elt.setAttribute("theme", "dark");
+    this.elt.setAttribute("flex", "1");
+
+    this.children = document.createElementNS(HTML_NS, "ul");
+    this.children.setAttribute("flex", "1");
+    this.elt.appendChild(this.children);
+
+    this.resourceChildrenChanged = this.resourceChildrenChanged.bind(this);
+    this.updateResource = this.updateResource.bind(this);
+  },
+
+  destroy: function() {
+    this._destroyed = true;
+    this.elt.remove();
+  },
+
+  /**
+   * 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 placeholder = this.doc.createElementNS(HTML_NS, "div");
+    placeholder.className = "child";
+    item.appendChild(placeholder);
+
+    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,
+      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;
+  },
+
+  /**
+   * 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("side-menu-widget-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);
+
+    return container;
+  },
+
+  /**
+   * Delete a Resource from the FileSystem.  XXX: This should
+   * definitely be moved away from here, maybe to the store?
+   *
+   * @param Resource resource
+   */
+  deleteResource: function(resource) {
+    if (resource.isDir) {
+      return OS.File.removeDir(resource.path);
+    } else {
+      return OS.File.remove(resource.path);
+    }
+  },
+
+  /**
+   * 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) {
+    resource.off("children-changed", this.resourceChildrenChanged);
+    resource.off("label-change", this.updateResource);
+    if (this._containers.get(resource)) {
+      this._containers.get(resource).destroy();
+      this._containers.delete(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;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/moz.build
@@ -0,0 +1,6 @@
+# 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/.
+
+TEST_DIRS += ['test']
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/test/browser.ini
@@ -0,0 +1,14 @@
+[DEFAULT]
+skip-if = os == "win" && !debug # Bug 1014046
+subsuite = devtools
+support-files =
+  head.js
+  helper_homepage.html
+
+[browser_projecteditor_delete_file.js]
+[browser_projecteditor_editing_01.js]
+[browser_projecteditor_immediate_destroy.js]
+[browser_projecteditor_init.js]
+[browser_projecteditor_new_file.js]
+[browser_projecteditor_stores.js]
+[browser_projecteditor_tree_selection.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/test/browser_projecteditor_delete_file.js
@@ -0,0 +1,80 @@
+/* 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
+
+let test = asyncTest(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.projectTree.getViewContainer(child));
+  }
+
+  function onPopupShow(contextMenu) {
+    let defer = promise.defer();
+    contextMenu.addEventListener("popupshown", function onpopupshown() {
+      contextMenu.removeEventListener("popupshown", onpopupshown);
+      defer.resolve();
+    });
+    return defer.promise;
+  }
+
+  function onPopupHide(contextMenu) {
+    let defer = promise.defer();
+    contextMenu.addEventListener("popuphidden", function popuphidden() {
+      contextMenu.removeEventListener("popuphidden", popuphidden);
+      defer.resolve();
+    });
+    return defer.promise;
+  }
+
+  function openContextMenuOn(node) {
+    EventUtils.synthesizeMouseAtCenter(
+      node,
+      {button: 2, type: "contextmenu"},
+      node.ownerDocument.defaultView
+    );
+  }
+
+  function deleteWithContextMenu(container) {
+    let defer = promise.defer();
+
+    let resource = container.resource;
+    let popup = projecteditor.document.getElementById("directory-menu-popup");
+    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");
+
+      onPopupHide(popup).then(() => {
+        ok (true, "Popup has been hidden, waiting for project refresh");
+        projecteditor.project.refresh().then(() => {
+          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();
+          });
+        });
+      });
+
+      deleteCommand.click();
+      popup.hidePopup();
+    });
+
+    openContextMenuOn(container.label);
+
+    return defer.promise;
+  }
+
+});
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/test/browser_projecteditor_editing_01.js
@@ -0,0 +1,94 @@
+/* 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
+let test = asyncTest(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();
+
+  resources.forEach((r, i) => {
+    console.log("Resource detected", r.path, i);
+  });
+
+  let stylesCss = resources.filter(r=>r.basename === "styles.css")[0];
+  yield selectFile(projecteditor, stylesCss);
+  yield testEditFile(projecteditor, getTempFile("css/styles.css").path, "body,html { color: orange; }");
+
+  let indexHtml = resources.filter(r=>r.basename === "index.html")[0];
+  yield selectFile(projecteditor, indexHtml);
+  yield testEditFile(projecteditor, getTempFile("index.html").path, "<h1>Changed Content Again</h1>");
+
+  let license = resources.filter(r=>r.basename === "LICENSE")[0];
+  yield selectFile(projecteditor, license);
+  yield testEditFile(projecteditor, getTempFile("LICENSE").path, "My new license");
+
+  let readmeMd = resources.filter(r=>r.basename === "README.md")[0];
+  yield selectFile(projecteditor, readmeMd);
+  yield testEditFile(projecteditor, getTempFile("README.md").path, "My new license");
+
+  let scriptJs = resources.filter(r=>r.basename === "script.js")[0];
+  yield selectFile(projecteditor, scriptJs);
+  yield testEditFile(projecteditor, getTempFile("js/script.js").path, "alert('hi')");
+
+  let vectorSvg = resources.filter(r=>r.basename === "vector.svg")[0];
+  yield selectFile(projecteditor, vectorSvg);
+  yield testEditFile(projecteditor, getTempFile("img/icons/vector.svg").path, "<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);
+}
+
+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.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);
+
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/test/browser_projecteditor_immediate_destroy.js
@@ -0,0 +1,62 @@
+/* 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 destroyed in various states of loading
+// without causing any leaks or exceptions.
+
+let test = asyncTest(function* () {
+
+  info ("Testing tab closure when projecteditor is in various states");
+
+  yield addTab("chrome://browser/content/devtools/projecteditor-test.html").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("chrome://browser/content/devtools/projecteditor-test.html").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("chrome://browser/content/devtools/projecteditor-test.html").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("chrome://browser/content/devtools/projecteditor-test.html").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();
+    });
+  });
+
+  finish();
+});
+
+
new file mode 100644
--- /dev/null
+++ b/browser/devtools/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();
+  });
+}
+
new file mode 100644
--- /dev/null
+++ b/browser/devtools/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
+
+let test = asyncTest(function*() {
+  let projecteditor = yield addProjectEditorTabForTempDirectory();
+  ok(projecteditor, "ProjectEditor has loaded");
+
+});
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/devtools/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
+let test = asyncTest(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");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/test/browser_projecteditor_tree_selection.js
@@ -0,0 +1,69 @@
+/* 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
+
+let test = asyncTest(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("|"),
+    "ProjectEditor|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);
+
+  if (resource.isDir) {
+    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;
+  }
+
+  // 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);
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/test/head.js
@@ -0,0 +1,255 @@
+/* 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 = Components.utils;
+const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+const TargetFactory = devtools.TargetFactory;
+const {console} = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
+const promise = devtools.require("sdk/core/promise");
+const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm", {});
+const {NetUtil} = Cu.import("resource://gre/modules/NetUtil.jsm", {});
+const ProjectEditor = devtools.require("projecteditor/projecteditor");
+
+const TEST_URL_ROOT = "http://mochi.test:8888/browser/browser/devtools/projecteditor/test/";
+const SAMPLE_WEBAPP_URL = TEST_URL_ROOT + "/helper_homepage.html";
+let TEMP_PATH;
+
+// All test are asynchronous
+waitForExplicitFinish();
+
+//Services.prefs.setBoolPref("devtools.dump.emit", true);
+
+// Set the testing flag on gDevTools and reset it when the test ends
+gDevTools.testing = true;
+registerCleanupFunction(() => gDevTools.testing = false);
+
+// Clear preferences that may be set during the course of tests.
+registerCleanupFunction(() => {
+  // Services.prefs.clearUserPref("devtools.dump.emit");
+  TEMP_PATH = 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();
+  }
+});
+
+/**
+ * Define an async test based on a generator function
+ */
+function asyncTest(generator) {
+  return () => Task.spawn(generator).then(null, ok.bind(null, false)).then(finish);
+}
+
+/**
+ * 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();
+  gBrowser.selectedBrowser.addEventListener("load", function onload() {
+    gBrowser.selectedBrowser.removeEventListener("load", onload, true);
+    info("URL '" + url + "' loading complete");
+    waitForFocus(() => {
+      def.resolve(tab);
+    }, content);
+  }, true);
+  content.location = url;
+
+  return def.promise;
+}
+
+function addProjectEditorTabForTempDirectory() {
+  TEMP_PATH = buildTempDirectoryStructure();
+  let CUSTOM_OPTS = {
+    name: "Test",
+    iconUrl: "chrome://browser/skin/devtools/tool-options.svg",
+    projectOverviewURL: SAMPLE_WEBAPP_URL
+  };
+
+  return addProjectEditorTab().then((projecteditor) => {
+    return projecteditor.setProjectToAppPath(TEMP_PATH, CUSTOM_OPTS).then(() => {
+      return projecteditor;
+    });
+  });
+}
+
+function addProjectEditorTab() {
+  return addTab("chrome://browser/content/devtools/projecteditor-test.html").then(() => {
+    let iframe = content.document.getElementById("projecteditor-iframe");
+    let projecteditor = ProjectEditor.ProjectEditor(iframe);
+
+    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() {
+
+  // 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) {
+  console.log("Writing to file: " + file.path, 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);
+    }
+  });
+}
+
+function getTempFile(path) {
+  let parts = ["ProjectEditor"];
+  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(path) {
+  let file = new FileUtils.File(path);
+  let def = promise.defer();
+
+  NetUtil.asyncFetch(file, 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;
+}
+
+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;
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/test/helper_homepage.html
@@ -0,0 +1,1 @@
+<h1>ProjectEditor tests</h1>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/devtools/projecteditor/test/moz.build
@@ -0,0 +1,8 @@
+# -*- Mode: python; c-basic-offset: 4; 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/.
+
+BROWSER_CHROME_MANIFESTS += ['browser.ini']
+
new file mode 100644
--- /dev/null
+++ b/browser/locales/en-US/chrome/browser/devtools/projecteditor.properties
@@ -0,0 +1,49 @@
+# 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/.
+
+# LOCALIZATION NOTE These strings are used inside the ProjectEditor component
+# which is used for editing files in a directory and is used inside the
+# App Manager.
+# The correct localization of this file might be to keep it in
+# English, or another language commonly spoken among web developers.
+# You want to make that choice consistent across the developer tools.
+# A good criteria is the language in which you'd find the best
+# documentation on web development on the web.
+
+# LOCALIZATION NOTE (projecteditor.deleteLabel):
+# This string is displayed as a context menu item for allowing the selected
+# file / folder to be deleted
+projecteditor.deleteLabel=Delete
+
+# LOCALIZATION NOTE (projecteditor.newLabel):
+# This string is displayed as a context menu item for adding a new file to
+# the directory
+projecteditor.newLabel=New…
+
+# LOCALIZATION NOTE (projecteditor.selectFileLabel):
+# This string is displayed as the title on the file picker when saving a file
+projecteditor.selectFileLabel=Select a File
+
+# LOCALIZATION NOTE (projecteditor.openFolderLabel):
+# This string is displayed as the title on the file picker when opening a folder
+projecteditor.openFolderLabel=Select a Folder
+
+# LOCALIZATION NOTE (projecteditor.openFileLabel):
+# This string is displayed as the title on the file picker when opening a file
+projecteditor.openFileLabel=Open a File
+
+# LOCALIZATION NOTE  (projecteditor.find.commandkey): This is the key to use in
+# conjunction with accel (Command on Mac or Ctrl on other platforms) to search
+# text in the files
+projecteditor.find.commandkey=F
+
+# LOCALIZATION NOTE  (projecteditor.save.commandkey): This is the key to use in
+# conjunction with accel (Command on Mac or Ctrl on other platforms) to
+# save the file.  It is used with accel+shift to "save as"
+projecteditor.save.commandkey=S
+
+# LOCALIZATION NOTE  (projecteditor.new.commandkey): This is the key to use in
+# conjunction with accel (Command on Mac or Ctrl on other platforms) to
+# create a new file
+projecteditor.new.commandkey=N
--- a/browser/locales/jar.mn
+++ b/browser/locales/jar.mn
@@ -52,16 +52,17 @@
     locale/browser/devtools/sourceeditor.dtd          (%chrome/browser/devtools/sourceeditor.dtd)
     locale/browser/devtools/profiler.dtd              (%chrome/browser/devtools/profiler.dtd)
     locale/browser/devtools/profiler.properties       (%chrome/browser/devtools/profiler.properties)
     locale/browser/devtools/layoutview.dtd            (%chrome/browser/devtools/layoutview.dtd)
     locale/browser/devtools/responsiveUI.properties   (%chrome/browser/devtools/responsiveUI.properties)
     locale/browser/devtools/toolbox.dtd            (%chrome/browser/devtools/toolbox.dtd)
     locale/browser/devtools/toolbox.properties     (%chrome/browser/devtools/toolbox.properties)
     locale/browser/devtools/inspector.dtd          (%chrome/browser/devtools/inspector.dtd)
+    locale/browser/devtools/projecteditor.properties     (%chrome/browser/devtools/projecteditor.properties)
     locale/browser/devtools/eyedropper.properties     (%chrome/browser/devtools/eyedropper.properties)
     locale/browser/devtools/connection-screen.dtd  (%chrome/browser/devtools/connection-screen.dtd)
     locale/browser/devtools/connection-screen.properties (%chrome/browser/devtools/connection-screen.properties)
     locale/browser/devtools/font-inspector.dtd     (%chrome/browser/devtools/font-inspector.dtd)
     locale/browser/devtools/app-manager.dtd        (%chrome/browser/devtools/app-manager.dtd)
     locale/browser/devtools/app-manager.properties (%chrome/browser/devtools/app-manager.properties)
     locale/browser/newTab.dtd                      (%chrome/browser/newTab.dtd)
     locale/browser/newTab.properties               (%chrome/browser/newTab.properties)
--- a/browser/themes/linux/jar.mn
+++ b/browser/themes/linux/jar.mn
@@ -302,16 +302,18 @@ browser.jar:
   skin/classic/browser/devtools/vview-open-inspector@2x.png (../shared/devtools/images/vview-open-inspector@2x.png)
   skin/classic/browser/devtools/undock@2x.png               (../shared/devtools/images/undock@2x.png)
   skin/classic/browser/devtools/font-inspector.css          (../shared/devtools/font-inspector.css)
   skin/classic/browser/devtools/computedview.css            (../shared/devtools/computedview.css)
   skin/classic/browser/devtools/arrow-e.png                 (../shared/devtools/images/arrow-e.png)
   skin/classic/browser/devtools/responsiveui-rotate.png     (../shared/devtools/responsiveui-rotate.png)
   skin/classic/browser/devtools/responsiveui-touch.png      (../shared/devtools/responsiveui-touch.png)
   skin/classic/browser/devtools/responsiveui-screenshot.png (../shared/devtools/responsiveui-screenshot.png)
+  skin/classic/browser/devtools/projecteditor/projecteditor.css         (../shared/devtools/projecteditor/projecteditor.css)
+  skin/classic/browser/devtools/projecteditor/file-icons-sheet@2x.png       (../shared/devtools/projecteditor/file-icons-sheet@2x.png)
   skin/classic/browser/devtools/app-manager/connection-footer.css     (../shared/devtools/app-manager/connection-footer.css)
   skin/classic/browser/devtools/app-manager/index.css                 (../shared/devtools/app-manager/index.css)
   skin/classic/browser/devtools/app-manager/device.css                (../shared/devtools/app-manager/device.css)
   skin/classic/browser/devtools/app-manager/projects.css              (../shared/devtools/app-manager/projects.css)
   skin/classic/browser/devtools/app-manager/help.css                  (../shared/devtools/app-manager/help.css)
   skin/classic/browser/devtools/app-manager/warning.svg               (../shared/devtools/app-manager/images/warning.svg)
   skin/classic/browser/devtools/app-manager/error.svg                 (../shared/devtools/app-manager/images/error.svg)
   skin/classic/browser/devtools/app-manager/plus.svg                  (../shared/devtools/app-manager/images/plus.svg)
--- a/browser/themes/osx/jar.mn
+++ b/browser/themes/osx/jar.mn
@@ -421,16 +421,18 @@ browser.jar:
   skin/classic/browser/devtools/vview-open-inspector@2x.png (../shared/devtools/images/vview-open-inspector@2x.png)
   skin/classic/browser/devtools/undock@2x.png               (../shared/devtools/images/undock@2x.png)
   skin/classic/browser/devtools/font-inspector.css          (../shared/devtools/font-inspector.css)
   skin/classic/browser/devtools/computedview.css            (../shared/devtools/computedview.css)
   skin/classic/browser/devtools/arrow-e.png                 (../shared/devtools/images/arrow-e.png)
   skin/classic/browser/devtools/responsiveui-rotate.png     (../shared/devtools/responsiveui-rotate.png)
   skin/classic/browser/devtools/responsiveui-touch.png      (../shared/devtools/responsiveui-touch.png)
   skin/classic/browser/devtools/responsiveui-screenshot.png (../shared/devtools/responsiveui-screenshot.png)
+  skin/classic/browser/devtools/projecteditor/projecteditor.css         (../shared/devtools/projecteditor/projecteditor.css)
+  skin/classic/browser/devtools/projecteditor/file-icons-sheet@2x.png       (../shared/devtools/projecteditor/file-icons-sheet@2x.png)
   skin/classic/browser/devtools/app-manager/connection-footer.css     (../shared/devtools/app-manager/connection-footer.css)
   skin/classic/browser/devtools/app-manager/index.css                 (../shared/devtools/app-manager/index.css)
   skin/classic/browser/devtools/app-manager/device.css                (../shared/devtools/app-manager/device.css)
   skin/classic/browser/devtools/app-manager/projects.css              (../shared/devtools/app-manager/projects.css)
   skin/classic/browser/devtools/app-manager/help.css                  (../shared/devtools/app-manager/help.css)
   skin/classic/browser/devtools/app-manager/warning.svg               (../shared/devtools/app-manager/images/warning.svg)
   skin/classic/browser/devtools/app-manager/error.svg                 (../shared/devtools/app-manager/images/error.svg)
   skin/classic/browser/devtools/app-manager/plus.svg                  (../shared/devtools/app-manager/images/plus.svg)
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..39672e1fbefd907a0b3cf46be241487a3f1ce95d
GIT binary patch
literal 4138
zc$}SAc{tST+aJc3Jt}J&6H1I3Gq#y5VeCe>PGp-g2E&*cV`O9tZRn6Cls(lsLMXeC
zNReHZ2xE;9Q`yO@^PbN8Uhnn0&L8jd$Me1K=ll8G_x;?<A5V<6m5BhaBrgB}5HK@E
z+X4V=;s>$}57)uIk;^83@R6V!IneDWp7bCbjR4U1ptuu2W_~y?f-M2(5$xYa&;|g2
z0YrNTx&y`%g{Szb;C^CM7=Bd1&!w%$pyKep1Ukr_;6)_sfLH1o!62fC4)~%v28N*;
z61<6~S7-#gD^~XSE53N72Ut%Rq|HDb81N&|aUg~ti5!4p=z#ySi#m{h9z(&PzaVs9
z9q@k#<$$pU8B%Bjkh+RG1P_C&f;5pTs_JS;RaIpW9HxqZs=}afBt%serKX8e)d2my
zzz5!F9-b&$wDI4*4m2IGH=RyJL7_oGK`KEA6$;G@s)|G+p)fcU4u>2dAOXQ-I*tJ$
z2b}z60Zj<N(}+|$kwOOj)N$^VK)MbX{ObySR1D@H!{mU!6LpX>C<8}@s;a=Cetytj
z=m5Gc!SmnI0rtUE0@RieKnbMb59;B0@_)Jdcj`~zK{TlIG~z)~a3nMZALvIQ)6LL2
z;DbLZ9z+k623#HCiGv{^NF-bXqJ~2tAUGV-6N1C5dJvF!H4SwQtzUNj6Cb8-V4$Xk
zG(y7l)l^lD5C%w1O=ERqINA_qj8HdL{lzsS2hee3JmD9Y_&;3qzj9HAGy;xJq1jU?
zq+eZN?M<Om0=y|ykf9;yGzNz!l7H%oKWp@Ns014EGQq=`M)3pvm1Gq0KLmhq$7#7E
z4oXeHscAvf;OcM)QVT(VXt?7wH1G(xhMEQ({5Rj@|C>DMK~T{D(>cG64%+dj_|F_1
zDE~|nfqXDNw1YW`ZNQBI0Q?v;w7xxKWXXlk-hM#xhZoi}9S3s#HkC-ZljLs+I+4!B
z5D;S*ae>#}i7EIf@1j;~rmNWN6l@0Rxvgg2nIufOuCjU|O2G$-y|Kcb&~#W>TKrzj
z3onX_C@>1vocZB%sIOmF$A?duTf-siK@Iy8JAmo&`j8&cXL#u=y1CpA{w-ezk6kY!
znYvdEIW&YK#iP-0bhy4vhYY>D@JX6r#n(*_`Tdban&j&h!1M&yH&LL)Q$X?3Ja2Z}
z2vkF3aCLq9+w7UU<{FGuY%=TQ@ujtH$&caZpWkTj_JOvK7w7mqTX)jy?!tyEM^-r(
zYM=6@hKw><xUmeOE3)~0rA-y5t=mg+KTPX0vF%pjjo`Pr-}>C2J+xFv&zuk+fJb*J
zeRVq%xc|E^Eqq~Nfh((u^Z~9I-2_AG_1Wm{<m!JiqBwyGQ45U`&xg+NqbN?>mWr^V
z+f#4U0;dt_m)V}RQ}|5xXr<^*wumEFgx{4+DF&XKIRh!%)snWlYFnTT;Ny>KF&Dpc
z>IIs#<M<(louhb%f6fcTZN`;+Lb>qc-MvDm_i^ah+i@s|fVYs0wNUxQ-6uEIxo?Jf
zC)b~fF_hCY_lTm5)!tp6n(CwM+S=I}{)thLjMkC9oO!0d-~nv?TT*gz=U_Nk=@T=w
zcVOwx{YdmWwEs6@Wot1HC5hynjs!EY18-u;C)+#4Ev<d=ki-a?K>5@h+RL%Gch)**
zDo<n_cf5FUC}&qjPDN|`VY|(fP3<MC*6Rzxj;Ei>RYu%%{=-!E8#QN3b>R_FRH?B2
zgJse28DDp&IJuUub&$bUt1WwsvYxN+qJz1|c3jTGv^N5Iv6*K!_?0i%ACk@6%f=43
z${e{MGTO*r=~8r+HSHP2c>~<WY-xK!8#;W}lvLp#AtQG9U5QO@4BxJEMLae!rm&>s
zRH~b%L8=+A#M#Gw9~0a;%a-5F47VOCj$gl@Q-L&vix+V?4veR|x%^R&d6-;A#CYq-
zl5pD-a!ENO4eGe5Gz`SV-LH+Lr&M@tw2D%trKOdgc|zVBbcEMN=AadvoSgIv?9EMm
z^i-M^@=PC)B-|jNXAu(0<fvQ0MURZnu1D^sd!B47HJ7}QRds)u8nfrsS8r$FzD2B#
zki%?VGE&x4NWS;Jrw3~rZffuB;v#S?Z9h(yhl71;Btf9)p?g`<@Q+7arb)N<z5%!H
zy>H?uUJeg?x%F{?8a#38#~dlYr6`@e$UEd5)tuw3uGGjnHNIFGzwuR7EBzw&w{S&8
z#j(NJxw%(A_I3p3-ptZiFHP9bMNh*mc9t`@!m9$6EHs+md_C^DJaLaTT3f8tX2H3Y
zw<a{>rNKQd(ID=}ZgzB@8FWLqvt%oy4E>EyNJumGgv*q{nbe(5+1iLbgXECc-P`{2
z%9uE=Vd={8hR=n(rIWOmHZwfOgy&zLu=Tg`y_EaZkyBfvilWK?yJRLKl=%$lIq8!B
zf}_UqB+Iw=R<N+{Y<_snmy=MKscA!cqiE9Q?l<6kpBKQz2aP=gLRs^Iy(@Pr8qDl2
z@MUP{Gw+t8soA2&i(YAIX;Og=X=y`vrC3vEjtp1L&ywvmZaoTc%_lRfb~fzSFk=E~
zCC7)BDL&dURVK0h1rr5quIc5&VAz=8V4Y<B#+&sN=JKLMuXS*q$ol@q>C7A&o3@Dn
zg0C^-1c|$jIDK_d$GWP7Y^X6_Iarumw(3<X6h7!qlmUmTv)o|B`F?7>>!HdN2dbm8
zwQmKZP&D6ag`!@7-Q8%Tx)xkbBm=z<r|lk}UR_+48e98pKXm?;m+w~X#?GJaf4=?h
zh;9ECq`%cg?-?Asrf(C_^l9aJB%wL_^Rs^H-1C7R`kJ&>du#N{3wi9yXSR{LPO6WE
zr2X6qAT?R|#e?Z4CAoG|*8XTy924OuZpszwUr=ie49d7P-#D68$6H~SrXT;6tK+`h
zK%vs;9bjTIaCH%Sa=fYJwPRdUd%PFTHQ_mM0lI0bja{Mq0s1lATG^x>f~ZDnZ7>9R
z6XwxI7l<VZ#dcr*2$xiVbu`3UIJWXCi;!gz%=V`k>uo=UV~I%#<Lnqb<XG0WTU;)q
z(;!sviOEz{{F~^TsQtm&iO#{T=+Ln|`U+=enm$il4sYMp!{v3U-Fu!*E9UYoUtEEq
z`;EFMkf{MwF$N-sdvdho-1le5)vzWTExF*sLyqTkQ=S-SRf9lf3)1HGL5vbdWc*sz
zq1{tqA%UbGx#l6+^|mJGwuu<dhhvc+#rLGCE*!Z}3tz{3w(K6Qwt_ixRh77r;yxWY
zI$U!*`!3JtRq<+v>d_*Bvv4ZMv2>x6aO_B(etE2g$<WunC<%~hctxC2%{QV9BU(Vq
zxI%tD@oh&~An)mXTW^0veP@$Hrqu@12A62BEiw~}*xZA8C1zK1%sy=n^pZNdiW!z3
z*~)@IuSpHaYaQ|77LiJcn;b>GEK45E{r=Y3WsKD?0iw%r=08e&`33*A_Pik1b-g67
z*2VFV&GUe?re^=s9%p-`>O^N~Eq%RAQT0N<tDBoR7P28+K78M@gFQ6928P{C7H(AM
zlCwQET=yBU<eZEd^&0B5YMzR6j$^Dzxu74cSs^Yfo$u_)*~8bNqMcYMW%EB9H1Jh~
zBck!ZNs7VtaIm#-t#qs4*CntDPw<dn^ShAwi0|{acf+c~eeRAo6hw?hzMc*L^7t_+
zKbx73#!42FnUJ`k{j**@+-^}`9pKEIwj8l1e9?FES#C*en4@WVSXZ8uFC*@Kt%iBs
z6%!>J4F3D~!}s*|uPX8ZnNx|pT8`A%)cA^f1M4@-QVtjD2R<mW*juLXj&4Fi3oKlg
z=XA6*qdy9i*mXT_41m=u9{bL(CVFK0ESt*v!I$^ief{tcqpQoCox%O`DP?o_XKL$@
ziO<*ymv?q<6r04j&RjCkn2QWy((h0({Q)Rw(Y~NU_zS+LGhu{2Y0I^voS|9i`R~f;
zty^v?%mN>diMD)JWy9mhR4cSym%qHz)sv++A(4FN$~F;8m+0%rKru@(#(d}LJjKO!
zf6A-%7PZyvCBE*9UW>CNWssOuEFV>ct5cAr*>RA=<3?P?S$TZp&0<p(D#y!KnCv~?
z?`wsh1Z=+FVkFOCt;OxbYI)>NW)r142!G_US_w=W)j8<GJ6Y1AzOveRe(_k*m!_t%
zc2hX}U2|o)wh3G5a#mjDiw<R+h)C8-QpDAmfISe%Ju~<VDTPt())qA{RN35>(g_P}
z7GnDAie8fX!dyL69M)UY`YLA<-NL&odHn+2@}ksp!PY6#4}-zB94xD#7)_I!6}|Cp
zy)_vnTW~f9=u|+PnD?+%xW!>;357))Jn+wc{TnE9yKt6qnFnK*U}ouO!9^%ye-r7t
zDV{PM_G-;cSXb)WYm<DdsOL_)EnDKQHy>V%Xddqsqy+S>ze?xPTk@pW@C!<Pa*ZdQ
zVVeZ7eSGn_Wyc73y*vnRKHiFvx|!T?Ki0>3_Q>>}Ae4JCz9wW_<T8Ygv2p6rt1j^A
z$uI&RZcO>o8=$DeR=gU9Pi+6r-me>S%`@5hl^8}R|AXwzrQPvup02Puvs0+Pp^^3c
zZCj@hWtF$}xr*c&PfN}eH~pHU<yQq~FADcOs9iVVd<^Bk{(b0KX|nz~U-$X!$6uB_
zOSQNaCM6!k8$_XRksX|Gv6#>OR+VktdWKB+ZqL14*~1L91=LQ~Wa&n*DJIrT7$A>m
zm>H95DP*or={y55YqHUHrqEH53c$qp(>OqV#=b&Vb15=YrTg?f;IgmV?!JVNL{<X8
z+C8Y4g3jqehO(54rEdyI0`K>XS*M7xO{#jW=Cdy&j81<NIhrlBb#vs0e2i+hyw0-(
zUeM&cnKp_q0omKIe*KyWyG%-SnO3HZv|<J@52zp^Eh9TLtti~zx<M?#J}JMhE9oa%
zUb0LWWiZ!N_E<0TD`tIh;aZQnZm{gj&It|l>@>dfRX(y8C|gdoDvnWg$nD@1x~TY~
z^4y1&p$Z=ULqQFK{e5#>`hxO;BR_t7T0mKtZ{%(0ov@nAqwgua1v+xHvV&Fet+!8j
z(_zQbc`PkN9k7v{j}1zsI~umg=zS53k3zzoD_VNjy~9~7R%#-aLmB~$nM<oNB~^C+
c$N>NWRuZHIg&TJ!e!kw#jI7X44X{!F1;|%rtN;K2
new file mode 100644
--- /dev/null
+++ b/browser/themes/shared/devtools/projecteditor/projecteditor.css
@@ -0,0 +1,172 @@
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* 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/. */
+
+ :root {
+  color: #18191a;
+}
+
+.plugin-hidden {
+  display: none;
+}
+
+#projecteditor-menubar {
+  /* XXX: Hide menu bar until we have option to add menu items
+     to an existing one. */
+  display: none;
+}
+
+#projecteditor-toolbar,
+#projecteditor-toolbar-bottom {
+  display: none; /* For now don't show the status bars */
+  min-height: 22px;
+  height: 22px;
+  background: rgb(237, 237, 237);
+}
+
+.sources-tree {
+  overflow:auto;
+  -moz-user-focus: normal;
+}
+
+.sources-tree input {
+  margin: 2px;
+  border: 1px solid gray;
+}
+
+#main-deck .sources-tree {
+  background: rgb(225, 225, 225);
+  min-width: 50px;
+}
+
+#main-deck .sources-tree .side-menu-widget-item {
+  color: #18191A;
+}
+
+#main-deck .sources-tree .side-menu-widget-item .file-label {
+  vertical-align: middle;
+  display: inline-block;
+}
+
+#main-deck .sources-tree .side-menu-widget-item .file-icon {
+  display: inline-block;
+  background: url(file-icons-sheet@2x.png);
+  background-size: 140px 15px;
+  background-repeat: no-repeat;
+  width: 20px;
+  height: 15px;
+  vertical-align: middle;
+  background-position: -40px 0;
+}
+
+#main-deck .sources-tree .side-menu-widget-item .file-icon.icon-none {
+  display: none;
+}
+
+#main-deck .sources-tree .side-menu-widget-item .icon-css {
+  background-position: 0 0;
+}
+
+#main-deck .sources-tree .side-menu-widget-item .icon-js {
+  background-position: -20px 0;
+}
+
+#main-deck .sources-tree .side-menu-widget-item .icon-html {
+  background-position: -40px 0;
+}
+
+#main-deck .sources-tree .side-menu-widget-item .icon-file {
+  background-position: -60px 0;
+}
+
+#main-deck .sources-tree .side-menu-widget-item .icon-folder {
+  background-position: -80px 0;
+}
+
+#main-deck .sources-tree .side-menu-widget-item .icon-img {
+  background-position: -100px 0;
+}
+
+#main-deck .sources-tree .side-menu-widget-item .icon-manifest {
+  background-position: -120px 0;
+}
+
+#main-deck .sources-tree .side-menu-widget-item:hover {
+  background: rgba(0, 0, 0, .05);
+  cursor: pointer;
+}
+
+#main-deck .sources-tree .side-menu-widget-item {
+  border: none;
+  box-shadow: none;
+  line-height: 20px;
+  vertical-align: middle;
+  white-space: nowrap;
+}
+
+#main-deck .sources-tree .side-menu-widget-item.selected {
+  background: #3875D7;
+  color: #F5F7FA;
+  outline: none;
+}
+
+#main-deck .sources-tree .side-menu-widget-group-title,
+#main-deck .sources-tree .side-menu-widget-group-title:hover:not(.selected) {
+  background: #B4D7EB;
+  color: #222;
+  font-weight: bold;
+  font-size: 1.05em;
+  cursor: default;
+  line-height: 35px;
+}
+
+#main-deck .sources-tree li.child:only-child .side-menu-widget-group-title .expander {
+  display: none;
+}
+#main-deck .sources-tree .side-menu-widget-item .expander {
+  width: 16px;
+  padding: 0;
+}
+
+.tree-collapsed .children {
+  display: none;
+}
+
+/* Plugins */
+
+#projecteditor-toolbar textbox {
+  margin: 0;
+}
+
+.projecteditor-basic-display {
+  padding: 0 3px;
+}
+
+.project-name-label {
+  font-weight: bold;
+  padding-left: 10px;
+}
+
+.project-version-label {
+  color: #666;
+  padding-left: 5px;
+  font-size: .9em;
+}
+
+.project-image {
+  max-height: 28px;
+  margin-left: -.5em;
+  vertical-align: middle;
+}
+
+.editor-image {
+  padding: 10px;
+}
+
+.projecteditor-file-label {
+  font-weight: bold;
+  padding-left: 29px;
+  vertical-align: middle;
+}
+
--- a/browser/themes/windows/jar.mn
+++ b/browser/themes/windows/jar.mn
@@ -339,16 +339,18 @@ browser.jar:
         skin/classic/browser/devtools/vview-open-inspector@2x.png   (../shared/devtools/images/vview-open-inspector@2x.png)
         skin/classic/browser/devtools/undock@2x.png                 (../shared/devtools/images/undock@2x.png)
         skin/classic/browser/devtools/font-inspector.css            (../shared/devtools/font-inspector.css)
         skin/classic/browser/devtools/computedview.css              (../shared/devtools/computedview.css)
         skin/classic/browser/devtools/arrow-e.png                   (../shared/devtools/images/arrow-e.png)
         skin/classic/browser/devtools/responsiveui-rotate.png       (../shared/devtools/responsiveui-rotate.png)
         skin/classic/browser/devtools/responsiveui-touch.png        (../shared/devtools/responsiveui-touch.png)
         skin/classic/browser/devtools/responsiveui-screenshot.png   (../shared/devtools/responsiveui-screenshot.png)
+        skin/classic/browser/devtools/projecteditor/projecteditor.css           (../shared/devtools/projecteditor/projecteditor.css)
+        skin/classic/browser/devtools/projecteditor/file-icons-sheet@2x.png       (../shared/devtools/projecteditor/file-icons-sheet@2x.png)
         skin/classic/browser/devtools/app-manager/connection-footer.css     (../shared/devtools/app-manager/connection-footer.css)
         skin/classic/browser/devtools/app-manager/index.css                 (../shared/devtools/app-manager/index.css)
         skin/classic/browser/devtools/app-manager/device.css                (../shared/devtools/app-manager/device.css)
         skin/classic/browser/devtools/app-manager/projects.css              (../shared/devtools/app-manager/projects.css)
         skin/classic/browser/devtools/app-manager/help.css                  (../shared/devtools/app-manager/help.css)
         skin/classic/browser/devtools/app-manager/warning.svg               (../shared/devtools/app-manager/images/warning.svg)
         skin/classic/browser/devtools/app-manager/error.svg                 (../shared/devtools/app-manager/images/error.svg)
         skin/classic/browser/devtools/app-manager/plus.svg                  (../shared/devtools/app-manager/images/plus.svg)
@@ -727,16 +729,18 @@ browser.jar:
         skin/classic/aero/browser/devtools/vview-open-inspector@2x.png (../shared/devtools/images/vview-open-inspector@2x.png)
         skin/classic/aero/browser/devtools/undock@2x.png             (../shared/devtools/images/undock@2x.png)
         skin/classic/aero/browser/devtools/font-inspector.css        (../shared/devtools/font-inspector.css)
         skin/classic/aero/browser/devtools/computedview.css          (../shared/devtools/computedview.css)
         skin/classic/aero/browser/devtools/arrow-e.png               (../shared/devtools/images/arrow-e.png)
         skin/classic/aero/browser/devtools/responsiveui-rotate.png   (../shared/devtools/responsiveui-rotate.png)
         skin/classic/aero/browser/devtools/responsiveui-touch.png    (../shared/devtools/responsiveui-touch.png)
         skin/classic/aero/browser/devtools/responsiveui-screenshot.png (../shared/devtools/responsiveui-screenshot.png)
+        skin/classic/aero/browser/devtools/projecteditor/projecteditor.css       (../shared/devtools/projecteditor/projecteditor.css)
+        skin/classic/aero/browser/devtools/projecteditor/file-icons-sheet@2x.png       (../shared/devtools/projecteditor/file-icons-sheet@2x.png)
         skin/classic/aero/browser/devtools/app-manager/connection-footer.css     (../shared/devtools/app-manager/connection-footer.css)
         skin/classic/aero/browser/devtools/app-manager/index.css                 (../shared/devtools/app-manager/index.css)
         skin/classic/aero/browser/devtools/app-manager/device.css                (../shared/devtools/app-manager/device.css)
         skin/classic/aero/browser/devtools/app-manager/projects.css              (../shared/devtools/app-manager/projects.css)
         skin/classic/aero/browser/devtools/app-manager/help.css                  (../shared/devtools/app-manager/help.css)
         skin/classic/aero/browser/devtools/app-manager/warning.svg               (../shared/devtools/app-manager/images/warning.svg)
         skin/classic/aero/browser/devtools/app-manager/error.svg                 (../shared/devtools/app-manager/images/error.svg)
         skin/classic/aero/browser/devtools/app-manager/plus.svg                  (../shared/devtools/app-manager/images/plus.svg)
--- a/toolkit/devtools/Loader.jsm
+++ b/toolkit/devtools/Loader.jsm
@@ -55,16 +55,17 @@ let loaderGlobals = {
   }
 };
 
 // Used when the tools should be loaded from the Firefox package itself (the default)
 function BuiltinProvider() {}
 BuiltinProvider.prototype = {
   load: function() {
     this.loader = new loader.Loader({
+      id: "fx-devtools",
       modules: {
         "Debugger": Debugger,
         "Services": Object.create(Services),
         "Timer": Object.create(Timer),
         "toolkit/loader": loader,
         "xpcInspector": xpcInspector,
       },
       paths: {
@@ -81,16 +82,17 @@ BuiltinProvider.prototype = {
         "devtools/css-color": "resource://gre/modules/devtools/css-color",
         "devtools/output-parser": "resource://gre/modules/devtools/output-parser",
         "devtools/touch-events": "resource://gre/modules/devtools/touch-events",
         "devtools/client": "resource://gre/modules/devtools/client",
         "devtools/pretty-fast": "resource://gre/modules/devtools/pretty-fast.js",
         "devtools/async-utils": "resource://gre/modules/devtools/async-utils",
         "devtools/content-observer": "resource://gre/modules/devtools/content-observer",
         "gcli": "resource://gre/modules/devtools/gcli",
+        "projecteditor": "resource:///modules/devtools/projecteditor",
         "acorn": "resource://gre/modules/devtools/acorn",
         "acorn/util/walk": "resource://gre/modules/devtools/acorn/walk.js",
         "tern": "resource://gre/modules/devtools/tern",
         "source-map": "resource://gre/modules/devtools/SourceMap.jsm",
 
         // Allow access to xpcshell test items from the loader.
         "xpcshell-test": "resource://test"
       },
@@ -133,21 +135,23 @@ SrcdirProvider.prototype = {
     let cssColorURI = this.fileURI(OS.Path.join(toolkitDir, "css-color"));
     let outputParserURI = this.fileURI(OS.Path.join(toolkitDir, "output-parser"));
     let touchEventsURI = this.fileURI(OS.Path.join(toolkitDir, "touch-events"));
     let clientURI = this.fileURI(OS.Path.join(toolkitDir, "client"));
     let prettyFastURI = this.fileURI(OS.Path.join(toolkitDir), "pretty-fast.js");
     let asyncUtilsURI = this.fileURI(OS.Path.join(toolkitDir), "async-utils.js");
     let contentObserverURI = this.fileURI(OS.Path.join(toolkitDir), "content-observer.js");
     let gcliURI = this.fileURI(OS.Path.join(toolkitDir, "gcli", "source", "lib", "gcli"));
+    let projecteditorURI = this.fileURI(OS.Path.join(devtoolsDir, "projecteditor"));
     let acornURI = this.fileURI(OS.Path.join(toolkitDir, "acorn"));
     let acornWalkURI = OS.Path.join(acornURI, "walk.js");
     let ternURI = OS.Path.join(toolkitDir, "tern");
     let sourceMapURI = this.fileURI(OS.Path.join(toolkitDir), "SourceMap.jsm");
     this.loader = new loader.Loader({
+      id: "fx-devtools",
       modules: {
         "Debugger": Debugger,
         "Services": Object.create(Services),
         "Timer": Object.create(Timer),
         "toolkit/loader": loader,
         "xpcInspector": xpcInspector,
       },
       paths: {
@@ -162,16 +166,17 @@ SrcdirProvider.prototype = {
         "devtools/css-color": cssColorURI,
         "devtools/output-parser": outputParserURI,
         "devtools/touch-events": touchEventsURI,
         "devtools/client": clientURI,
         "devtools/pretty-fast": prettyFastURI,
         "devtools/async-utils": asyncUtilsURI,
         "devtools/content-observer": contentObserverURI,
         "gcli": gcliURI,
+        "projecteditor": projecteditorURI,
         "acorn": acornURI,
         "acorn/util/walk": acornWalkURI,
         "tern": ternURI,
         "source-map": sourceMapURI,
       },
       globals: loaderGlobals,
       invisibleToDebugger: this.invisibleToDebugger
     });