gecko/browser/devtools/projecteditor/lib/tree.js

558 lines
15 KiB
JavaScript

/* -*- 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;