summaryrefslogtreecommitdiffstats
path: root/devtools/client/projecteditor/lib/tree.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/projecteditor/lib/tree.js')
-rw-r--r--devtools/client/projecteditor/lib/tree.js593
1 files changed, 593 insertions, 0 deletions
diff --git a/devtools/client/projecteditor/lib/tree.js b/devtools/client/projecteditor/lib/tree.js
new file mode 100644
index 000000000..50597804d
--- /dev/null
+++ b/devtools/client/projecteditor/lib/tree.js
@@ -0,0 +1,593 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { Cu } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const { emit } = require("sdk/event/core");
+const { EventTarget } = require("sdk/event/target");
+const { merge } = require("sdk/util/object");
+const promise = require("promise");
+const { InplaceEditor } = require("devtools/client/shared/inplace-editor");
+const { on, forget } = require("devtools/client/projecteditor/lib/helpers/event");
+const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {});
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * ResourceContainer is used as the view of a single Resource in
+ * the tree. It is not exported.
+ */
+var ResourceContainer = Class({
+ /**
+ * @param ProjectTreeView tree
+ * @param Resource resource
+ */
+ initialize: function (tree, resource) {
+ this.tree = tree;
+ this.resource = resource;
+ this.elt = null;
+ this.expander = null;
+ this.children = null;
+
+ let doc = tree.doc;
+
+ this.elt = doc.createElementNS(HTML_NS, "li");
+ this.elt.classList.add("child");
+
+ this.line = doc.createElementNS(HTML_NS, "div");
+ this.line.classList.add("child");
+ this.line.classList.add("entry");
+ this.line.setAttribute("theme", "dark");
+ this.line.setAttribute("tabindex", "0");
+
+ this.elt.appendChild(this.line);
+
+ this.highlighter = doc.createElementNS(HTML_NS, "span");
+ this.highlighter.classList.add("highlighter");
+ this.line.appendChild(this.highlighter);
+
+ this.expander = doc.createElementNS(HTML_NS, "span");
+ this.expander.className = "arrow expander";
+ this.expander.setAttribute("open", "");
+ this.line.appendChild(this.expander);
+
+ this.label = doc.createElementNS(HTML_NS, "span");
+ this.label.className = "file-label";
+ this.line.appendChild(this.label);
+
+ this.line.addEventListener("contextmenu", (ev) => {
+ this.select();
+ this.openContextMenu(ev);
+ }, false);
+
+ this.children = doc.createElementNS(HTML_NS, "ul");
+ this.children.classList.add("children");
+
+ this.elt.appendChild(this.children);
+
+ this.line.addEventListener("click", (evt) => {
+ this.select();
+ this.toggleExpansion();
+ evt.stopPropagation();
+ }, false);
+ this.expander.addEventListener("click", (evt) => {
+ this.toggleExpansion();
+ this.select();
+ evt.stopPropagation();
+ }, true);
+
+ if (!this.resource.isRoot) {
+ this.expanded = false;
+ }
+ this.update();
+ },
+
+ toggleExpansion: function () {
+ if (!this.resource.isRoot) {
+ this.expanded = !this.expanded;
+ } else {
+ this.expanded = true;
+ }
+ },
+
+ destroy: function () {
+ this.elt.remove();
+ this.expander.remove();
+ this.highlighter.remove();
+ this.children.remove();
+ this.label.remove();
+ this.elt = this.expander = this.highlighter = this.children = this.label = null;
+ },
+
+ /**
+ * Open the context menu when right clicking on the view.
+ * XXX: We could pass this to plugins to allow themselves
+ * to be register/remove items from the context menu if needed.
+ *
+ * @param Event e
+ */
+ openContextMenu: function (ev) {
+ ev.preventDefault();
+ let popup = this.tree.options.contextMenuPopup;
+ popup.openPopupAtScreen(ev.screenX, ev.screenY, true);
+ },
+
+ /**
+ * Update the view based on the current state of the Resource.
+ */
+ update: function () {
+ let visible = this.tree.options.resourceVisible ?
+ this.tree.options.resourceVisible(this.resource) :
+ true;
+
+ this.elt.hidden = !visible;
+
+ this.tree.options.resourceFormatter(this.resource, this.label);
+
+ this.expander.style.visibility = this.resource.hasChildren ? "visible" : "hidden";
+
+ },
+
+ /**
+ * Select this view in the ProjectTreeView.
+ */
+ select: function () {
+ this.tree.selectContainer(this);
+ },
+
+ /**
+ * @returns Boolean
+ * Is this view currently selected
+ */
+ get selected() {
+ return this.line.classList.contains("selected");
+ },
+
+ /**
+ * Set the selected state in the UI.
+ */
+ set selected(v) {
+ if (v) {
+ this.line.classList.add("selected");
+ } else {
+ this.line.classList.remove("selected");
+ }
+ },
+
+ /**
+ * @returns Boolean
+ * Are any children visible.
+ */
+ get expanded() {
+ return !this.elt.classList.contains("tree-collapsed");
+ },
+
+ /**
+ * Set the visiblity state of children.
+ */
+ set expanded(v) {
+ if (v) {
+ this.elt.classList.remove("tree-collapsed");
+ this.expander.setAttribute("open", "");
+ } else {
+ this.expander.removeAttribute("open");
+ this.elt.classList.add("tree-collapsed");
+ }
+ }
+});
+
+/**
+ * TreeView is a view managing a list of children.
+ * It is not to be instantiated directly - only extended.
+ * Use ProjectTreeView instead.
+ */
+var TreeView = Class({
+ extends: EventTarget,
+
+ /**
+ * @param Document document
+ * @param Object options
+ * - contextMenuPopup: a <menupopup> element
+ * - resourceFormatter: a function(Resource, DOMNode)
+ * that renders the resource into the view
+ * - resourceVisible: a function(Resource) -> Boolean
+ * that determines if the resource should show up.
+ */
+ initialize: function (doc, options) {
+ this.doc = doc;
+ this.options = merge({
+ resourceFormatter: function (resource, elt) {
+ elt.textContent = resource.toString();
+ }
+ }, options);
+ this.models = new Set();
+ this.roots = new Set();
+ this._containers = new Map();
+ this.elt = this.doc.createElementNS(HTML_NS, "div");
+ this.elt.tree = this;
+ this.elt.className = "sources-tree";
+ this.elt.setAttribute("with-arrows", "true");
+ this.elt.setAttribute("theme", "dark");
+ this.elt.setAttribute("flex", "1");
+
+ this.children = this.doc.createElementNS(HTML_NS, "ul");
+ this.elt.appendChild(this.children);
+
+ this.resourceChildrenChanged = this.resourceChildrenChanged.bind(this);
+ this.removeResource = this.removeResource.bind(this);
+ this.updateResource = this.updateResource.bind(this);
+ },
+
+ destroy: function () {
+ this._destroyed = true;
+ this.elt.remove();
+ },
+
+ /**
+ * Helper function to create DOM elements for promptNew and promptEdit
+ */
+ createInputContainer: function () {
+ let inputholder = this.doc.createElementNS(HTML_NS, "div");
+ inputholder.className = "child entry";
+
+ let expander = this.doc.createElementNS(HTML_NS, "span");
+ expander.className = "arrow expander";
+ expander.setAttribute("invisible", "");
+ inputholder.appendChild(expander);
+
+ let placeholder = this.doc.createElementNS(HTML_NS, "div");
+ placeholder.className = "child";
+ inputholder.appendChild(placeholder);
+
+ return {inputholder, placeholder};
+ },
+
+ /**
+ * Prompt the user to create a new file in the tree.
+ *
+ * @param string initial
+ * The suggested starting file name
+ * @param Resource parent
+ * @param Resource sibling
+ * Which resource to put this next to. If not set,
+ * it will be put in front of all other children.
+ *
+ * @returns Promise
+ * Resolves once the prompt has been successful,
+ * Rejected if it is cancelled
+ */
+ promptNew: function (initial, parent, sibling = null) {
+ let deferred = promise.defer();
+
+ let parentContainer = this._containers.get(parent);
+ let item = this.doc.createElement("li");
+ item.className = "child";
+
+ let {inputholder, placeholder} = this.createInputContainer();
+ item.appendChild(inputholder);
+
+ let children = parentContainer.children;
+ sibling = sibling ? this._containers.get(sibling).elt : null;
+ parentContainer.children.insertBefore(item, sibling ? sibling.nextSibling : children.firstChild);
+
+ new InplaceEditor({
+ element: placeholder,
+ initial: initial,
+ preserveTextStyles: true,
+ start: editor => {
+ editor.input.select();
+ },
+ done: function (val, commit) {
+ if (commit) {
+ deferred.resolve(val);
+ } else {
+ deferred.reject(val);
+ }
+ parentContainer.line.focus();
+ },
+ destroy: () => {
+ item.parentNode.removeChild(item);
+ },
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Prompt the user to rename file in the tree.
+ *
+ * @param string initial
+ * The suggested starting file name
+ * @param resource
+ *
+ * @returns Promise
+ * Resolves once the prompt has been successful,
+ * Rejected if it is cancelled
+ */
+ promptEdit: function (initial, resource) {
+ let deferred = promise.defer();
+ let item = this._containers.get(resource).elt;
+ let originalText = item.childNodes[0];
+
+ let {inputholder, placeholder} = this.createInputContainer();
+ item.insertBefore(inputholder, originalText);
+
+ item.removeChild(originalText);
+
+ new InplaceEditor({
+ element: placeholder,
+ initial: initial,
+ preserveTextStyles: true,
+ start: editor => {
+ editor.input.select();
+ },
+ done: function (val, commit) {
+ if (val === initial) {
+ item.insertBefore(originalText, inputholder);
+ }
+
+ item.removeChild(inputholder);
+
+ if (commit) {
+ deferred.resolve(val);
+ } else {
+ deferred.reject(val);
+ }
+ },
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Add a new Store into the TreeView
+ *
+ * @param Store model
+ */
+ addModel: function (model) {
+ if (this.models.has(model)) {
+ // Requesting to add a model that already exists
+ return;
+ }
+ this.models.add(model);
+ let placeholder = this.doc.createElementNS(HTML_NS, "li");
+ placeholder.style.display = "none";
+ this.children.appendChild(placeholder);
+ this.roots.add(model.root);
+ model.root.refresh().then(root => {
+ if (this._destroyed || !this.models.has(model)) {
+ // model may have been removed during the initial refresh.
+ // In this case, do not import the resource or add to DOM, just leave it be.
+ return;
+ }
+ let container = this.importResource(root);
+ container.line.classList.add("entry-group-title");
+ container.line.setAttribute("theme", "dark");
+ this.selectContainer(container);
+
+ this.children.insertBefore(container.elt, placeholder);
+ this.children.removeChild(placeholder);
+ });
+ },
+
+ /**
+ * Remove a Store from the TreeView
+ *
+ * @param Store model
+ */
+ removeModel: function (model) {
+ this.models.delete(model);
+ this.removeResource(model.root);
+ },
+
+
+ /**
+ * Get the ResourceContainer. Used for testing the view.
+ *
+ * @param Resource resource
+ * @returns ResourceContainer
+ */
+ getViewContainer: function (resource) {
+ return this._containers.get(resource);
+ },
+
+ /**
+ * Select a ResourceContainer in the tree.
+ *
+ * @param ResourceContainer container
+ */
+ selectContainer: function (container) {
+ if (this.selectedContainer === container) {
+ return;
+ }
+ if (this.selectedContainer) {
+ this.selectedContainer.selected = false;
+ }
+ this.selectedContainer = container;
+ container.selected = true;
+ emit(this, "selection", container.resource);
+ },
+
+ /**
+ * Select a Resource in the tree.
+ *
+ * @param Resource resource
+ */
+ selectResource: function (resource) {
+ this.selectContainer(this._containers.get(resource));
+ },
+
+ /**
+ * Get the currently selected Resource
+ *
+ * @param Resource resource
+ */
+ getSelectedResource: function () {
+ return this.selectedContainer.resource;
+ },
+
+ /**
+ * Insert a Resource into the view.
+ * Makes a new ResourceContainer if needed
+ *
+ * @param Resource resource
+ */
+ importResource: function (resource) {
+ if (!resource) {
+ return null;
+ }
+
+ if (this._containers.has(resource)) {
+ return this._containers.get(resource);
+ }
+ var container = ResourceContainer(this, resource);
+ this._containers.set(resource, container);
+ this._updateChildren(container);
+
+ on(this, resource, "children-changed", this.resourceChildrenChanged);
+ on(this, resource, "label-change", this.updateResource);
+ on(this, resource, "deleted", this.removeResource);
+
+ return container;
+ },
+
+ /**
+ * Remove a Resource (including children) from the view.
+ *
+ * @param Resource resource
+ */
+ removeResource: function (resource) {
+ let toRemove = resource.allDescendants();
+ toRemove.add(resource);
+ for (let remove of toRemove) {
+ this._removeResource(remove);
+ }
+ },
+
+ /**
+ * Remove an individual Resource (but not children) from the view.
+ *
+ * @param Resource resource
+ */
+ _removeResource: function (resource) {
+ forget(this, resource);
+ if (this._containers.get(resource)) {
+ this._containers.get(resource).destroy();
+ this._containers.delete(resource);
+ }
+ emit(this, "resource-removed", resource);
+ },
+
+ /**
+ * Listener for when a resource has new children.
+ * This can happen as files are being loaded in from FileSystem, for example.
+ *
+ * @param Resource resource
+ */
+ resourceChildrenChanged: function (resource) {
+ this.updateResource(resource);
+ this._updateChildren(this._containers.get(resource));
+ },
+
+ /**
+ * Listener for when a label in the view has been updated.
+ * For example, the 'dirty' plugin marks changed files with an '*'
+ * next to the filename, and notifies with this event.
+ *
+ * @param Resource resource
+ */
+ updateResource: function (resource) {
+ let container = this._containers.get(resource);
+ container.update();
+ },
+
+ /**
+ * Build necessary ResourceContainers for a Resource and its
+ * children, then append them into the view.
+ *
+ * @param ResourceContainer container
+ */
+ _updateChildren: function (container) {
+ let resource = container.resource;
+ let fragment = this.doc.createDocumentFragment();
+ if (resource.children) {
+ for (let child of resource.childrenSorted) {
+ let childContainer = this.importResource(child);
+ fragment.appendChild(childContainer.elt);
+ }
+ }
+
+ while (container.children.firstChild) {
+ container.children.firstChild.remove();
+ }
+
+ container.children.appendChild(fragment);
+ },
+});
+
+/**
+ * ProjectTreeView is the implementation of TreeView
+ * that is exported. This is the class that is to be used
+ * directly.
+ */
+var ProjectTreeView = Class({
+ extends: TreeView,
+
+ /**
+ * See TreeView.initialize
+ *
+ * @param Document document
+ * @param Object options
+ */
+ initialize: function (document, options) {
+ TreeView.prototype.initialize.apply(this, arguments);
+ },
+
+ destroy: function () {
+ this.forgetProject();
+ TreeView.prototype.destroy.apply(this, arguments);
+ },
+
+ /**
+ * Remove current project and empty the tree
+ */
+ forgetProject: function () {
+ if (this.project) {
+ forget(this, this.project);
+ for (let store of this.project.allStores()) {
+ this.removeModel(store);
+ }
+ }
+ },
+
+ /**
+ * Show a project in the tree
+ *
+ * @param Project project
+ * The project to render into a tree
+ */
+ setProject: function (project) {
+ this.forgetProject();
+ this.project = project;
+ if (this.project) {
+ on(this, project, "store-added", this.addModel.bind(this));
+ on(this, project, "store-removed", this.removeModel.bind(this));
+ on(this, project, "project-saved", this.refresh.bind(this));
+ this.refresh();
+ }
+ },
+
+ /**
+ * Refresh the tree with all of the current project stores
+ */
+ refresh: function () {
+ for (let store of this.project.allStores()) {
+ this.addModel(store);
+ }
+ }
+});
+
+exports.ProjectTreeView = ProjectTreeView;