diff --git a/browser/devtools/app-manager/Makefile.in b/browser/devtools/app-manager/Makefile.in
new file mode 100644
index 00000000000..de2b81dba52
--- /dev/null
+++ b/browser/devtools/app-manager/Makefile.in
@@ -0,0 +1,16 @@
+#
+# 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/.
+
+DEPTH = @DEPTH@
+topsrcdir = @top_srcdir@
+srcdir = @srcdir@
+VPATH = @srcdir@
+
+include $(DEPTH)/config/autoconf.mk
+
+include $(topsrcdir)/config/rules.mk
+
+libs::
+ $(NSINSTALL) $(srcdir)/*.js $(FINAL_TARGET)/modules/devtools/app-manager
diff --git a/browser/devtools/app-manager/content/template.js b/browser/devtools/app-manager/content/template.js
new file mode 100644
index 00000000000..f43b85678c6
--- /dev/null
+++ b/browser/devtools/app-manager/content/template.js
@@ -0,0 +1,320 @@
+/* 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/. */
+
+/**
+ * Template mechanism based on Object Emitters.
+ *
+ * The data used to expand the templates comes from
+ * a ObjectEmitter object. The templates are automatically
+ * updated as the ObjectEmitter is updated (via the "set"
+ * event). See documentation in observable-object.js.
+ *
+ * Templates are used this way:
+ *
+ * (See examples in browser/devtools/app-manager/content/*.xhtml)
+ *
+ *
+ *
+ * {
+ * type: "attribute"
+ * name: name of the attribute
+ * path: location of the attribute value in the ObjectEmitter
+ * }
+ *
+ * {
+ * type: "textContent"
+ * path: location of the textContent value in the ObjectEmitter
+ * }
+ *
+ * {
+ * type: "localizedContent"
+ * paths: array of locations of the value of the arguments of the property
+ * property: l10n property
+ * }
+ *
+ *
+ *
+ * {
+ * arrayPath: path of the array in the ObjectEmitter to loop from
+ * childSelector: selector of the element to duplicate in the loop
+ * }
+ *
+ */
+
+const NOT_FOUND_STRING = "n/a";
+
+/**
+ * let t = new Template(root, store, l10nResolver);
+ * t.start();
+ *
+ * @param DOMNode root.
+ * Node from where templates are expanded.
+ * @param ObjectEmitter store.
+ * ObjectEmitter object.
+ * @param function (property, args). l10nResolver
+ * A function that returns localized content.
+ */
+function Template(root, store, l10nResolver) {
+ this._store = store;
+ this._l10n = l10nResolver;
+
+ // Listeners are stored in Maps.
+ // path => Set(node1, node2, ..., nodeN)
+ // For example: "foo.bar.4.name" => Set(div1,div2)
+
+ this._nodeListeners = new Map();
+ this._loopListeners = new Map();
+ this._root = root;
+ this._doc = this._root.ownerDocument;
+
+ this._store.on("set", (event,path,value) => this._storeChanged(path,value));
+}
+
+Template.prototype = {
+ start: function() {
+ this._processTree(this._root);
+ },
+
+ _resolvePath: function(path, defaultValue=null) {
+
+ // From the store, get the value of an object located
+ // at @path.
+ //
+ // For example, if the store is designed as:
+ //
+ // {
+ // foo: {
+ // bar: [
+ // {},
+ // {},
+ // {a: 2}
+ // }
+ // }
+ //
+ // _resolvePath("foo.bar.2.a") will return "2".
+ //
+ // Array indexes are not surrounded by brackets.
+
+ let chunks = path.split(".");
+ let obj = this._store.object;
+ for (let word of chunks) {
+ if ((typeof obj) == "object" &&
+ (word in obj)) {
+ obj = obj[word];
+ } else {
+ return defaultValue;
+ }
+ }
+ return obj;
+ },
+
+ _storeChanged: function(path, value) {
+
+ // The store has changed (a "set" event has been emitted).
+ // We need to invalidate and rebuild the affected elements.
+
+ let strpath = path.join(".");
+ this._invalidate(strpath);
+
+ for (let [registeredPath, set] of this._nodeListeners) {
+ if (strpath != registeredPath &&
+ registeredPath.indexOf(strpath) > -1) {
+ this._invalidate(registeredPath);
+ }
+ }
+ },
+
+ _invalidate: function(path) {
+ // Loops:
+ let set = this._loopListeners.get(path);
+ if (set) {
+ for (let elt of set) {
+ this._processLoop(elt);
+ }
+ }
+
+ // Nodes:
+ set = this._nodeListeners.get(path);
+ if (set) {
+ for (let elt of set) {
+ this._processNode(elt);
+ }
+ }
+ },
+
+ _registerNode: function(path, element) {
+
+ // We map a node to a path.
+ // If the value behind this path is updated,
+ // we get notified from the ObjectEmitter,
+ // and then we know which objects to update.
+
+ if (!this._nodeListeners.has(path)) {
+ this._nodeListeners.set(path, new Set());
+ }
+ let set = this._nodeListeners.get(path);
+ set.add(element);
+ },
+
+ _unregisterNodes: function(nodes) {
+ for (let [registeredPath, set] of this._nodeListeners) {
+ for (let e of nodes) {
+ set.delete(e);
+ }
+ if (set.size == 0) {
+ this._nodeListeners.delete(registeredPath);
+ }
+ }
+ },
+
+ _registerLoop: function(path, element) {
+ if (!this._loopListeners.has(path)) {
+ this._loopListeners.set(path, new Set());
+ }
+ let set = this._loopListeners.get(path);
+ set.add(element);
+ },
+
+ _processNode: function(element, rootPath="") {
+ // The actual magic.
+ // The element has a template attribute.
+ // The value is supposed to be a JSON string.
+ // rootPath is the prefex to the path used by
+ // these elements (if children of template-loop);
+
+ let e = element;
+ let str = e.getAttribute("template");
+
+ if (rootPath) {
+ // We will prefix paths with this rootPath.
+ // It needs to end with a dot.
+ rootPath = rootPath + ".";
+ }
+
+ try {
+ let json = JSON.parse(str);
+ // Sanity check
+ if (!("type" in json)) {
+ throw new Error("missing property");
+ }
+ if (json.rootPath) {
+ // If node has been generated through a loop, we stored
+ // previously its rootPath.
+ rootPath = json.rootPath;
+ }
+
+ // paths is an array that will store all the paths we needed
+ // to expand the node. We will then, via _registerNode, link
+ // this element to these paths.
+
+ let paths = [];
+
+ switch (json.type) {
+ case "attribute": {
+ if (!("name" in json) ||
+ !("path" in json)) {
+ throw new Error("missing property");
+ }
+ e.setAttribute(json.name, this._resolvePath(rootPath + json.path, NOT_FOUND_STRING));
+ paths.push(rootPath + json.path);
+ break;
+ }
+ case "textContent": {
+ if (!("path" in json)) {
+ throw new Error("missing property");
+ }
+ e.textContent = this._resolvePath(rootPath + json.path, NOT_FOUND_STRING);
+ paths.push(rootPath + json.path);
+ break;
+ }
+ case "localizedContent": {
+ if (!("property" in json) ||
+ !("paths" in json)) {
+ throw new Error("missing property");
+ }
+ let params = json.paths.map((p) => {
+ paths.push(rootPath + p);
+ let str = this._resolvePath(rootPath + p, NOT_FOUND_STRING);
+ return str;
+ });
+ e.textContent = this._l10n(json.property, params);
+ break;
+ }
+ }
+ if (rootPath) {
+ // We save the rootPath if any.
+ json.rootPath = rootPath;
+ e.setAttribute("template", JSON.stringify(json));
+ }
+ if (paths.length > 0) {
+ for (let path of paths) {
+ this._registerNode(path, e);
+ }
+ }
+ } catch(exception) {
+ console.error("Invalid template: " + e.outerHTML + " (" + exception + ")");
+ }
+ },
+
+ _processLoop: function(element, rootPath="") {
+ // The element has a template-loop attribute.
+ // The related path must be an array. We go
+ // through the array, and build one child per
+ // item. The template for this child is pointed
+ // by the childSelector property.
+ try {
+ let e = element;
+ let template, count;
+ let str = e.getAttribute("template-loop");
+ let json = JSON.parse(str);
+ if (!("arrayPath" in json) ||
+ !("childSelector" in json)) {
+ throw new Error("missing property");
+ }
+ if (rootPath) {
+ json.arrayPath = rootPath + "." + json.arrayPath;
+ }
+ let templateParent = this._doc.querySelector(json.childSelector);
+ if (!templateParent) {
+ throw new Error("can't find child");
+ }
+ template = this._doc.createElement("div");
+ template.innerHTML = templateParent.innerHTML;
+ template = template.firstElementChild;
+ let array = this._resolvePath(json.arrayPath, []);
+ if (!Array.isArray(array)) {
+ console.error("referenced array is not an array");
+ }
+ count = array.length;
+
+ let fragment = this._doc.createDocumentFragment();
+ for (let i = 0; i < count; i++) {
+ let node = template.cloneNode(true);
+ this._processTree(node, json.arrayPath + "." + i);
+ fragment.appendChild(node);
+ }
+ this._registerLoop(json.arrayPath, e);
+ this._registerLoop(json.arrayPath + ".length", e);
+ this._unregisterNodes(e.querySelectorAll("[template]"));
+ e.innerHTML = "";
+ e.appendChild(fragment);
+ } catch(exception) {
+ console.error("Invalid template: " + e.outerHTML + " (" + exception + ")");
+ }
+ },
+
+ _processTree: function(parent, rootPath="") {
+ let loops = parent.querySelectorAll(":not(template) [template-loop]");
+ let nodes = parent.querySelectorAll(":not(template) [template]");
+ for (let e of loops) {
+ this._processLoop(e, rootPath);
+ }
+ for (let e of nodes) {
+ this._processNode(e, rootPath);
+ }
+ if (parent.hasAttribute("template")) {
+ this._processNode(parent, rootPath);
+ }
+ },
+}
diff --git a/browser/devtools/app-manager/moz.build b/browser/devtools/app-manager/moz.build
new file mode 100644
index 00000000000..86ec4674859
--- /dev/null
+++ b/browser/devtools/app-manager/moz.build
@@ -0,0 +1,7 @@
+# -*- 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/.
+
+TEST_DIRS += ['test']
diff --git a/browser/devtools/app-manager/test/Makefile.in b/browser/devtools/app-manager/test/Makefile.in
new file mode 100644
index 00000000000..6fdcef9f898
--- /dev/null
+++ b/browser/devtools/app-manager/test/Makefile.in
@@ -0,0 +1,17 @@
+# 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/.
+
+DEPTH = @DEPTH@
+topsrcdir = @top_srcdir@
+srcdir = @srcdir@
+VPATH = @srcdir@
+relativesrcdir = @relativesrcdir@
+
+include $(DEPTH)/config/autoconf.mk
+
+MOCHITEST_CHROME_FILES = \
+ test_template.html \
+ $(NULL)
+
+include $(topsrcdir)/config/rules.mk
diff --git a/browser/devtools/app-manager/test/moz.build b/browser/devtools/app-manager/test/moz.build
new file mode 100644
index 00000000000..895d11993cf
--- /dev/null
+++ b/browser/devtools/app-manager/test/moz.build
@@ -0,0 +1,6 @@
+# -*- 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/.
+
diff --git a/browser/devtools/app-manager/test/test_template.html b/browser/devtools/app-manager/test/test_template.html
new file mode 100644
index 00000000000..8f5aa351608
--- /dev/null
+++ b/browser/devtools/app-manager/test/test_template.html
@@ -0,0 +1,239 @@
+
+
+
+
+
+
+
+
+
+
+
+
+