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) { if (err) { self.loading = false; self.render(); return chrome.storage.local.remove("retainedProject"); } file.read(function(err, data) { if (err) return; 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) { if (err) return; self.loadProject(data); var id = file.retain(); chrome.storage.local.set({retainedProject: id}); self.projectFile = file; }); file.onWrite = self.watchProjectFile; }); }, watchProjectFile: function() { var self = this; this.projectFile.read(function(err, data) { if (err) return; 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) { if (err) return; 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)); return pm; });