Bug 922193 - Add VariablesView as manifest editor in App Manager. r=paul
authorJ. Ryan Stinnett <jryans@gmail.com>
Wed, 09 Oct 2013 00:52:38 -0500
changeset 165659 f12bb231ae374f70a5e08ee86e7cb45e56a06687
parent 165658 a8177c0528e68a47598a42981dfa18713e82a257
child 165660 5d220eac034245553413489cf24eb9264d20312f
push id428
push userbbajaj@mozilla.com
push dateTue, 28 Jan 2014 00:16:25 +0000
treeherdermozilla-release@cd72a7ff3a75 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspaul
bugs922193
milestone27.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 922193 - Add VariablesView as manifest editor in App Manager. r=paul
browser/app/profile/firefox.js
browser/devtools/app-manager/app-validator.js
browser/devtools/app-manager/content/manifest-editor.js
browser/devtools/app-manager/content/projects.js
browser/devtools/app-manager/content/projects.xhtml
browser/devtools/app-manager/test/browser.ini
browser/devtools/app-manager/test/browser_manifest_editor.js
browser/devtools/app-manager/test/head.js
browser/devtools/app-manager/test/moz.build
browser/devtools/jar.mn
browser/locales/en-US/chrome/browser/devtools/app-manager.dtd
browser/themes/shared/devtools/app-manager/projects.css
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1077,19 +1077,20 @@ pref("services.sync.prefs.sync.xpinstall
 pref("devtools.errorconsole.enabled", false);
 
 // Developer toolbar and GCLI preferences
 pref("devtools.toolbar.enabled", true);
 pref("devtools.toolbar.visible", false);
 pref("devtools.gcli.allowSet", false);
 pref("devtools.commands.dir", "");
 
-// Disable the app manager
+// Enable the app manager
 pref("devtools.appmanager.enabled", true);
 pref("devtools.appmanager.firstrun", true);
+pref("devtools.appmanager.manifestEditor.enabled", false);
 
 // Toolbox preferences
 pref("devtools.toolbox.footer.height", 250);
 pref("devtools.toolbox.sidebar.width", 500);
 pref("devtools.toolbox.host", "bottom");
 pref("devtools.toolbox.selectedTool", "webconsole");
 pref("devtools.toolbox.toolbarSpec", '["paintflashing toggle","tilt toggle","scratchpad","resize toggle"]');
 pref("devtools.toolbox.sideEnabled", true);
--- a/browser/devtools/app-manager/app-validator.js
+++ b/browser/devtools/app-manager/app-validator.js
@@ -19,34 +19,41 @@ function AppValidator(project) {
 AppValidator.prototype.error = function (message) {
   this.errors.push(message);
 }
 
 AppValidator.prototype.warning = function (message) {
   this.warnings.push(message);
 }
 
-AppValidator.prototype._getPackagedManifestURL = function () {
+AppValidator.prototype._getPackagedManifestFile = function () {
   let manifestFile = FileUtils.File(this.project.location);
   if (!manifestFile.exists()) {
     this.error(strings.GetStringFromName("validator.nonExistingFolder"));
-    return;
+    return null;
   }
   if (!manifestFile.isDirectory()) {
     this.error(strings.GetStringFromName("validator.expectProjectFolder"));
-    return;
+    return null;
   }
   manifestFile.append("manifest.webapp");
   if (!manifestFile.exists() || !manifestFile.isFile()) {
     this.error(strings.GetStringFromName("validator.wrongManifestFileName"));
-    return;
+    return null;
   }
+  return manifestFile;
+};
 
+AppValidator.prototype._getPackagedManifestURL = function () {
+  let manifestFile = this._getPackagedManifestFile();
+  if (!manifestFile) {
+    return null;
+  }
   return Services.io.newFileURI(manifestFile).spec;
-}
+};
 
 AppValidator.prototype._fetchManifest = function (manifestURL) {
   let deferred = promise.defer();
 
   let req = new XMLHttpRequest();
   try {
     req.open("GET", manifestURL, true);
   } catch(e) {
new file mode 100644
--- /dev/null
+++ b/browser/devtools/app-manager/content/manifest-editor.js
@@ -0,0 +1,105 @@
+/* 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";
+
+Cu.import("resource://gre/modules/osfile.jsm");
+const {VariablesView} =
+  Cu.import("resource:///modules/devtools/VariablesView.jsm", {});
+
+const VARIABLES_VIEW_URL =
+  "chrome://browser/content/devtools/widgets/VariablesView.xul";
+
+function ManifestEditor(project) {
+  this.project = project;
+  this._onContainerReady = this._onContainerReady.bind(this);
+  this._onEval = this._onEval.bind(this);
+  this._onSwitch = this._onSwitch.bind(this);
+  this._onDelete = this._onDelete.bind(this);
+}
+
+ManifestEditor.prototype = {
+  get manifest() { return this.project.manifest; },
+
+  show: function(containerElement) {
+    let deferred = promise.defer();
+    let iframe = document.createElement("iframe");
+
+    iframe.addEventListener("load", function onIframeLoad() {
+      iframe.removeEventListener("load", onIframeLoad, true);
+      deferred.resolve(iframe.contentWindow);
+    }, true);
+
+    iframe.setAttribute("src", VARIABLES_VIEW_URL);
+    iframe.classList.add("variables-view");
+    containerElement.appendChild(iframe);
+
+    return deferred.promise.then(this._onContainerReady);
+  },
+
+  _onContainerReady: function(varWindow) {
+    let variablesContainer = varWindow.document.querySelector("#variables");
+
+    let editor = this.editor = new VariablesView(variablesContainer);
+
+    editor.eval = this._onEval;
+    editor.switch = this._onSwitch;
+    editor.delete = this._onDelete;
+
+    return this.update();
+  },
+
+  _onEval: function(evalString) {
+    let manifest = this.manifest;
+    eval("manifest" + evalString);
+    this.update();
+  },
+
+  _onSwitch: function(variable, newName) {
+    let manifest = this.manifest;
+    let newSymbolicName = variable.ownerView.symbolicName +
+                          "['" + newName + "']";
+    if (newSymbolicName == variable.symbolicName) {
+      return;
+    }
+
+    let evalString = "manifest" + newSymbolicName + " = " +
+                     "manifest" + variable.symbolicName + ";" +
+                     "delete manifest" + variable.symbolicName;
+
+    eval(evalString);
+    this.update();
+  },
+
+  _onDelete: function(variable) {
+    let manifest = this.manifest;
+    let evalString = "delete manifest" + variable.symbolicName;
+    eval(evalString);
+  },
+
+  update: function() {
+    this.editor.createHierarchy();
+    this.editor.rawObject = this.manifest;
+    this.editor.commitHierarchy();
+
+    // Wait until the animation from commitHierarchy has completed
+    let deferred = promise.defer();
+    setTimeout(deferred.resolve, this.editor.lazyEmptyDelay + 1);
+    return deferred.promise;
+  },
+
+  save: function() {
+    if (this.project.type == "packaged") {
+      let validator = new AppValidator(this.project);
+      let manifestFile = validator._getPackagedManifestFile();
+      let path = manifestFile.path;
+
+      let encoder = new TextEncoder();
+      let data = encoder.encode(JSON.stringify(this.manifest, null, 2));
+
+      return OS.File.writeAtomic(path, data, { tmpPath: path + ".tmp" });
+    }
+
+    return promise.resolve();
+  }
+};
--- a/browser/devtools/app-manager/content/projects.js
+++ b/browser/devtools/app-manager/content/projects.js
@@ -10,19 +10,22 @@ Cu.import("resource:///modules/devtools/
 const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
 const {require} = devtools;
 const {ConnectionManager, Connection} = require("devtools/client/connection-manager");
 const {AppProjects} = require("devtools/app-manager/app-projects");
 const {AppValidator} = require("devtools/app-manager/app-validator");
 const {Services} = Cu.import("resource://gre/modules/Services.jsm");
 const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm");
 const {installHosted, installPackaged, getTargetForApp} = require("devtools/app-actor-front");
+const {EventEmitter} = Cu.import("resource:///modules/devtools/shared/event-emitter.js");
 
 const promise = require("sdk/core/promise");
 
+const MANIFEST_EDITOR_ENABLED = "devtools.appmanager.manifestEditor.enabled";
+
 window.addEventListener("message", function(event) {
   try {
     let json = JSON.parse(event.data);
     if (json.name == "connection") {
       let cid = parseInt(json.cid);
       for (let c of ConnectionManager.connections) {
         if (c.uid == cid) {
           UI.connection = c;
@@ -30,22 +33,30 @@ window.addEventListener("message", funct
           break;
         }
       }
     }
   } catch(e) {}
 }, false);
 
 let UI = {
+  isReady: false,
+
   onload: function() {
+    if (Services.prefs.getBoolPref(MANIFEST_EDITOR_ENABLED)) {
+      document.querySelector("#lense").setAttribute("manifest-editable", "");
+    }
+
     this.template = new Template(document.body, AppProjects.store, Utils.l10n);
     this.template.start();
 
     AppProjects.load().then(() => {
       AppProjects.store.object.projects.forEach(UI.validate);
+      this.isReady = true;
+      this.emit("ready");
     });
   },
 
   onNewConnection: function() {
     this.connection.on(Connection.Events.STATUS_CHANGED, () => this._onConnectionStatusChange());
     this._onConnectionStatusChange();
   },
 
@@ -83,21 +94,21 @@ let UI = {
 
   addHosted: function() {
     let form = document.querySelector("#new-hosted-project-wrapper")
     if (!form.checkValidity())
       return;
 
     let urlInput = document.querySelector("#url-input");
     let manifestURL = urlInput.value;
-    AppProjects.addHosted(manifestURL)
-               .then(function (project) {
-                 UI.validate(project);
-                 UI.selectProject(project.location);
-               });
+    return AppProjects.addHosted(manifestURL)
+                      .then(function (project) {
+                        UI.validate(project);
+                        UI.selectProject(project.location);
+                      });
   },
 
   _getLocalIconURL: function(project, manifest) {
     let icon;
     if (manifest.icons) {
       let size = Object.keys(manifest.icons).sort(function(a, b) b - a)[0];
       if (size) {
         icon = manifest.icons[size];
@@ -116,17 +127,16 @@ let UI = {
     }
   },
 
   validate: function(project) {
     let validation = new AppValidator(project);
     return validation.validate()
       .then(function () {
         if (validation.manifest) {
-          project.name = validation.manifest.name;
           project.icon = UI._getLocalIconURL(project, validation.manifest);
           project.manifest = validation.manifest;
         }
 
         project.validationStatus = "valid";
 
         if (validation.warnings.length > 0) {
           project.warningsCount = validation.warnings.length;
@@ -148,17 +158,20 @@ let UI = {
 
       });
 
   },
 
   update: function(button, location) {
     button.disabled = true;
     let project = AppProjects.get(location);
-    this.validate(project)
+    this.manifestEditor.save()
+        .then(() => {
+          return this.validate(project);
+        })
         .then(() => {
            // Install the app to the device if we are connected,
            // and there is no error
            if (project.errorsCount == 0 && this.listTabsResponse) {
              return this.install(project);
            }
          })
         .then(
@@ -381,10 +394,21 @@ let UI = {
     let button = document.getElementById(location);
     button.classList.add("selected");
 
     let template = '{"path":"projects.' + idx + '","childSelector":"#lense-template"}';
 
     let lense = document.querySelector("#lense");
     lense.setAttribute("template-for", template);
     this.template._processFor(lense);
+
+    let project = projects[idx];
+    this._showManifestEditor(project).then(() => this.emit("project-selected"));
   },
-}
+
+  _showManifestEditor: function(project) {
+    let editorContainer = document.querySelector("#lense .manifest-editor");
+    this.manifestEditor = new ManifestEditor(project);
+    return this.manifestEditor.show(editorContainer);
+  }
+};
+
+EventEmitter.decorate(UI);
--- a/browser/devtools/app-manager/content/projects.xhtml
+++ b/browser/devtools/app-manager/content/projects.xhtml
@@ -9,61 +9,65 @@
 
 <html xmlns="http://www.w3.org/1999/xhtml">
 
   <head>
     <meta charset="utf8"/>
     <base href="chrome://browser/content/devtools/app-manager/"></base>
     <title>&projects.title;</title>
     <link rel="stylesheet" href="chrome://browser/skin/devtools/app-manager/projects.css" type="text/css"/>
+    <script type="application/javascript;version=1.8" src="utils.js"></script>
+    <script type="application/javascript;version=1.8" src="projects.js"></script>
+    <script type="application/javascript;version=1.8" src="template.js"></script>
+    <script type="application/javascript;version=1.8" src="manifest-editor.js"></script>
   </head>
 
   <body onload="UI.onload()">
     <aside id="sidebar">
       <div id="project-list" template='{"type":"attribute","path":"projects.length","name":"projects-count"}'>
         <div template-loop='{"arrayPath":"projects","childSelector":"#project-item-template"}'></div>
         <div id="no-project">&projects.noProjects;</div>
       </div>
       <div id="new-packaged-project" onclick="UI.addPackaged()" title="&projects.addPackagedTooltip;">&projects.addPackaged;</div>
       <div id="new-hosted-project">&projects.addHosted;
         <form onsubmit="UI.addHosted(); return false;" id="new-hosted-project-wrapper">
-          <input value="" id="url-input" type="url" required="true" pattern="https?://.+" placeholder="&projects.hostedManifestPlaceHolder2;" size="50" />
+          <input value="" id="url-input" type="url" required="true" pattern="(https?|chrome)://.+" placeholder="&projects.hostedManifestPlaceHolder2;" size="50" />
           <div onclick="UI.addHosted()" id="new-hosted-project-click" title="&projects.addHostedTooltip;"></div>
           <input type="submit" hidden="true"></input>
         </form>
       </div>
     </aside>
     <section id="lense"></section>
   </body>
 
   <template id="project-item-template">
   <div class="project-item" template='{"type":"attribute","path":"location","name":"id"}' onclick="UI.selectProject(this.id)">
     <div class="project-item-status" template='{"type":"attribute","path":"validationStatus","name":"status"}'></div>
     <img class="project-item-icon" template='{"type":"attribute","path":"icon","name":"src"}' />
     <div class="project-item-meta">
       <div class="button-remove" onclick="UI.remove(this.dataset.location, event)" template='{"type":"attribute","path":"location","name":"data-location"}' title="&projects.removeAppFromList;"></div>
-      <strong template='{"type":"textContent","path":"name"}'></strong>
+      <strong template='{"type":"textContent","path":"manifest.name"}'></strong>
       <span class="project-item-type" template='{"type":"textContent","path":"type"}'></span>
       <p class="project-item-description" template='{"type":"textContent","path":"manifest.description"}'></p>
       <div template='{"type":"attribute","path":"validationStatus","name":"status"}'>
         <div class="project-item-errors"><span template='{"type":"textContent","path":"errorsCount"}'></span></div>
         <div class="project-item-warnings"><span template='{"type":"textContent","path":"warningsCount"}'></span></div>
       </div>
     </div>
   </div>
   </template>
 
   <template id="lense-template">
   <div>
     <div class="project-details" template='{"type":"attribute","path":"validationStatus","name":"status"}'>
       <div class="project-header">
         <img class="project-icon" template='{"type":"attribute","path":"icon","name":"src"}'/>
-        <div class="project-details">
+        <div class="project-metadata">
           <div class="project-title">
-            <h1 template='{"type":"textContent","path":"name"}'></h1>
+            <h1 template='{"type":"textContent","path":"manifest.name"}'></h1>
             <div class="project-status" template='{"type":"attribute","path":"validationStatus","name":"status"}'>
               <p class="project-validation" template='{"type":"textContent","path":"validationStatus"}'></p>
               <p class="project-type" template='{"type":"textContent","path":"type"}'></p>
             </div>
           </div>
           <span template='{"type":"textContent","path":"manifest.developer.name"}'></span>
           <p class="project-location" template='{"type":"textContent","path":"location"}' onclick="UI.reveal(this.textContent)"></p>
           <p class="project-description" template='{"type":"textContent","path":"manifest.description"}'></p>
@@ -71,16 +75,14 @@
       </div>
       <div class="project-buttons">
         <button class="project-button-update" onclick="UI.update(this, this.dataset.location)" template='{"type":"attribute","path":"location","name":"data-location"}' title="&projects.updateAppTooltip;">&projects.updateApp;</button>
         <button class="device-action project-button-debug" onclick="UI.debug(this, this.dataset.location)" template='{"type":"attribute","path":"location","name":"data-location"}' title="&projects.debugAppTooltip;">&projects.debugApp;</button>
       </div>
       <div class="project-errors" template='{"type":"textContent","path":"errors"}'></div>
       <div class="project-warnings" template='{"type":"textContent","path":"warnings"}'></div>
     </div>
+    <div class="manifest-editor">
+      <h2>&projects.manifestEditor;</h2>
+    </div>
   </div>
   </template>
-
-
-  <script type="application/javascript;version=1.8" src="utils.js"></script>
-  <script type="application/javascript;version=1.8" src="projects.js"></script>
-  <script type="application/javascript;version=1.8" src="template.js"></script>
 </html>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/app-manager/test/browser.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+support-files =
+  head.js
+  hosted_app.manifest
+
+[browser_manifest_editor.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/app-manager/test/browser_manifest_editor.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const {Services} = Cu.import("resource://gre/modules/Services.jsm");
+
+const MANIFEST_EDITOR_ENABLED = "devtools.appmanager.manifestEditor.enabled";
+
+function test() {
+  waitForExplicitFinish();
+
+  Task.spawn(function() {
+    Services.prefs.setBoolPref(MANIFEST_EDITOR_ENABLED, true);
+    let tab = yield openAppManager();
+    yield selectProjectsPanel();
+    yield addSampleHostedApp();
+    yield showSampleProjectDetails();
+    yield changeManifestValue("name", "the best app");
+    yield removeSampleHostedApp();
+    yield removeTab(tab);
+    Services.prefs.setBoolPref(MANIFEST_EDITOR_ENABLED, false);
+    finish();
+  });
+}
+
+function changeManifestValue(key, value) {
+  return Task.spawn(function() {
+    let manifestWindow = getManifestWindow();
+    let manifestEditor = getProjectsWindow().UI.manifestEditor;
+
+    let propElem = manifestWindow.document
+                   .querySelector("[id ^= '" + key + "']");
+    is(propElem.querySelector(".name").value, key,
+       "Key doesn't match expected value");
+
+    let valueElem = propElem.querySelector(".value");
+    EventUtils.sendMouseEvent({ type: "mousedown" }, valueElem, manifestWindow);
+
+    let valueInput = propElem.querySelector(".element-value-input");
+    valueInput.value = '"' + value + '"';
+    EventUtils.sendKey("RETURN", manifestWindow);
+
+    // Wait until the animation from commitHierarchy has completed
+    yield waitForTime(manifestEditor.editor.lazyEmptyDelay + 1);
+    // Elements have all been replaced, re-select them
+    propElem = manifestWindow.document.querySelector("[id ^= '" + key + "']");
+    valueElem = propElem.querySelector(".value");
+    is(valueElem.value, '"' + value + '"',
+       "Value doesn't match expected value");
+
+    is(manifestEditor.manifest[key], value,
+       "Manifest doesn't contain expected value");
+  });
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/app-manager/test/head.js
@@ -0,0 +1,152 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const {utils: Cu} = Components;
+
+const {Promise: promise} =
+  Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", {});
+const {devtools} =
+  Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+const {require} = devtools;
+
+const {AppProjects} = require("devtools/app-manager/app-projects");
+
+const APP_MANAGER_URL = "about:app-manager";
+const TEST_BASE =
+  "chrome://mochitests/content/browser/browser/devtools/app-manager/test/";
+const HOSTED_APP_MANIFEST = TEST_BASE + "hosted_app.manifest";
+
+function addTab(url, targetWindow = window) {
+  info("Adding tab: " + url);
+
+  let deferred = promise.defer();
+  let targetBrowser = targetWindow.gBrowser;
+
+  targetWindow.focus();
+  let tab = targetBrowser.selectedTab = targetBrowser.addTab(url);
+  let linkedBrowser = tab.linkedBrowser;
+
+  linkedBrowser.addEventListener("load", function onLoad() {
+    linkedBrowser.removeEventListener("load", onLoad, true);
+    info("Tab added and finished loading: " + url);
+    deferred.resolve(tab);
+  }, true);
+
+  return deferred.promise;
+}
+
+function removeTab(tab, targetWindow = window) {
+  info("Removing tab.");
+
+  let deferred = promise.defer();
+  let targetBrowser = targetWindow.gBrowser;
+  let tabContainer = targetBrowser.tabContainer;
+
+  tabContainer.addEventListener("TabClose", function onClose(aEvent) {
+    tabContainer.removeEventListener("TabClose", onClose, false);
+    info("Tab removed and finished closing.");
+    deferred.resolve();
+  }, false);
+
+  targetBrowser.removeTab(tab);
+
+  return deferred.promise;
+}
+
+function openAppManager() {
+  return addTab(APP_MANAGER_URL);
+}
+
+function addSampleHostedApp() {
+  info("Adding sample hosted app");
+  let projectsWindow = getProjectsWindow();
+  let projectsDocument = projectsWindow.document;
+  let url = projectsDocument.querySelector("#url-input");
+  url.value = HOSTED_APP_MANIFEST;
+  return projectsWindow.UI.addHosted();
+}
+
+function removeSampleHostedApp() {
+  info("Removing sample hosted app");
+  return AppProjects.remove(HOSTED_APP_MANIFEST);
+}
+
+function getProjectsWindow() {
+  return content.document.querySelector(".projects-panel").contentWindow;
+}
+
+function getManifestWindow() {
+  return getProjectsWindow().document.querySelector(".variables-view")
+         .contentWindow;
+}
+
+function waitForProjectsPanel(deferred = promise.defer()) {
+  info("Wait for projects panel");
+
+  let projectsWindow = getProjectsWindow();
+  let projectsUI = projectsWindow.UI;
+  if (!projectsUI) {
+    projectsWindow.addEventListener("load", function onLoad() {
+      projectsWindow.removeEventListener("load", onLoad);
+      waitForProjectsPanel(deferred);
+    });
+    return deferred.promise;
+  }
+
+  if (projectsUI.isReady) {
+    deferred.resolve();
+    return deferred.promise;
+  }
+
+  projectsUI.once("ready", deferred.resolve);
+  return deferred.promise;
+}
+
+function selectProjectsPanel() {
+  return Task.spawn(function() {
+    let projectsButton = content.document.querySelector(".projects-button");
+    EventUtils.sendMouseEvent({ type: "click" }, projectsButton, content);
+
+    yield waitForProjectsPanel();
+  });
+}
+
+function waitForProjectSelection() {
+  info("Wait for project selection");
+
+  let deferred = promise.defer();
+  getProjectsWindow().UI.once("project-selected", deferred.resolve);
+  return deferred.promise;
+}
+
+function selectFirstProject() {
+  return Task.spawn(function() {
+    let projectsFrame = content.document.querySelector(".projects-panel");
+    let projectsWindow = projectsFrame.contentWindow;
+    let projectsDoc = projectsWindow.document;
+    let projectItem = projectsDoc.querySelector(".project-item");
+    EventUtils.sendMouseEvent({ type: "click" }, projectItem, projectsWindow);
+
+    yield waitForProjectSelection();
+  });
+}
+
+function showSampleProjectDetails() {
+  return Task.spawn(function() {
+    yield selectProjectsPanel();
+    yield selectFirstProject();
+  });
+}
+
+function waitForTick() {
+  let deferred = promise.defer();
+  executeSoon(deferred.resolve);
+  return deferred.promise;
+}
+
+function waitForTime(aDelay) {
+  let deferred = promise.defer();
+  setTimeout(deferred.resolve, aDelay);
+  return deferred.promise;
+}
--- a/browser/devtools/app-manager/test/moz.build
+++ b/browser/devtools/app-manager/test/moz.build
@@ -1,8 +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']
 MOCHITEST_CHROME_MANIFESTS += ['chrome.ini']
-
--- a/browser/devtools/jar.mn
+++ b/browser/devtools/jar.mn
@@ -80,8 +80,9 @@ browser.jar:
     content/browser/devtools/app-manager/connection-footer.xhtml       (app-manager/content/connection-footer.xhtml)
     content/browser/devtools/app-manager/device.js                     (app-manager/content/device.js)
     content/browser/devtools/app-manager/device.xhtml                  (app-manager/content/device.xhtml)
     content/browser/devtools/app-manager/projects.js                   (app-manager/content/projects.js)
     content/browser/devtools/app-manager/projects.xhtml                (app-manager/content/projects.xhtml)
     content/browser/devtools/app-manager/index.xul                     (app-manager/content/index.xul)
     content/browser/devtools/app-manager/index.js                      (app-manager/content/index.js)
     content/browser/devtools/app-manager/help.xhtml                    (app-manager/content/help.xhtml)
+    content/browser/devtools/app-manager/manifest-editor.js            (app-manager/content/manifest-editor.js)
--- a/browser/locales/en-US/chrome/browser/devtools/app-manager.dtd
+++ b/browser/locales/en-US/chrome/browser/devtools/app-manager.dtd
@@ -68,16 +68,17 @@
 <!ENTITY projects.appDetails "App Details">
 <!ENTITY projects.removeAppFromList "Remove this app from the list of apps you are working on. This will not remove it from a device or a simulator.">
 <!ENTITY projects.updateApp "Update">
 <!ENTITY projects.updateAppTooltip "Execute validation checks and update the app to the connected device">
 <!ENTITY projects.debugApp "Debug">
 <!ENTITY projects.debugAppTooltip "Open Developer Tools connected to this app">
 <!ENTITY projects.hostedManifestPlaceHolder2 "http://example.com/app/manifest.webapp">
 <!ENTITY projects.noProjects "No projects. Add a new packaged app below (local directory) or a hosted app (link to a manifest file).">
+<!ENTITY projects.manifestEditor "Manifest Editor">
 
 <!ENTITY help.title "App Manager">
 <!ENTITY help.close "Close">
 <!ENTITY help.intro "This tool will help you build and install web apps on compatible devices (i.e. Firefox OS). The <strong>Apps</strong> tab will assist you in the validation and installation process of your app. The <strong>Device</strong> tab will give you information about the connected device. Use the bottom toolbar to connect to a device or start the simulator.">
 <!ENTITY help.usefullLinks "Useful links:">
 <!ENTITY help.appMgrDoc "Documentation: Using the App Manager">
 <!ENTITY help.configuringDevice "How to setup your Firefox OS device">
 <!ENTITY help.troubleShooting "Troubleshooting">
--- a/browser/themes/shared/devtools/app-manager/projects.css
+++ b/browser/themes/shared/devtools/app-manager/projects.css
@@ -226,27 +226,33 @@ strong {
   background-repeat: no-repeat, repeat;
   background-size: 35%, auto;
   background-position: center center, top left;
 }
 
 #lense > div {
   display: flex;
   flex-grow: 1;
+  flex-direction: column;
 }
 
 
 /********* PROJECT ***********/
 
 
 .project-details {
   background-color: rgb(225, 225, 225);
   padding: 10px;
+  line-height: 160%;
+  display: flex;
+  flex-direction: column;
+}
+
+.project-metadata {
   flex-grow: 1;
-  line-height: 160%;
 }
 
 .project-status {
   display: flex;
 }
 
 .project-title {
   flex-direction: row;
@@ -433,8 +439,49 @@ strong {
 }
 
 .project-item-warnings > span,
 .project-item-errors > span {
   font-size: 11px;
   padding-left: 16px;
   font-weight: bold;
 }
+
+
+/********* MANIFEST EDITOR ***********/
+
+.manifest-editor {
+  display: flex;
+  flex-direction: column;
+  flex-grow: 1;
+  background-color: #E1E1E1;
+}
+
+.manifest-editor > h2 {
+  font-size: 18px;
+  margin: 1em 30px;
+}
+
+.variables-view {
+  flex-grow: 1;
+  border: 0;
+  border-top: 5px solid #C9C9C9;
+}
+
+/* Bug 925921: Remove when the manifest editor is always on */
+
+.manifest-editor {
+  display: none;
+}
+
+.project-details {
+  flex-grow: 1;
+}
+
+#lense[manifest-editable] .manifest-editor {
+  display: flex;
+}
+
+#lense[manifest-editable] .project-details {
+  flex-grow: 0;
+}
+
+/* End blocks to remove */