Caret/js/ui/projectManager.js
Thomas Wilburn 12870d3b1d Revert Promises in file API to callbacks.
Promises do not seem reliable as they are in Chrome 34. Rather than
continue to implement hacks that can work around these problems, let's
just go back to callbacks. Maybe we don't need to be on the cutting edge
_all_ the time.
2014-05-04 02:09:19 -07:00

493 lines
No EOL
14 KiB
JavaScript

define([
"settings!user",
"command",
"sessions",
"storage/file",
"util/manos",
"ui/dialog",
"ui/contextMenus",
"editor",
"util/template!templates/projectDir.html,templates/projectFile.html",
"util/dom2"
], function(Settings, command, sessions, File, M, dialog, context, editor, inflate) {
/*
It's tempting to store projects in local storage, similar to the way that we
retain files for tabs, but this would be a mistake. Reading from storage is a
pain, because it wants to store a single level deep, and we'll want to alter
parts of the setup individually.
Instead, we'll retain a single file handle to the project file, which (as
JSON) will store the IDs of individual directories, the project-specific
settings, and (hopefully, one day) build systems. This also gets us around
the issues of restored directory order and constantly updating the retained
file list--we'll just update it when the project file is saved.
*/
var guidCounter = 0;
//pseudo-worker to let the UI thread breathe
var queue = [];
var working = false;
var tick = function(fn) {
if (fn) queue.push(fn);
if (fn && working) return;
working = true;
//start work on the next frame
var process = function() {
var then = Date.now();
while (queue.length) {
var now = Date.now();
if (now - then > 10) {
return setTimeout(process);
}
var next = queue.shift();
next();
}
working = false;
};
setTimeout(process);
};
//FSNodes are used to track filesystem state inside projects
//We don't use the typical File object, because we're not really reading them
//Nodes form a tree starting at the root directory
var FSNode = function(entry) {
this.children = [];
this.id = guidCounter++;
if (entry) this.setEntry(entry);
};
FSNode.prototype = {
isDirectory: false,
entry: null,
tab: null,
id: null,
label: null,
setEntry: function(entry, c) {
this.entry = entry;
this.label = entry.name;
this.isDirectory = entry.isDirectory;
},
//walk will asynchronously collect the file tree
walk: function(done) {
var self = this;
var entries = [];
var reader = this.entry.createReader();
var inc = 1;
var check = function() {
inc--;
if (inc == 0) {
return done(self);
}
};
var collect = function(list) {
if (list.length == 0) return complete();
entries.push.apply(entries, list);
reader.readEntries(collect);
};
var complete = function() {
self.children = [];
entries.forEach(function(entry) {
//skip dot dirs, but not files
if (entry.name[0] == "." && entry.isDirectory) return;
//skip ignored files
var blacklist = Settings.get("user").ignoreFiles;
if (blacklist) {
blacklist = new RegExp(blacklist);
if (blacklist.test(entry.name)) return;
}
var node = new FSNode(entry);
self.children.push(node);
if (node.isDirectory) {
inc++;
//give the UI thread a chance to breathe
tick(function() { node.walk(check); });
}
});
check();
};
reader.readEntries(collect);
}
};
// The Project Manager actually handles rendering and interfacing with the rest
// of the code. Commands are bound to a singleton instance, but it's technically
// not picky about being the only one.
var ProjectManager = function(element) {
this.directories = [];
this.pathMap = {};
this.expanded = {};
this.project = null;
this.projectFile = null;
if (element) {
this.setElement(element);
}
this.loading = false;
var self = this;
chrome.storage.local.get("retainedProject", function(data) {
if (data.retainedProject) {
self.loading = true;
self.render();
var file = new File();
file.onWrite = self.watchProjectFile.bind(self);
file.restore(data.retainedProject, function(err, f) {
file.read(function(err, data) {
if (err) {
self.loading = false;
self.render();
return chrome.storage.local.remove("retainedProject");
}
self.projectFile = file;
self.loadProject(JSON.parse(data));
});
});
}
});
};
ProjectManager.prototype = {
element: null,
addDirectory: function(c) {
var self = this;
chrome.fileSystem.chooseEntry({ type: "openDirectory" }, function(d) {
if (!d) return;
self.insertDirectory(d);
});
},
insertDirectory: function(entry) {
var root;
this.element.addClass("loading");
//ensure we aren't duplicating
this.directories.forEach(function(directoryNode){
if (directoryNode.entry.fullPath === entry.fullPath) {
root = directoryNode;
}
});
//if this is the first, go ahead and start the slideout
if (!this.directories.length) {
this.element.addClass("show");
}
if (!root) {
root = new FSNode(entry);
this.directories.push(root);
}
//if the directory was there, we still want
//to refresh it, in response to the users
//interaction
var self = this;
tick(function() {
root.walk(function() {
self.render()
});
});
},
removeDirectory: function(args) {
this.element.addClass("loading");
this.directories = this.directories.filter(function(node) {
return node.id != args.id;
});
this.render();
},
removeAllDirectories: function() {
this.directories = [];
this.render();
},
refresh: function() {
var counter = 0;
var self = this;
var check = function() {
counter++;
if (counter = self.directories.length) {
self.render();
}
};
this.directories.forEach(function(d) {
d.walk(check);
});
},
render: function() {
if (!this.element) return;
//Ace doesn't know about non-window resize events
//moving the panel will screw up its dimensions
setTimeout(function() {
editor.resize();
}, 500);
var tree = this.element.find(".tree");
this.pathMap = {};
if (this.directories.length == 0 && !this.loading) {
this.element.removeClass("show");
tree.innerHTML = "";
return;
}
var self = this;
this.element.addClass("show");
if (this.loading) {
this.element.addClass("loading");
}
var walker = function(node) {
var li = document.createElement("li");
if (node.isDirectory) {
var isRoot = self.directories.indexOf(node) != -1;
var nodeData = {
label: node.label,
path: node.entry.fullPath,
contextMenu: context.makeURL(isRoot ? "root/directory" : "directory", node.id)
};
var a = inflate.get("templates/projectDir.html", nodeData);
li.append(a);
if (self.expanded[node.entry.fullPath]) {
li.addClass("expanded");
}
var ul = document.createElement("ul");
node.children.sort(function(a, b) {
if (a.isDirectory != b.isDirectory) {
//sneaky casting trick
return ~~b.isDirectory - ~~a.isDirectory;
}
if (a.label < b.label) return -1;
if (a.label > b.label) return 1;
return 0;
});
for (var i = 0; i < node.children.length; i++) {
ul.append(walker(node.children[i]));
}
li.append(ul);
} else {
var nodeData = {
path: node.entry.fullPath,
contextMenu: context.makeURL("file", node.entry.fullPath.replace(/[\/\\]/g, "@")),
label: node.label
};
var a = inflate.get("templates/projectFile.html", nodeData)
li.append(a);
self.pathMap[node.entry.fullPath] = node;
}
return li;
};
//we give the load bar a chance to display before rendering
tick(function() {
var trees = self.directories.map(walker);
var list = document.createElement("ul");
trees.forEach(function(dir) {
dir.classList.add("root");
dir.classList.add("expanded");
list.appendChild(dir);
});
tree.innerHTML = "";
tree.appendChild(list);
if (!self.loading) {
self.element.removeClass("loading");
}
});
},
setElement: function(el) {
this.element = el;
this.bindEvents();
},
bindEvents: function() {
var self = this;
this.element.on("click", function(e) {
e.preventDefault();
var target = e.target;
if (target.hasClass("directory")) {
target.parentElement.toggle("expanded");
var path = target.getAttribute("data-full-path");
self.expanded[path] = !!!self.expanded[path];
}
editor.focus();
});
},
openFile: function(path) {
var self = this;
var found = false;
var node = this.pathMap[path];
if (!node) return;
//walk through existing tabs to see if it's already open
var tabs = sessions.getAllTabs();
chrome.fileSystem.getDisplayPath(node.entry, function(path) {
//look through the tabs for matching display paths
M.map(
tabs,
function(tab, i, c) {
if (!tab.file || tab.file.virtual) {
return c(false);
}
tab.file.getPath(function(err, p) {
if (p == path) {
sessions.setCurrent(tab);
found = true;
}
//we don't actually use the result
c();
});
},
//if no match found, create a tab
function() {
if (found) return;
var file = new File(node.entry);
file.read(function(err, data) {
sessions.addFile(data, file);
});
}
);
});
},
generateProject: function() {
var project = this.project || {};
//everything but "folders" is left as-is
//run through all directories, retain them, and add to the structure
project.folders = this.directories.map(function(node) {
var id = chrome.fileSystem.retainEntry(node.entry);
return {
retained: id,
path: node.entry.fullPath
};
});
var json = JSON.stringify(project, null, 2);
if (this.projectFile) {
this.projectFile.write(json);
} else {
var file = new File();
var watch = this.watchProjectFile.bind(this);
var self = this;
file.open("save", function() {
file.write(json);
var id = file.retain();
chrome.storage.local.set({retainedProject: id});
file.onWrite = watch;
self.projectFile = file;
});
}
return json;
},
openProjectFile: function() {
var file = new File();
var self = this;
file.open(function() {
file.read(function(err, data) {
self.loadProject(data);
var id = file.retain();
chrome.storage.local.set({retainedProject: id});
self.projectFile = file;
file.onWrite = self.watchProjectFile.bind(self);
});
});
},
watchProjectFile: function() {
var self = this;
this.projectFile.read(function(err, data) {
self.loadProject(data);
});
},
loadProject: function(project) {
var self = this;
//project is the JSON from a project file
if (typeof project == "string") {
project = JSON.parse(project);
}
this.project = project;
//assign settings
if (project.settings) {
Settings.setProject(project.settings);
}
this.loading = true;
//restore directory entries that can be restored
this.directories = [];
M.map(
project.folders,
function(folder, index, c) {
chrome.fileSystem.restoreEntry(folder.retained, function(entry) {
//remember, you can only restore project directories you'd previously opened
if (!entry) return c();
var node = new FSNode(entry);
self.directories.push(node);
node.walk(c);
});
},
function() {
self.loading = false;
self.render();
}
);
},
editProjectFile: function() {
if (!this.projectFile) {
return dialog("No project opened.");
}
var self = this;
this.projectFile.read(function(err, data) {
sessions.addFile(data, self.projectFile);
});
},
clearProject: function(keepRetained) {
this.projectFile = null;
this.directories = [];
this.project = {};
Settings.clearProject();
if (!keepRetained) chrome.storage.local.remove("retainedProject");
this.render();
},
getPaths: function() {
return Object.keys(this.pathMap);
}
};
var pm = new ProjectManager(document.find(".project"));
command.on("project:add-dir", pm.addDirectory.bind(pm));
command.on("project:remove-all", pm.removeAllDirectories.bind(pm));
command.on("project:generate", pm.generateProject.bind(pm));
command.on("project:open-file", pm.openFile.bind(pm));
command.on("project:refresh-dir", pm.refresh.bind(pm));
command.on("project:open", pm.openProjectFile.bind(pm));
command.on("project:edit", pm.editProjectFile.bind(pm));
command.on("project:clear", pm.clearProject.bind(pm));
context.register("Remove from Project", "removeDirectory", "root/directory/:id", pm.removeDirectory.bind(pm));
var setAutoHide = function() {
var hide = Settings.get("user").autoHideProject;
if (hide) {
pm.element.classList.add("autohide");
} else {
pm.element.classList.remove("autohide");
}
}
command.on("init:startup", setAutoHide);
command.on("init:restart", setAutoHide);
return pm;
});