Compare commits

...

13 commits

Author SHA1 Message Date
Thomas Wilburn
20fe5b64d8 Blacklist implemented 2015-10-18 17:54:58 -07:00
Thomas Wilburn
65e3a26cfa Merge branch 'master' into project-rewrite 2015-10-18 17:05:50 -07:00
Thomas Wilburn
0ad9330ddc Merge branch 'master' into project-rewrite 2015-04-11 08:48:47 -07:00
Thomas Wilburn
7e2c77d572 Track new/deleted files in project view.
Refresh now works, but nodes will be re-used instead of generating a completely new tree.
2015-03-24 17:45:56 -07:00
Thomas Wilburn
90d07c4ffa Safe readdir() use for pathMaps 2015-03-24 17:26:11 -07:00
Thomas Wilburn
a28955c011 Walk should work correctly now? 2015-03-24 14:33:52 -07:00
Thomas Wilburn
56701f58fd Faster rendering 2015-03-24 14:28:40 -07:00
Thomas Wilburn
36d875c251 Hook up most commands. Fixes double-open from project tree. 2015-03-22 16:54:47 -07:00
Thomas Wilburn
1fe74fa2fc Opening files now works, but is buggy (opens files twice) 2015-03-22 15:45:58 -07:00
Thomas Wilburn
a7b6e1f455 Add notes on pending commands 2015-03-22 13:16:27 -07:00
Thomas Wilburn
e3005d25af Remove old project manager, move functionality into project/file.js 2015-03-22 13:11:43 -07:00
Thomas Wilburn
f4b54839e3 Expand/contract works, nothing else does. 2015-03-20 18:44:30 -07:00
Thomas Wilburn
09ae51eab9 Start with on-demand rendering for projects
New-style nodes are responsible for their own rendering
2015-03-20 13:25:22 -07:00
11 changed files with 456 additions and 531 deletions

View file

@ -100,6 +100,7 @@
"dialogUnsaved": { "message": "$1 has unsaved work." },
"projectNoCurrentProject": { "message": "No project currently open." },
"projectRemoveDirectory": { "message": "Remove from project" },
"paletteExecuting": { "message": "Executing $1..." },

View file

@ -8,7 +8,8 @@ require([
"sessions",
"util/manos",
"util/i18n",
"ui/projectManager",
"project/tree",
"project/file",
"ui/keys",
"fileManager",
"ui/menus",

120
js/project/file.js Normal file
View file

@ -0,0 +1,120 @@
define([
"project/tree",
"command",
"storage/file",
"storage/settingsProvider",
"sessions",
"ui/dialog",
"util/manos"
], function(projectTree, command, File, Settings, session, dialog, M) {
var projectConfig;
var projectFile;
var generateProject = function() {
var project = projectConfig || {};
//everything but "folders" is left as-is
//run through all directories, retain them, and add to the structure
project.folders = projectTree.getDirectories().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 (projectFile) {
projectFile.write(json);
} else {
var file = new File();
var watch = watchProjectFile;
file.open("save", function() {
file.write(json);
var id = file.retain();
chrome.storage.local.set({retainedProject: id});
file.onWrite = watch;
projectFile = file;
});
}
return json;
};
var openProjectFile = function() {
var file = new File();
file.open(function() {
file.read(function(err, data) {
loadProject(data);
var retained = file.retain();
chrome.storage.local.set({retainedProject: retained});
projectFile = file;
file.onWrite = watchProjectFile;
});
});
};
var watchProjectFile = function() {
projectFile.read(function(err, data) {
loadProject(data);
});
};
var loadProject = function(project) {
//project is the JSON from a project file
if (typeof project == "string") {
project = JSON.parse(project);
}
projectConfig = project;
//assign settings
if (project.settings) {
Settings.setProject(project.settings);
}
this.loading = true;
this.element.addClass("loading");
//restore directory entries that can be restored
this.directories = [];
blacklist = blacklistRegExp();
//TODO: untangle this from tree view
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);
//if this is the first, go ahead and start the slideout
if (!self.directories.length) {
self.element.addClass("show");
}
self.directories.push(node);
node.walk(blacklist, c);
});
},
function() {
self.loading = false;
self.render();
}
);
};
var editProjectFile = function() {
if (!this.projectFile) {
return dialog(i18n.get("projectNoCurrentProject"));
}
projectFile.read(function(err, data) {
sessions.addFile(data, projectFile);
});
};
var clearProject = function(keepRetained) {
projectFile = null;
projectConfig = {};
projectTree.clear();
Settings.clearProject();
if (!keepRetained) chrome.storage.local.remove("retainedProject");
};
command.on("project:generate", generateProject);
command.on("project:open", openProjectFile);
command.on("project:edit", editProjectFile);
command.on("project:clear", clearProject);
});

204
js/project/node.js Normal file
View file

@ -0,0 +1,204 @@
define([
"util/manos",
"util/elementData",
"sessions",
"storage/file",
"util/template!templates/projectDir.html,templates/projectFile.html",
"ui/contextMenus",
"settings!user",
"util/dom2"
], function(M, elementData, sessions, File, inflate, context, Settings) {
//TODO: implement a polling-based watch for directories
//TODO: pull the blacklist and use it during readdir()
var fileListSort = function(a, b) {
if (a.isDir != b.isDir) {
return ~~b.isDir - ~~a.isDir;
}
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
};
var noop = function() {};
var guid = 0;
var Node = function(entry) {
this.entry = entry;
this.name = entry.name
this.isDirty = true;
this.isDir = entry.isDirectory;
this.children = [];
this.id = guid++;
};
Node.prototype = {
id: null,
entry: null,
isOpen: false,
isDirty: false,
isDir: false,
isRoot: false,
path: null,
name: null,
parent: null,
children: null,
element: null,
toggle: function(done) {
this.isOpen = !this.isOpen;
this.render(done);
},
setElement: function(element) {
this.element = element;
elementData.set(element, this);
},
render: function(done) {
var self = this;
done = done || noop;
if (!this.element) return done();
//render the label
var template;
var menu;
if (this.isDir) {
template = "templates/projectDir.html";
menu = context.makeURL(this.isRoot ? "root/directory" : "directory", this.id);
} else {
template = "templates/projectFile.html";
menu = context.makeURL("file", this.entry.fullPath.replace(/[\/\\]/g, "@"));
}
var a = this.element.find("a.label");
if (!a) {
a = document.createElement("a");
this.element.append(a);
}
a.outerHTML = inflate.getHTML(template, {
label: this.name,
path: this.entry.fullPath,
contextMenu: menu
});
if (!this.isOpen) {
this.element.removeClass("expanded");
return done();
}
//only render children if open
this.element.addClass("expanded");
if (this.isDirty && this.isDir) {
this.readdir(function() {
self.renderChildren(function() {
done();
});
});
} else {
this.renderChildren(done);
}
},
renderChildren: function(done) {
var ul = this.element.find("ul.children");
if (!ul) {
ul = document.createElement("ul");
ul.className = "children";
this.element.append(ul);
}
if (!this.children.length) return done();
this.children.sort(fileListSort);
M.map(this.children, function(item, i, c) {
if (!item.element) {
var li = document.createElement("li");
item.setElement(li);
ul.append(li);
}
item.render(c); //recurses on its own if new
}, done)
},
readdir: function(done) {
if (!this.isDir) return done();
var self = this;
var reader = this.entry.createReader();
var entries = [];
var existing = {};
this.children.forEach(function(child) {
existing[child.name] = child;
});
var collect = function(list) {
if (!list.length) return complete();
entries.push.apply(entries, list);
reader.readEntries(collect);
};
var complete = function() {
var matched = [];
var added = [];
var oldChildren = self.children;
//filter out the blacklist
try {
var filter = Settings.get("user").ignoreFiles;
filter = new RegExp(filter);
entries = entries.filter(function(entry) {
//reject .directories
if (entry.name[0] == "." && entry.isDirectory) return false;
return !filter.test(entry.name);
});
} catch (e) {
console.log("Error applying blacklist", e, filter);
}
self.children = entries.map(function(entry) {
if (existing[entry.name]) {
return existing[entry.name];
}
return new Node(entry);
});
//cull files that disappeared
oldChildren.forEach(function(child) {
if (self.children.indexOf(child) == -1) {
if (child.element) child.element.remove();
}
})
self.isDirty = false;
done();
};
reader.readEntries(collect);
},
walk: function(f, done) {
M.map(this.children, function(node, i, c) {
f(node, function() {
node.walk(f, c);
});
}, done);
},
openFile: function() {
var self = this;
var tabs = sessions.getAllTabs();
var found = false;
chrome.fileSystem.getDisplayPath(this.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(self.entry);
file.read(function(err, data) {
sessions.addFile(data, file);
});
}
);
});
}
};
return Node;
});

121
js/project/tree.js Normal file
View file

@ -0,0 +1,121 @@
define([
"project/node",
"command",
"util/elementData",
"util/i18n",
"ui/contextMenus",
"util/manos",
"util/dom2"
], function(Node, command, elementData, i18n, context, M) {
var directories = [];
var pathMap = {};
var container = document.find(".project");
var tree = container.find(".tree");
var setVisible = function() {
if (directories.length) {
container.addClass("show");
} else {
container.removeClass("show");
}
};
var addDirectory = function() {
chrome.fileSystem.chooseEntry({ type: "openDirectory" }, function(entry) {
if (!entry) return;
var root = new Node(entry);
directories.push(root);
var element = document.createElement("ul");
var rootElement = document.createElement("li");
tree.append(element);
element.append(rootElement);
root.setElement(rootElement);
root.isOpen = true;
root.isRoot = true;
root.render(function() {
//after initial render, do the walk for path lookup
root.walk(function(node, c) {
pathMap[node.entry.fullPath] = node;
//make sure the children are read and populated, then continue
if (node.isDir) {
node.readdir(c);
} else c();
});
});
setVisible();
});
};
var removeDirectory = function(id) {
directories = directories.filter(function(node) {
if (node.id == id) {
node.element.remove();
return false;
}
return true;
});
setVisible();
};
var removeAll = function() {
tree.innerHTML = "";
directories = [];
pathmap = {};
setVisible();
};
//toggle directories, or open files directly
tree.on("click", function(e) {
var li = e.target.findUp("li");
var node = elementData.get(li);
if (!li || !node) return;
e.preventDefault();
if (e.target.hasClass("directory")) {
node.toggle();
} else {
node.openFile();
}
});
command.on("project:refresh-dir", function() {
pathMap = {};
directories.forEach(function(dir) {
M.chain(
dir.readdir.bind(dir),
dir.render.bind(dir),
function() {
dir.walk(function(node, c) {
pathMap[node.entry.fullPath] = node;
if (node.isDir) {
node.readdir(c);
} else c();
});
}
);
});
});
command.on("project:add-dir", addDirectory);
command.on("project:open-file", function(path) {
var node = pathMap[path];
if (node) node.openFile();
});
command.on("project:remove-all", removeAll);
context.register(
i18n.get("projectRemoveDirectory"),
"removeDirectory",
"root/directory/:id",
function(args) {
removeDirectory(args.id);
}
);
return {
getPaths: function() { return Object.keys(pathMap) },
getDirectories: function() { return directories },
insertDirectory: addDirectory,
clear: removeAll
}
});

View file

@ -1,7 +1,7 @@
define([
"command",
"sessions/addRemove",
"ui/projectManager",
"project/tree",
"storage/file"
], function(command, addRemove, projectManager, File) {

View file

@ -4,7 +4,7 @@ define([
"editor",
"settings!menus,user",
"ui/statusbar",
"ui/projectManager",
"project/tree",
"util/template!templates/paletteItem.html",
"util/i18n",
"util/dom2"

View file

@ -1,521 +0,0 @@
define([
"settings!user",
"command",
"sessions",
"storage/file",
"util/manos",
"ui/dialog",
"ui/contextMenus",
"editor",
"util/template!templates/projectDir.html,templates/projectFile.html",
"util/i18n",
"util/dom2"
], function(Settings, command, sessions, File, M, dialog, context, editor, inflate, i18n) {
/*
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(blacklist, 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
if (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(blacklist, 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) {
var retained = data.retainedProject;
if (typeof retained == "string") {
retained = {
id: retained
};
}
self.loading = true;
self.render();
var file = new File();
var onFail = function() {
self.loading = false;
self.render();
chrome.storage.local.remove("retainedProject");
}
file.onWrite = self.watchProjectFile.bind(self);
file.restore(retained.id, function(err, f) {
if (err) {
return onFail();
}
file.read(function(err, data) {
if (err) {
return onFail();
}
self.projectFile = file;
self.loadProject(JSON.parse(data));
});
});
}
});
};
var blacklistRegExp = function() {
var blacklist = Settings.get("user").ignoreFiles;
if (blacklist) {
return new RegExp(blacklist);
}
return null;
}
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(blacklistRegExp(), function() {
self.render()
});
});
},
removeDirectory: function(args) {
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;
this.element.addClass("loading");
var check = function() {
counter++;
if (counter == self.directories.length) {
//render() should get rid of the class, but let's be sure
self.element.removeClass("loading");
self.render();
}
};
blacklist = blacklistRegExp();
this.directories.forEach(function(d) {
d.walk(blacklist, 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 retained = file.retain();
chrome.storage.local.set({retainedProject: retained});
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;
this.element.addClass("loading");
//restore directory entries that can be restored
this.directories = [];
blacklist = blacklistRegExp();
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);
//if this is the first, go ahead and start the slideout
if (!self.directories.length) {
self.element.addClass("show");
}
self.directories.push(node);
node.walk(blacklist, c);
});
},
function() {
self.loading = false;
self.render();
}
);
},
editProjectFile: function() {
if (!this.projectFile) {
return dialog(i18n.get("projectNoCurrentProject"));
}
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;
});

View file

@ -1,7 +1,7 @@
{
"name": "Caret",
"description": "Professional text editing for Chrome and Chrome OS",
"version": "1.5.13",
"version": "1.5.16",
"manifest_version": 2,
"default_locale": "en",
"icons": {

View file

@ -1,9 +1,9 @@
<a
tabindex="-1"
class="directory"
href="{{contextMenu}}"
class="directory label"
command="null"
data-full-path="{{path}}"
href="{{contextMenu}}"
tabindex="-1"
>
{{label}}
</a>

View file

@ -1,7 +1,6 @@
<a
class="file label"
href="{{contextMenu}}"
command="project:open-file"
argument="{{path}}"
tabindex="-1"
>
{{label}}