todosapp: start of changes to support sharing/sync
- redo css (i.e. make it sane & make it easier to extend UI)
- simplify and improve JS state management (prepare for concurrent
updates from sync)
- various other things
Also includes some tangentially related fixes and tweaks:
- add keys where needed to avoid react warnings
- use less for css
- use autoprefixer and cssnano for vendor prefixes and css compression
- switch to ctrl-L to open/close in-dom console log
- show loading message even while dispatcher is being initialized
Change-Id: I531649b521d41ef83f2a6d4903879e3daad9f531
diff --git a/Makefile b/Makefile
index ea49bb4..5cf4f9f 100644
--- a/Makefile
+++ b/Makefile
@@ -60,6 +60,9 @@
# https://github.com/substack/node-browserify/issues/1063
touch node_modules
+public/bundle.min.css: $(shell find stylesheets) node_modules
+ lessc -sm=on stylesheets/index.less | postcss -u autoprefixer -u cssnano > $@
+
public/bundle.min.js: browser/index.js $(shell find browser) node_modules
ifdef DEBUG
$(call BROWSERIFY,$<,$@)
@@ -68,7 +71,7 @@
endif
.PHONY: build
-build: bin node_modules public/bundle.min.js
+build: bin node_modules public/bundle.min.css public/bundle.min.js
.PHONY: serve
serve: build
@@ -76,7 +79,7 @@
.PHONY: clean
clean:
- rm -rf bin node_modules public/bundle.min.js
+ rm -rf bin node_modules public/bundle.*
.PHONY: lint
lint:
diff --git a/README.md b/README.md
index 00169cd..9b9bf64 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@
By default, the web app will use an in-memory (in-browser-tab) local storage
engine, and will not talk to Syncbase at all. To configure the app to talk to
Syncbase, add `d=syncbase` to the url query params, or simply click the storage
-engine indicator in the upper right corner to toggle it.
+engine indicator in the top right corner to toggle it.
When using Syncbase, by default the app attempts to contact the Syncbase service
using the Vanadium object name `/localhost:8200`. To specify a different name,
@@ -132,28 +132,19 @@
To run a simple benchmark (100 puts, followed by a scan of those rows), specify
query param `bm=1`.
-### Open questions
+### Benchmark performance
-- Why can test browser talk to normal syncbase and not to test syncbase? This is
- the opposite of what I'd expect given the blessings.
- - Glob from test browser to test syncbase (service.listApps) fails with "does
- not have Resolve access".
- - RPCs from test browser to normal syncbase should fail with "untrusted root",
- but instead they succeed.
+All numbers assume dev console is closed, and assume non-test setup as described
+above.
-- Why do test and normal browsers have different performance talking to the
- same (normal) syncbase?
- - Test browser: 100 puts takes 2s, scan takes 3.5s.
- - Normal browser: 100 puts takes 5s, scan takes 9s.
+Currently, parallel 100 puts takes 4s, and scanning 100 rows takes 0.6s.
- With dev console closed, scan takes roughly 0.6s on both (see below), but 100
- puts still takes 2s in test browser vs. 4s in normal browser.
+For the puts, avoiding unnecessarily cautious Signature RPC and avoiding 2x
+blowup from unnecessary Resolve calls will help. Parallelism doesn't help as
+much as one would hope, need to understand why.
-- Why is JS scan so slow? Note, latency appears to be proportional to data size,
- with some small fixed overhead. Also note, vrpc scan takes less than 0.3s.
-
- ANSWER: Turns out if the dev console is closed, scan is much faster (0.6s).
- Issue filed: https://github.com/vanadium/issues/issues/610
+For the scan, 100ms comes from JS encode/decode, and probably much of the rest
+from WSPR. Needs further digging.
[syncbase]: https://docs.google.com/document/d/12wS_IEPf8HTE7598fcmlN-Y692OWMSneoe2tvyBEpi0/edit#
[crx]: https://v.io/tools/vanadium-chrome-extension.html
diff --git a/browser/benchmark.js b/browser/benchmark.js
index 9e264a0..d6ba9a7 100644
--- a/browser/benchmark.js
+++ b/browser/benchmark.js
@@ -8,21 +8,21 @@
var util = require('./util');
-exports.logLatency = logLatency;
+exports.logFn = logFn;
exports.runBenchmark = runBenchmark;
var LOG_EVERYTHING = false;
function logStart(name) {
- util.log(name + ' start');
+ console.log(name + ' start');
return Date.now();
}
function logStop(name, start) {
- util.log(name + ' took ' + (Date.now() - start) + 'ms');
+ console.log(name + ' took ' + (Date.now() - start) + 'ms');
}
-function logLatency(name, cb) {
+function logFn(name, cb) {
var start = logStart(name);
return function() {
logStop(name, start);
@@ -31,24 +31,17 @@
}
// Does n parallel puts with a common prefix, then returns the prefix.
-// TODO(sadovsky): According to Shyam, since these puts are being done in
-// parallel, it may be the case that each one is setting up its own VC (doing
-// auth handshake, fetching discharge, etc.), rather than all puts sharing a
-// single VC. Ali or Suharsh should know the state of VC sharing. Possible
-// workaround would be to do something that forces VC creation before starting
-// the parallel puts. (OTOH, we always run service.listApps before the puts,
-// which should create the VC.)
function doPuts(ctx, tb, n, cb) {
- cb = logLatency('doPuts', cb);
+ cb = logFn('doPuts', cb);
var prefix = util.timestamp() + '.';
async.times(100, function(n, cb) {
// TODO(sadovsky): Remove this once we loosen Syncbase's naming rules.
prefix = prefix.replace(/:/g, '.');
var key = prefix + n;
var value = '';
- if (LOG_EVERYTHING) util.log('put: ' + key);
+ if (LOG_EVERYTHING) console.log('put: ' + key);
tb.put(ctx, key, value, function(err) {
- if (LOG_EVERYTHING) util.log('put done: ' + key);
+ if (LOG_EVERYTHING) console.log('put done: ' + key);
cb(err);
});
}, function(err) {
@@ -58,16 +51,16 @@
// Scans (and logs) all records with the given prefix.
function doScan(ctx, tb, prefix, cb) {
- cb = logLatency('doScan(' + prefix + ')', cb);
+ cb = logFn('doScan(' + prefix + ')', cb);
var bytes = 0, streamErr = null;
tb.scan(ctx, nosql.rowrange.prefix(prefix), function(err) {
err = err || streamErr;
if (err) return cb(err);
- util.log('scanned ' + bytes + ' bytes');
+ console.log('scanned ' + bytes + ' bytes');
cb();
}).on('data', function(row) {
bytes += row.key.length + row.value.length;
- if (LOG_EVERYTHING) util.log('scan: ' + JSON.stringify(row));
+ if (LOG_EVERYTHING) console.log('scan: ' + JSON.stringify(row));
}).on('error', function(err) {
streamErr = streamErr || err.error;
});
@@ -75,7 +68,7 @@
// Assumes table 'tb' exists.
function runBenchmark(ctx, db, cb) {
- cb = logLatency('runBenchmark', cb);
+ cb = logFn('runBenchmark', cb);
var tb = db.table('tb');
doPuts(ctx, tb, 100, function(err, prefix) {
if (err) return cb(err);
diff --git a/browser/defaults.js b/browser/defaults.js
index bae85e3..d41e637 100644
--- a/browser/defaults.js
+++ b/browser/defaults.js
@@ -2,12 +2,13 @@
var async = require('async');
var syncbase = require('syncbase');
+var vanadium = require('vanadium');
+var verror = vanadium.verror;
var bm = require('./benchmark');
var CollectionDispatcher = require('./collection_dispatcher');
var MemCollection = require('./mem_collection');
var SyncbaseDispatcher = require('./syncbase_dispatcher');
-var util = require('./util');
// Copied from meteor/todos/server/bootstrap.js.
var data = [
@@ -48,11 +49,12 @@
];
function initData(disp, cb) {
+ cb = bm.logFn('initData', cb);
var timestamp = Date.now();
async.each(data, function(list, cb) {
disp.addList({name: list.name}, function(err, listId) {
if (err) return cb(err);
- async.eachSeries(list.contents, function(info, cb) {
+ async.each(list.contents, function(info, cb) {
timestamp += 1; // ensure unique timestamp
disp.addTodo(listId, {
text: info[0],
@@ -62,7 +64,13 @@
}, cb);
}, cb);
});
- }, cb);
+ }, function(err) {
+ // NOTE(sadovsky): Based on console logs, it looks like browser async.each
+ // doesn't use process.nextTick for its final callback!
+ process.nextTick(function() {
+ return cb(err);
+ });
+ });
}
// Returns a new Vanadium context object with a timeout.
@@ -70,45 +78,37 @@
return ctx.withTimeout(timeout || 5000);
}
-function appExists(ctx, service, name, cb) {
- service.listApps(ctx, function(err, names) {
- if (err) return cb(err);
- return cb(null, names.indexOf(name) >= 0);
- });
-}
-
exports.initSyncbaseDispatcher = function(rt, name, benchmark, cb) {
- cb = bm.logLatency('initSyncbaseDispatcher', cb);
- var service = syncbase.newService(name);
- // TODO(sadovsky): Instead of appExists, simply check for ErrExist in the
- // app.create response.
+ cb = bm.logFn('initSyncbaseDispatcher', cb);
var ctx = rt.getContext();
- appExists(wt(ctx), service, 'todos', function(err, exists) {
- if (err) return cb(err);
- var app = service.app('todos'), db = app.noSqlDatabase('db');
- var disp = new SyncbaseDispatcher(rt, db);
- if (exists) {
- if (benchmark) {
- return bm.runBenchmark(ctx, db, cb);
+ var service = syncbase.newService(name);
+ var app = service.app('todos'), db = app.noSqlDatabase('db');
+ var disp = new SyncbaseDispatcher(rt, db);
+ // TODO(sadovsky): Check that the VC (and discharge, etc.) for this RPC is
+ // reused for all subsequent RPCs.
+ app.create(wt(ctx), {}, function(err) {
+ if (err) {
+ if (err instanceof verror.ExistError) {
+ if (benchmark) {
+ return bm.runBenchmark(ctx, db, cb);
+ }
+ console.log('app exists; assuming database has been initialized');
+ return cb(null, disp);
}
- util.log('app exists; assuming everything has been initialized');
- return cb(null, disp);
+ return cb(err);
}
- util.log('app does not exist; initializing everything');
- app.create(wt(ctx), {}, function(err) {
+ console.log('app did not exist; initializing database');
+ db.create(wt(ctx), {}, function(err) {
if (err) return cb(err);
- db.create(wt(ctx), {}, function(err) {
+ db.createTable(wt(ctx), 'tb', {}, function(err) {
if (err) return cb(err);
- db.createTable(wt(ctx), 'tb', {}, function(err) {
+ if (benchmark) {
+ return bm.runBenchmark(ctx, db, cb);
+ }
+ console.log('hierarchy created; writing rows');
+ initData(disp, function(err) {
if (err) return cb(err);
- if (benchmark) {
- return bm.runBenchmark(ctx, db, cb);
- }
- util.log('hierarchy created; writing rows');
- initData(disp, function(err) {
- if (err) return cb(err);
- cb(null, disp);
- });
+ cb(null, disp);
});
});
});
@@ -116,7 +116,7 @@
};
exports.initCollectionDispatcher = function(cb) {
- cb = bm.logLatency('initCollectionDispatcher', cb);
+ cb = bm.logFn('initCollectionDispatcher', cb);
var lists = new MemCollection('lists'), todos = new MemCollection('todos');
var disp = new CollectionDispatcher(lists, todos);
initData(disp, function(err) {
diff --git a/browser/index.js b/browser/index.js
index 00e3eff..6b4df96 100644
--- a/browser/index.js
+++ b/browser/index.js
@@ -1,11 +1,12 @@
-// TODO(sadovsky): Maybe update to the new Meteor Todos UI.
// https://github.com/meteor/simple-todos
'use strict';
/* jshint newcap: false */
+/* global Mousetrap */
var _ = require('lodash');
+var async = require('async');
var page = require('page');
var React = require('react');
var url = require('url');
@@ -25,13 +26,44 @@
////////////////////////////////////////
// Global state
-var disp; // type Dispatcher
+// Dispatcher, initialized by initDispatcher.
+var disp;
+
+// Used for query params.
+var u = url.parse(window.location.href, true);
////////////////////////////////////////
// Helpers
function noop() {}
+function initDispatcher(dispType, syncbaseName, benchmark, cb) {
+ var clientCb = cb;
+ cb = function(err, resDisp) {
+ if (err) return clientCb(err);
+ disp = resDisp;
+ clientCb();
+ };
+ if (dispType === 'collection') {
+ console.assert(!benchmark);
+ defaults.initCollectionDispatcher(cb);
+ } else if (dispType === 'syncbase') {
+ var vanadiumConfig = {
+ logLevel: vanadium.vlog.levels.INFO,
+ namespaceRoots: u.query.mounttable ? [u.query.mounttable] : undefined,
+ proxy: u.query.proxy
+ };
+ vanadium.init(vanadiumConfig, function(err, rt) {
+ if (err) return cb(err);
+ defaults.initSyncbaseDispatcher(rt, syncbaseName, benchmark, cb);
+ });
+ } else {
+ process.nextTick(function() {
+ cb(new Error('unknown dispType: ' + dispType));
+ });
+ }
+}
+
function activateInput(input) {
input.focus();
input.select();
@@ -40,27 +72,27 @@
function okCancelEvents(cbs) {
var ok = cbs.ok || noop;
var cancel = cbs.cancel || noop;
- function done(ev) {
- var value = ev.target.value;
+ function done(e) {
+ var value = e.target.value;
if (value) {
- ok(value, ev);
+ ok(value, e);
} else {
- cancel(ev);
+ cancel(e);
}
}
return {
- onKeyDown: function(ev) {
- if (ev.which === 27) { // esc
- cancel(ev);
+ onKeyDown: function(e) {
+ if (e.which === 27) { // esc
+ cancel(e);
}
},
- onKeyUp: function(ev) {
- if (ev.which === 13) { // enter
- done(ev);
+ onKeyUp: function(e) {
+ if (e.which === 13) { // enter
+ done(e);
}
},
- onBlur: function(ev) {
- done(ev);
+ onBlur: function(e) {
+ done(e);
}
};
}
@@ -68,8 +100,8 @@
////////////////////////////////////////
// Components
-var TagFilter = React.createFactory(React.createClass({
- displayName: 'TagFilter',
+var TagsPane = React.createFactory(React.createClass({
+ displayName: 'TagsPane',
render: function() {
var that = this;
var tagFilter = this.props.tagFilter;
@@ -94,22 +126,23 @@
count: totalCount,
selected: tagFilter === null
});
- return h('div#tag-filter.tag-list', [
- h('div.label', 'Show:')
+ return h('div#tags-pane', [
+ h('div.label', {key: 'label'}, 'Show:')
].concat(_.map(tagInfos, function(tagInfo) {
- var count = h('span.count', '(' + tagInfo.count + ')');
+ var count = h('span.count', {key: 'count'}, '(' + tagInfo.count + ')');
return h('div.tag' + (tagInfo.selected ? '.selected' : ''), {
+ key: 'tag:' + (tagInfo.tag || ''),
onMouseDown: function() {
var newTagFilter = tagFilter === tagInfo.tag ? null : tagInfo.tag;
that.props.setTagFilter(newTagFilter);
}
- }, [tagInfo.tag === null ? 'All items' : tagInfo.tag, ' ', count]);
+ }, [(tagInfo.tag || 'All items'), count]);
})));
}
}));
-var Tags = React.createFactory(React.createClass({
- displayName: 'Tags',
+var TodoTags = React.createFactory(React.createClass({
+ displayName: 'TodoTags',
getInitialState: function() {
return {
addingTag: false
@@ -124,25 +157,20 @@
var that = this;
var children = [];
_.each(this.props.tags, function(tag) {
- // Note, we must specify the "key" prop so that React doesn't reuse the
- // opacity=0 node after a tag is removed.
- children.push(h('div.tag.removable_tag', {key: tag}, [
- h('div.name', tag),
+ children.push(h('div.tag.removable', {key: tag}, [
+ h('div.name', {key: 'name'}, tag),
h('div.remove', {
- onClick: function(ev) {
- ev.target.parentNode.style.opacity = 0;
- // Wait for CSS animation to finish.
- window.setTimeout(function() {
- // TODO(sadovsky): If no other todos have the removed tag, maybe
- // set tagFilter to null.
- disp.removeTag(that.props.todoId, tag);
- }, 200);
+ key: 'remove',
+ onClick: function(e) {
+ disp.removeTag(that.props.todoId, tag);
}
})
]));
});
if (this.state.addingTag) {
- children.push(h('div.tag.edittag', h('input#edittag-input', _.assign({
+ children.push(h('div.tag.edittag', {
+ key: 'edittag'
+ }, h('input#edittag-input', _.assign({
type: 'text',
defaultValue: ''
}, okCancelEvents({
@@ -156,6 +184,7 @@
})))));
} else {
children.push(h('div.tag.addtag', {
+ key: 'addtag',
onClick: function() {
that.setState({addingTag: true});
}
@@ -178,10 +207,12 @@
}
},
render: function() {
- var that = this;
- var todo = this.props.todo, children = [];
- if (this.state.editingText) {
- children.push(h('div.edit', h('input#todo-input', _.assign({
+ var that = this, todo = this.props.todo, et = this.state.editingText;
+ var hDescription;
+ if (et) {
+ hDescription = h('div.description', {
+ key: 'description'
+ }, h('input#todo-input', _.assign({
type: 'text',
defaultValue: todo.text
}, okCancelEvents({
@@ -192,67 +223,71 @@
cancel: function() {
that.setState({editingText: false});
}
- })))));
+ }))));
} else {
- children.push(h('div.destroy', {
+ hDescription = h('div.description', {
+ key: 'description',
+ onDoubleClick: function() {
+ that.setState({editingText: true});
+ }
+ }, todo.text);
+ }
+ var opts = (et ? '.edit' : '') + (todo.done ? '.done' : '');
+ return h('li.todo-row' + opts, [
+ h('div.destroy', {
+ key: 'destroy',
onClick: function() {
disp.removeTodo(todo._id);
}
- }));
- children.push(h('div.display', [
- h('input.check', {
- type: 'checkbox',
- checked: todo.done,
- onClick: function() {
- disp.markTodoDone(todo._id, !todo.done);
- }
- }),
- h('div.todo-text', {
- onDoubleClick: function() {
- that.setState({editingText: true});
- }
- }, todo.text)
- ]));
- }
- children.push(Tags({todoId: todo._id, tags: todo.tags}));
- return h('li.todo' + (todo.done ? '.done' : ''), children);
+ }),
+ h('input.checkbox', {
+ key: 'checkbox',
+ type: 'checkbox',
+ checked: todo.done,
+ onChange: function() {
+ disp.markTodoDone(todo._id, !todo.done);
+ }
+ }),
+ hDescription,
+ TodoTags({key: 'tags', todoId: todo._id, tags: todo.tags})
+ ]);
}
}));
-var Todos = React.createFactory(React.createClass({
- displayName: 'Todos',
+var TodosPane = React.createFactory(React.createClass({
+ displayName: 'TodosPane',
render: function() {
var that = this;
if (!this.props.listId) {
return null;
}
var children = [];
- if (this.props.todos === null) {
- children.push('Loading...');
+ if (!this.props.todos) {
+ children.push(h('div.loading', {key: 'loading'}, 'Loading...'));
} else {
var tagFilter = this.props.tagFilter, items = [];
_.each(this.props.todos, function(todo) {
if (tagFilter === null || _.contains(todo.tags, tagFilter)) {
- items.push(Todo({todo: todo}));
+ items.push(Todo({key: todo._id, todo: todo}));
}
});
- children.push(h('div#new-todo-box', h('input#new-todo', _.assign({
+ children.push(h('div#new-todo', {key: 'new-todo'}, h('input', _.assign({
type: 'text',
placeholder: 'New item'
}, okCancelEvents({
- ok: function(value, ev) {
+ ok: function(value, e) {
disp.addTodo(that.props.listId, {
text: value,
tags: tagFilter ? [tagFilter] : [],
done: false,
timestamp: Date.now()
});
- ev.target.value = '';
+ e.target.value = '';
}
})))));
- children.push(h('ul#item-list', items));
+ children.push(h('ul#todo-list', {key: 'todo-list'}, items));
}
- return h('div#items-view', children);
+ return h('div#todos-pane', children);
}
}));
@@ -269,11 +304,13 @@
}
},
render: function() {
- var that = this;
- var list = this.props.list, child;
+ var that = this, list = this.props.list;
+ var children = [];
// http://facebook.github.io/react/docs/forms.html#controlled-components
if (this.state.editingName) {
- child = h('div.edit', h('input#list-name-input', _.assign({
+ children.push(h('div.edit', {
+ key: 'edit'
+ }, h('input#list-name-input', _.assign({
type: 'text',
defaultValue: list.name
}, okCancelEvents({
@@ -284,14 +321,23 @@
cancel: function() {
that.setState({editingName: false});
}
- }))));
+ })))));
} else {
- child = h('div.display', h('a.list-name' + (list.name ? '' : '.empty'), {
- href: '/lists/' + list._id,
- onClick: function(ev) {
- ev.preventDefault();
+ children.push(h('div.status', {
+ key: 'status',
+ onClick: function(e) {
+ e.preventDefault();
+ that.props.showStatusDialog();
}
- }, list.name));
+ }, h('div.circle')));
+ children.push(h('div.display', {
+ key: 'display'
+ }, h('a.list-name' + (list.name ? '' : '.empty'), {
+ href: '/lists/' + list._id,
+ onClick: function(e) {
+ e.preventDefault();
+ }
+ }, list.name)));
}
return h('div.list' + (list.selected ? '.selected' : ''), {
onMouseDown: function() {
@@ -300,48 +346,98 @@
onDoubleClick: function() {
that.setState({editingName: true});
}
- }, child);
+ }, children);
}
}));
-var Lists = React.createFactory(React.createClass({
- displayName: 'Lists',
+var StatusPane = React.createFactory(React.createClass({
+ displayName: 'StatusPane',
+ componentDidMount: function() {
+ var that = this;
+ Mousetrap.bind('esc', function() {
+ that.props.close();
+ });
+ },
+ componentWillUnmount: function() {
+ Mousetrap.unbind('esc');
+ },
render: function() {
var that = this;
- var children = [h('h3', 'Todo Lists')];
- if (this.props.lists === null) {
- children.push(h('div#lists', 'Loading...'));
+ return h('div#status-pane', {
+ onClick: function(e) {
+ if (e.target === e.currentTarget) {
+ that.props.close();
+ }
+ }
+ }, h('div.status-dialog', [
+ // TODO(sadovsky): Add stuff here.
+ h('h4', {key: 'title'}, 'Share with others'),
+ // FIXME: Finish implementing this.
+ h('div.close', {
+ key: 'close',
+ onClick: function() {
+ that.props.close();
+ }
+ })
+ ]));
+ }
+}));
+
+var ListsPane = React.createFactory(React.createClass({
+ displayName: 'ListsPane',
+ getInitialState: function() {
+ return {
+ statusDialog: null // null or list id
+ };
+ },
+ render: function() {
+ var that = this;
+ var children = [h('div.lists-title', {key: 'title'}, 'Todo Lists')];
+ if (!this.props.lists) {
+ children.push(h('div.loading', {key: 'loading'}, 'Loading...'));
} else {
var lists = [];
_.each(this.props.lists, function(list) {
list.selected = that.props.listId === list._id;
lists.push(List({
+ key: list._id,
list: list,
- setListId: that.props.setListId
+ setListId: that.props.setListId,
+ showStatusDialog: function() {
+ that.setState({statusDialog: list._id});
+ }
}));
});
- children.push(h('div#lists', lists));
- children.push(h('div#createList', h('input#new-list', _.assign({
+ children.push(h('div', {key: 'lists'}, lists));
+ children.push(h('div.new-list', {key: 'new-list'}, h('input', _.assign({
type: 'text',
placeholder: 'New list'
}, okCancelEvents({
- ok: function(value, ev) {
+ ok: function(value, e) {
disp.addList({name: value}, function(err, listId) {
if (err) throw err;
that.props.setListId(listId);
});
- ev.target.value = '';
+ e.target.value = '';
}
})))));
+ if (this.state.statusDialog) {
+ children.push(StatusPane({
+ key: 'status',
+ close: function() {
+ that.setState({statusDialog: null});
+ }
+ }));
+ }
}
- return h('div', children);
+ return h('div#lists-pane', children);
}
}));
var DispType = React.createFactory(React.createClass({
render: function() {
var that = this;
- return h('div.disp-type.' + this.props.dispType, {
+ return h('div#disp-type.' + this.props.dispType, {
onClick: function() {
that.props.toggleDispType();
}
@@ -353,8 +449,11 @@
displayName: 'Page',
getInitialState: function() {
return {
- lists: null, // all lists
- todos: null, // all todos for current listId
+ dispInitialized: false,
+ lists: {seq: 0, items: null}, // all lists
+ todos: {}, // map of listId to {seq, items}
+ // FIXME: Populate and use this.
+ syncgroups: {}, // map of listId to sgInfo
listId: this.props.initialListId, // current list
tagFilter: null // current tag
};
@@ -382,58 +481,120 @@
// Note, this doesn't trigger a re-render; it's purely visual.
window.history.replaceState({}, '', pathname + window.location.search);
},
- componentDidMount: function() {
- var that = this;
-
- // TODO(sadovsky): Only read (and only update) what's needed based on what
- // changed.
- disp.on('change', function() {
- var listId = that.state.listId;
- that.getLists_(function(err, lists) {
- if (err) throw err;
- that.getTodos_(listId, function(err, todos) {
- if (err) throw err;
- // TODO(sadovsky): Maybe don't call setState if a newer change has
- // been observed.
- var nextState = {lists: lists};
- if (that.state.listId === listId) {
- nextState.todos = todos;
- }
- that.setState(nextState);
- });
- });
- });
-
- that.getLists_(function(err, lists) {
+ componentWillMount: function() {
+ var that = this, props = this.props;
+ var dt = props.dispType, sn = props.syncbaseName, bm = props.benchmark;
+ initDispatcher(dt, sn, bm, function(err) {
if (err) throw err;
- var listId = that.state.listId;
- if ((!listId || !_.includes(_.pluck(lists, '_id'), listId)) &&
- lists.length > 0) {
+ that.setState({dispInitialized: true});
+ });
+ },
+ componentDidMount: function() {
+ console.assert(!this.state.dispInitialized);
+ },
+ componentDidUpdate: function(prevProps, prevState) {
+ var that = this;
+ this.updateURL();
+
+ // Only run the code below when disp has just been initialized.
+ if (prevState.dispInitialized || !this.state.dispInitialized) {
+ return;
+ }
+
+ // Returns the list id for the list that should be displayed.
+ function getListId() {
+ var listId = that.state.listId, lists = that.state.lists.items;
+ // If listId refers to an unknown list, set it to null.
+ if (!_.includes(_.pluck(lists, '_id'), listId)) {
+ listId = null;
+ }
+ // If listId is not set, set it to the id of the first list.
+ if (!listId && lists.length > 0) {
listId = lists[0]._id;
}
+ return listId;
+ }
+
+ // Updates lists. Calls cb once the setState call has completed.
+ // TODO(sadovsky): If possible, simplify how we deal with concurrent state
+ // updates, here and elsewhere. The current approach is fairly subtle and
+ // error-prone. Our goal is simple: never show stale data, even in the
+ // presence of sync.
+ function updateLists(cb) {
+ var listsSeq = that.state.lists.seq + 1;
+ that.getLists_(function(err, lists) {
+ if (err) return cb(err);
+ // Use setState(cb) form to ensure atomicity.
+ // References: https://goo.gl/CZ82Vp and https://goo.gl/vVCp8B
+ that.setState(function(state) {
+ if (listsSeq <= state.lists.seq) {
+ return {};
+ }
+ return {lists: {seq: listsSeq, items: lists}};
+ }, cb);
+ });
+ }
+
+ // Updates todos for the specified list. Calls cb once the setState call
+ // has completed.
+ function updateTodos(listId, cb) {
+ var stateTodos = that.state.todos[listId];
+ var todosSeq = (stateTodos ? stateTodos.seq : 0) + 1;
that.getTodos_(listId, function(err, todos) {
+ if (err) return cb(err);
+ // Use setState(cb) form to ensure atomicity.
+ // https://goo.gl/CZ82Vp
+ that.setState(function(state) {
+ var stateTodos = state.todos[listId];
+ if (stateTodos && todosSeq <= stateTodos.seq) {
+ return {};
+ }
+ state.todos[listId] = {seq: todosSeq, items: todos};
+ return {todos: state.todos};
+ }, cb);
+ });
+ }
+
+ // TODO(sadovsky): Only read (and only redraw) what's needed based on what
+ // changed.
+ disp.on('change', function() {
+ updateLists(function(err) {
if (err) throw err;
- that.setState({
- lists: lists,
- todos: todos,
- listId: listId
+ var listId = getListId();
+ updateTodos(listId, function(err) {
+ if (err) throw err;
});
});
});
- },
- componentWillUpdate: function(nextProps, nextState) {
- if (false) {
- util.log(this.props, nextProps);
- util.log(this.state, nextState);
- }
- },
- componentDidUpdate: function() {
- this.updateURL();
+
+ // Load initial lists and todos. Note that changes can come in concurrently
+ // via sync.
+ updateLists(function(err) {
+ if (err) throw err;
+ // Set initial listId if needed.
+ var listId = getListId();
+ if (listId !== that.state.listId) {
+ that.setState({listId: listId});
+ }
+ // Get todos for all lists.
+ var listIds = _.pluck(that.state.lists.items, '_id');
+ async.each(listIds, updateTodos, function(err) {
+ if (err) throw err;
+ });
+ });
},
render: function() {
+ if (this.props.benchmark) {
+ return null;
+ }
+
var that = this;
- return h('div', [
+ var listId = this.state.listId;
+ // If currTodos is {}, todos.items will be undefined, as desired.
+ var currTodos = this.state.todos[listId] || {};
+ return h('div#page-pane', [
DispType({
+ key: 'DispType',
dispType: this.props.dispType,
toggleDispType: function() {
var newDispType = DISP_TYPE_SYNCBASE;
@@ -443,44 +604,35 @@
window.location.href = '/?d=' + newDispType + '&n=' + SYNCBASE_NAME;
}
}),
- h('div#top-tag-filter', TagFilter({
- todos: this.state.todos,
- tagFilter: this.state.tagFilter,
- setTagFilter: function(tagFilter) {
- that.setState({tagFilter: tagFilter});
- }
- })),
- h('div#main-pane', Todos({
- todos: this.state.todos,
- listId: this.state.listId,
- tagFilter: this.state.tagFilter
- })),
- h('div#side-pane', Lists({
- lists: this.state.lists,
- listId: this.state.listId,
+ ListsPane({
+ key: 'ListsPane',
+ lists: this.state.lists.items,
+ listId: listId,
setListId: function(listId) {
if (listId !== that.state.listId) {
that.setState({
- todos: null,
listId: listId,
tagFilter: null
- }, function() {
- // Run getTodos_ in the setState callback to ensure that it will
- // execute after the 'change' event handler executes when a list
- // is created locally.
- // TODO(sadovsky): Maybe hold all todos (for all lists) in memory
- // so that we don't show a brief "loading" message on every list
- // change.
- that.getTodos_(listId, function(err, todos) {
- if (err) throw err;
- if (listId === that.state.listId) {
- that.setState({todos: todos});
- }
- });
});
}
}
- }))
+ }),
+ h('div#tags-and-todos-pane', {key: 'tags-and-todos-pane'}, [
+ TagsPane({
+ key: 'TagsPane',
+ todos: currTodos.items,
+ tagFilter: this.state.tagFilter,
+ setTagFilter: function(tagFilter) {
+ that.setState({tagFilter: tagFilter});
+ }
+ }),
+ TodosPane({
+ key: 'TodosPane',
+ todos: currTodos.items,
+ listId: listId,
+ tagFilter: this.state.tagFilter
+ })
+ ])
]);
}
}));
@@ -488,63 +640,36 @@
////////////////////////////////////////
// Initialization
+// TODO(sadovsky): Override other console methods and add window.onerror
+// handler.
var logEl = document.querySelector('#log');
-util.addLogger(function() {
+var consoleLog = console.log.bind(console);
+console.log = function() {
+ var args = [util.timestamp()].concat(Array.prototype.slice.call(arguments));
+ consoleLog.apply(null, args);
var msgEl = document.createElement('div');
msgEl.className = 'msg';
- msgEl.innerText = Array.prototype.slice.call(arguments).join(' ');
+ msgEl.innerText = args.join(' ');
logEl.appendChild(msgEl);
+ logEl.scrollTop = logEl.scrollHeight; // scroll to bottom
+};
+
+Mousetrap.bind(['ctrl+l', 'meta+l'], function() {
+ logEl.classList.toggle('visible');
});
-util.log('starting app');
-
-var u = url.parse(window.location.href, true);
-
-var rc; // React component
-function render(props) {
- console.assert(!rc);
- rc = React.render(Page(props), document.querySelector('#page'));
-}
-
-function initDispatcher(dispType, syncbaseName, benchmark, cb) {
- if (dispType === 'collection') {
- console.assert(!benchmark);
- defaults.initCollectionDispatcher(cb);
- } else if (dispType === 'syncbase') {
- var vanadiumConfig = {
- logLevel: vanadium.vlog.levels.INFO,
- namespaceRoots: u.query.mounttable ? [u.query.mounttable] : undefined,
- proxy: u.query.proxy
- };
- vanadium.init(vanadiumConfig, function(err, rt) {
- if (err) return cb(err);
- defaults.initSyncbaseDispatcher(rt, syncbaseName, benchmark, cb);
- });
- } else {
- process.nextTick(function() {
- cb(new Error('unknown dispType: ' + dispType));
- });
- }
-}
// Note, ctx here is a Page.js context, not a Vanadium context.
function main(ctx) {
- console.assert(!rc);
var dispType = u.query.d || 'collection';
var syncbaseName = u.query.n || SYNCBASE_NAME;
var benchmark = Boolean(u.query.bm);
var props = {
initialListId: ctx.params.listId,
dispType: dispType,
- syncbaseName: syncbaseName
+ syncbaseName: syncbaseName,
+ benchmark: benchmark
};
- initDispatcher(dispType, syncbaseName, benchmark, function(err, resDisp) {
- if (err) throw err;
- if (benchmark) return;
- disp = resDisp;
- // TODO(sadovsky): initDispatcher with DISP_TYPE_SYNCBASE is slow. We should
- // show a "loading" message in the UI.
- render(props);
- });
+ React.render(Page(props), document.querySelector('#page'));
}
page('/', main);
diff --git a/browser/mem_collection.js b/browser/mem_collection.js
index 2156dc4..3b2dc16 100644
--- a/browser/mem_collection.js
+++ b/browser/mem_collection.js
@@ -27,7 +27,7 @@
return that.matches_(v, q);
});
if (opts.sort) {
- // TODO(sadovsky): Eliminate simplifying assumptions.
+ // Note, we make various simplifying assumptions.
var keys = _.keys(opts.sort);
console.assert(keys.length === 1);
var key = keys[0];
@@ -72,7 +72,7 @@
return that.matches_(v, q);
});
- // TODO(sadovsky): Eliminate simplifying assumptions.
+ // Note, we make various simplifying assumptions.
var keys = _.keys(opts);
console.assert(keys.length === 1);
var key = keys[0];
diff --git a/browser/syncbase_dispatcher.js b/browser/syncbase_dispatcher.js
index 1c64ccc..e840b44 100644
--- a/browser/syncbase_dispatcher.js
+++ b/browser/syncbase_dispatcher.js
@@ -13,10 +13,10 @@
// enables us to use simple last-one-wins conflict resolution for all records
// stored in Syncbase.
//
-// TODO(sadovsky): Unfortunately, orphaning degrades performance, because scan
-// RPCs (e.g. scan to get all lists) read (and discard) orphaned records. If we
-// switch from scans to queries, performance should improve since all row
-// filtering will happen on the server side.
+// TODO(sadovsky): Orphaning degrades performance, because scan responses (e.g.
+// scan to get all lists) include orphaned records. If we switch from scans to
+// queries, performance should improve since all row filtering will happen
+// server side.
'use strict';
@@ -31,7 +31,6 @@
var bm = require('./benchmark');
var Dispatcher = require('./dispatcher');
-var util = require('./util');
inherits(SyncbaseDispatcher, Dispatcher);
module.exports = SyncbaseDispatcher;
@@ -115,7 +114,7 @@
// Drop ctx and cb, convert to JSON, drop square brackets.
var cb = args[args.length - 1];
var argsStr = JSON.stringify(args.slice(1, -1)).slice(1, -1);
- args[args.length - 1] = bm.logLatency(name + '(' + argsStr + ')', cb);
+ args[args.length - 1] = bm.logFn(name + '(' + argsStr + ')', cb);
return fn.apply(this, args);
};
}
@@ -249,7 +248,7 @@
};
SyncbaseDispatcher.prototype.logTraceRecords = function() {
- util.log(vtrace.formatTraces(this.getTraceRecords()));
+ console.log(vtrace.formatTraces(this.getTraceRecords()));
};
// TODO(sadovsky): Watch for changes on Syncbase itself so that we can detect
diff --git a/browser/util.js b/browser/util.js
index 999ca99..0584383 100644
--- a/browser/util.js
+++ b/browser/util.js
@@ -23,20 +23,7 @@
};
// Returns a string timestamp, useful for logging.
-var timestamp = exports.timestamp = function(t) {
+exports.timestamp = function(t) {
t = t || Date.now();
return moment(t).format('HH:mm:ss.SSS');
};
-
-var LOGGERS = [console.log.bind(console)];
-
-exports.addLogger = function(logger) {
- LOGGERS.push(logger);
-};
-
-exports.log = function() {
- var args = [timestamp()].concat(Array.prototype.slice.call(arguments));
- _.forEach(LOGGERS, function(logger) {
- logger.apply(null, args);
- });
-};
diff --git a/package.json b/package.json
index 139877b..026b9ef 100644
--- a/package.json
+++ b/package.json
@@ -24,10 +24,14 @@
"react": "^0.13.3"
},
"devDependencies": {
+ "autoprefixer": "^5.2.0",
"browserify": "^10.2.6",
"browserify-shim": "^3.8.9",
+ "cssnano": "^2.1.0",
"exorcist": "~0.4.0",
"jshint": "^2.8.0",
+ "less": "^2.5.1",
+ "postcss-cli": "^1.4.0",
"uglifyify": "~3.0.1"
}
}
diff --git a/public/extras.css b/public/extras.css
deleted file mode 100644
index 2edb974..0000000
--- a/public/extras.css
+++ /dev/null
@@ -1,54 +0,0 @@
-*,
-:before,
-:after {
- box-sizing: border-box;
-}
-
-.disp-type {
- position: fixed;
- top: 0;
- right: 0;
- padding: 4px 8px;
- cursor: pointer;
- color: white;
- font-weight: bold;
- z-index: 1;
-}
-
-/* https://www.google.com/design/spec/style/color.html */
-.disp-type.collection {
- background-color: #388e3c;
-}
-.disp-type.syncbase {
- background-color: #673ab7;
-}
-
-#log {
- position: fixed;
- right: 0;
- bottom: 0;
- width: 20px;
- height: 20px;
- background-color: #e91e63;
- font: 400 14px/1.4 monospace;
- overflow-x: hidden;
- overflow-y: scroll;
- white-space: pre-wrap;
- word-wrap: break-word;
-}
-
-#log .msg {
- display: none;
-}
-
-#log:hover {
- width: 100%;
- height: 300px;
- padding: 16px;
- background-color: #fff;
- border-top: 1px solid #000;
-}
-
-#log:hover .msg {
- display: block;
-}
diff --git a/public/index.css b/public/index.css
deleted file mode 100644
index 7ada0b4..0000000
--- a/public/index.css
+++ /dev/null
@@ -1,266 +0,0 @@
-/* Copy of the original Meteor Todos app CSS file (only slightly modified). */
-
-* {
- padding: 0;
- margin: 0;
-}
-
-ul {
- list-style: none;
-}
-
-html, body {
- height: 100%;
-}
-
-body {
- font-size: 16px;
- line-height: 1.5;
- background: #eeeeee;
- color: #333333;
-}
-
-body, input {
- font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
-}
-
-input {
- font-size: 100%;
-}
-
-a,
-a:visited,
-a:active {
- color: #258;
-}
-
-h3 {
- font-weight: bold;
- text-decoration: underline;
- font-size: 120%;
- padding: 8px 6px;
- text-align: center;
-}
-
-#top-tag-filter, #main-pane, #side-pane, #bottom-pane {
- position: absolute;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- overflow: hidden;
-}
-
-#top-tag-filter {
- left: 200px;
- height: 44px;
- bottom: auto;
- background: #ddd;
- border-bottom: 1px solid #999;
-}
-
-#help {
- padding: 8px;
-}
-
-#main-pane {
- top: 45px;
- bottom: 0;
- left: 220px;
- overflow: auto;
-}
-
-#side-pane {
- width: 200px;
- right: auto;
- overflow: auto;
- background: #eee;
- border-right: 1px solid #999;
- background: #ddd;
-}
-
-.tag {
- cursor: pointer;
- float: left;
- margin: 5px;
- padding: 2px 7px;
- font-size: 80%;
- font-weight: bold;
- background: #999;
- color: #fff;
- border-radius: 4px;
- -webkit-border-radius: 4px;
- -moz-border-radius: 4px;
- -o-border-radius: 4px;
-
- opacity: 1;
- transition: opacity 0.3s linear;
- -moz-transition: opacity 0.3s linear;
- -webkit-transition: opacity 0.3s linear;
- -o-transition: opacity 0.3s linear;
-
- position: relative;
-}
-
-#tag-filter .label {
- float: left;
- margin-top: 9px;
- margin-left: 12px;
- margin-right: 8px;
-}
-
-#tag-filter .tag {
- margin-top: 10px;
- border: 1px solid #777;
-}
-
-#tag-filter .selected {
- background: #69d;
-}
-
-#tag-filter .count {
- font-weight: normal;
- padding-left: 2px;
-}
-
-#lists .list {
- padding: 3px 6px;
-}
-
-#lists .selected {
- padding: 2px 6px;
- background: #9be;
- font-weight: bold;
-}
-
-#lists .list-name {
- cursor: pointer;
- color: black;
- text-decoration: none;
-}
-
-#createList {
- padding: 3px 6px;
- margin-top: 5px;
-}
-
-#createList input {
- width: 180px;
-}
-
-#new-todo-box {
- margin-top: 10px;
- margin-bottom: 10px;
- margin-left: 60px;
- margin-right: 20px;
- font-size: 160%;
- position: relative;
- height: 40px;
-}
-
-#new-todo {
- position: absolute;
- width: 100%;
-}
-
-#items-view {
- margin-top: 5px;
- margin-left: 5px;
-}
-
-#item-list .todo {
- display: block;
- height: 50px;
- position: relative;
- overflow: hidden;
- border-top: 1px solid #ccc;
-}
-
-#item-list .todo .destroy {
- cursor: pointer;
- position: absolute;
- left: 5px;
- top: 15px;
- height: 20px;
- width: 20px;
-}
-
-#item-list .todo .display, #item-list .todo .edit {
- margin-left: 30px;
- height: 100%;
- width: auto;
- float: left;
- padding-top: 18px;
- line-height: 1;
-}
-
-#todo-input {
- width: 300px;
- position: relative;
- top: -3px;
-}
-
-#item-list .done .todo-text {
- text-decoration: line-through;
- color: #999;
-}
-
-#item-list .todo:hover .destroy {
- background: url("/public/destroy.png") no-repeat 0 0;
-}
-
-#item-list .todo .destroy:hover {
- background-position: 0 -20px;
-}
-
-#item-list .todo .item-tags {
- overflow: auto;
- float: right;
- margin-right: 8px;
-}
-
-#item-list .todo .item-tags .tag {
- margin-top: 15px;
-}
-
-#item-list .todo .item-tags .removable_tag {
- padding-right: 22px;
-}
-
-#item-list .todo .item-tags .tag .remove {
- position: absolute;
- top: 0;
- right: 4px;
- bottom: 0;
- width: 16px;
- background: url("/public/close_16.png") no-repeat 0 center;
-}
-
-#item-list .todo .item-tags .tag .remove:hover {
- background-position: -16px center;
-}
-
-#item-list .todo .item-tags div.addtag {
- background: none;
- color: #333;
- border: 1px dashed #999;
-}
-
-#item-list .todo .check {
- float: left;
- width: 25px;
-}
-
-#item-list .todo .todo-text {
- float: left;
- margin-left: 10px;
- font-size: 100%;
-}
-
-#item-list .todo .edit input {
- margin-left: 35px;
-}
-
-#edittag-input {
- width: 80px;
-}
diff --git a/public/index.html b/public/index.html
index 9cf329c..b76dc6d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,8 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no">
- <link rel="stylesheet" href="/public/index.css">
- <link rel="stylesheet" href="/public/extras.css">
+ <link rel="stylesheet" href="/public/bundle.min.css">
<link rel="icon" href="about:blank">
<title>Todos</title>
</head>
@@ -14,6 +13,7 @@
<script src="/third_party/async.min.js"></script>
<script src="/third_party/lodash.min.js"></script>
<script src="/third_party/moment.min.js"></script>
+ <script src="/third_party/mousetrap.min.js"></script>
<!--<script src="public/third_party/react.min.js"></script>-->
<script src="/third_party/react-with-addons.js"></script>
<script src="/public/bundle.min.js"></script>
diff --git a/stylesheets/constants.less b/stylesheets/constants.less
new file mode 100644
index 0000000..8e996ff
--- /dev/null
+++ b/stylesheets/constants.less
@@ -0,0 +1,3 @@
+/* https://www.google.com/design/spec/style/color.html */
+@color-green-700: #388e3c;
+@color-red-700: #d32f2f;
diff --git a/stylesheets/index.less b/stylesheets/index.less
new file mode 100644
index 0000000..b2b6897
--- /dev/null
+++ b/stylesheets/index.less
@@ -0,0 +1,384 @@
+/* Adapted from the original Meteor Todos app CSS file. */
+
+@import "constants";
+
+*,
+:before,
+:after {
+ box-sizing: border-box;
+}
+
+html {
+ font: 400 16px/1.5 sans-serif;
+}
+
+html, body {
+ height: 100%;
+}
+
+body {
+ font-size: 16px;
+ line-height: 1.5;
+ background-color: #eee;
+ color: #333;
+ margin: 0;
+}
+
+h1, h2, h3, h4, h5, h6, p {
+ margin: 0;
+
+ &:not(:last-child) {
+ margin-bottom: 1em;
+ }
+}
+
+h1, h2, h3, h4, h5, h6 {
+ &:not(:first-child) {
+ margin-top: 2em;
+ }
+}
+
+p {
+ &:not(:first-child) {
+ margin-top: 1em;
+ }
+}
+
+input {
+ font: inherit;
+
+ &:focus {
+ outline: none;
+ }
+}
+
+/* Pane arrangement ***********************************************************/
+
+#page-pane {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ overflow: hidden;
+}
+
+#page-pane, #tags-and-todos-pane {
+ display: flex;
+}
+
+#tags-and-todos-pane {
+ flex-direction: column;
+}
+
+#tags-and-todos-pane, #todos-pane {
+ flex: 1;
+}
+
+#lists-pane {
+ flex: 0 0 240px;
+}
+
+#tags-pane {
+ flex: 0 0 48px;
+}
+
+#todos-pane, #lists-pane, #tags-pane {
+ overflow: auto;
+}
+
+/* Generic classes ************************************************************/
+
+.hcenter {
+ display: flex;
+ justify-content: center;
+}
+
+.vcenter {
+ display: flex;
+ align-items: center;
+}
+
+.loading {
+ padding: 4px 8px;
+}
+
+.tag {
+ display: inline-block;
+ margin-left: 8px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ background-color: #999;
+ color: #fff;
+ font-size: 13px;
+ font-weight: bold;
+ cursor: pointer;
+}
+
+/* Lists pane *****************************************************************/
+
+#lists-pane {
+ border-right: 1px solid #999;
+ background-color: #ddd;
+
+ .lists-title {
+ font-weight: bold;
+ text-decoration: underline;
+ font-size: 20px;
+ margin: 8px 0;
+ text-align: center;
+ }
+
+ .list, .new-list {
+ .vcenter;
+ padding: 0 12px;
+ width: 100%;
+ height: 40px;
+ }
+
+ .list{
+ &.selected {
+ background-color: #9be;
+ font-weight: bold;
+ }
+
+ .status {
+ position: relative;
+ display: inline-block;
+ margin-left: -8px;
+ padding: 8px;
+ cursor: pointer;
+
+ .circle {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background-color: #888;
+ }
+ }
+
+ .list-name {
+ cursor: pointer;
+ color: #000;
+ text-decoration: none;
+ }
+ }
+
+ .new-list input {
+ margin-top: 8px;
+ width: 100%;
+ }
+}
+
+/* Status pane (currently, within #lists-pane) ********************************/
+
+#status-pane {
+ .hcenter;
+ .vcenter;
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ overflow: hidden;
+ background-color: rgba(255, 255, 255, 0.85);
+ z-index: 10; /* above everything */
+
+ .status-dialog {
+ position: relative;
+ width: 600px;
+ padding: 32px;
+ background-color: #fff;
+ border: 1px solid rgba(0, 0, 0, 0.3);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
+
+ .title {
+ font-size: 18px;
+ margin-bottom: 1em;
+ }
+
+ .close {
+ position: absolute;
+ top: 0;
+ right: 0;
+ margin: 6px;
+ background: url("/public/destroy.png") no-repeat 0 0;
+ cursor: pointer;
+ height: 20px;
+ width: 20px;
+
+ &:hover {
+ background-position: 0 -20px;
+ }
+ }
+ }
+}
+
+/* Tags pane ******************************************************************/
+
+#tags-pane {
+ .vcenter;
+ border-bottom: 1px solid #999;
+ background-color: #ddd;
+
+ .label {
+ display: inline-block;
+ }
+
+ .label {
+ margin-left: 16px;
+ margin-right: 8px;
+ }
+
+ .tag {
+ border: 1px solid #666;
+
+ &.selected {
+ background: #69d;
+ }
+ }
+
+ .count {
+ font-weight: normal;
+
+ &:before {
+ content: " ";
+ }
+ }
+}
+
+/* Todos pane *****************************************************************/
+
+#todos-pane {
+ overflow-y: scroll;
+
+ #new-todo {
+ margin: 16px 32px;
+ font-size: 20px;
+
+ input {
+ width: 100%;
+ }
+ }
+
+ #todo-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ #todo-list .todo-row {
+ .vcenter;
+ display: flex;
+ height: 48px;
+ margin: 0 16px;
+ border-top: 1px solid #ccc;
+ overflow: hidden;
+
+ &.edit {
+ .destroy, .checkbox {
+ visibility: hidden;
+ }
+ }
+
+ .destroy {
+ cursor: pointer;
+ height: 20px;
+ width: 20px;
+ margin: 6px;
+ }
+
+ &:hover .destroy {
+ background: url("/public/destroy.png") no-repeat 0 0;
+
+ &:hover {
+ background-position: 0 -20px;
+ }
+ }
+
+ .description {
+ flex: 1;
+ margin-left: 16px;
+ }
+
+ &.done .description {
+ text-decoration: line-through;
+ color: #999;
+ }
+
+ .item-tags {
+ overflow: auto;
+
+ #edittag-input {
+ width: 80px;
+ }
+
+ .tag.removable {
+ position: relative;
+ padding-right: 24px;
+ }
+
+ .remove {
+ position: absolute;
+ top: 0;
+ right: 4px;
+ bottom: 0;
+ width: 16px;
+ background: url("/public/close_16.png") no-repeat 0 center;
+
+ &:hover {
+ background-position: -16px center;
+ }
+ }
+
+ .addtag {
+ margin-right: 8px;
+ border: 1px dashed #999;
+ background-color: transparent;
+ color: #333;
+ }
+ }
+ }
+}
+
+/* Other **********************************************************************/
+
+#log {
+ position: fixed;
+ bottom: 0;
+ width: 100%;
+ height: 300px;
+ z-index: 2; /* above .disp-type */
+ padding: 16px;
+ border-top: 1px solid #000;
+ overflow-y: scroll;
+ background-color: #fff;
+ color: #000;
+ font: 400 14px/1.4 monospace;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ transform: translate3d(0, 100%, 0);
+ transition: transform 0.2s ease-out;
+
+ &.visible {
+ transform: translate3d(0, 0, 0);
+ }
+}
+
+#disp-type {
+ position: fixed;
+ top: 0;
+ right: 0;
+ padding: 0 16px;
+ color: #fff;
+ cursor: pointer;
+ z-index: 1;
+
+ &.collection {
+ background-color: @color-red-700;
+ }
+
+ &.syncbase {
+ background-color: @color-green-700;
+ }
+}
diff --git a/third_party/mousetrap.min.js b/third_party/mousetrap.min.js
new file mode 100644
index 0000000..291aff8
--- /dev/null
+++ b/third_party/mousetrap.min.js
@@ -0,0 +1,11 @@
+/* mousetrap v1.5.3 craig.is/killing/mice */
+(function(C,r,g){function t(a,b,h){a.addEventListener?a.addEventListener(b,h,!1):a.attachEvent("on"+b,h)}function x(a){if("keypress"==a.type){var b=String.fromCharCode(a.which);a.shiftKey||(b=b.toLowerCase());return b}return l[a.which]?l[a.which]:p[a.which]?p[a.which]:String.fromCharCode(a.which).toLowerCase()}function D(a){var b=[];a.shiftKey&&b.push("shift");a.altKey&&b.push("alt");a.ctrlKey&&b.push("ctrl");a.metaKey&&b.push("meta");return b}function u(a){return"shift"==a||"ctrl"==a||"alt"==a||
+"meta"==a}function y(a,b){var h,c,e,g=[];h=a;"+"===h?h=["+"]:(h=h.replace(/\+{2}/g,"+plus"),h=h.split("+"));for(e=0;e<h.length;++e)c=h[e],z[c]&&(c=z[c]),b&&"keypress"!=b&&A[c]&&(c=A[c],g.push("shift")),u(c)&&g.push(c);h=c;e=b;if(!e){if(!k){k={};for(var m in l)95<m&&112>m||l.hasOwnProperty(m)&&(k[l[m]]=m)}e=k[h]?"keydown":"keypress"}"keypress"==e&&g.length&&(e="keydown");return{key:c,modifiers:g,action:e}}function B(a,b){return null===a||a===r?!1:a===b?!0:B(a.parentNode,b)}function c(a){function b(a){a=
+a||{};var b=!1,n;for(n in q)a[n]?b=!0:q[n]=0;b||(v=!1)}function h(a,b,n,f,c,h){var g,e,l=[],m=n.type;if(!d._callbacks[a])return[];"keyup"==m&&u(a)&&(b=[a]);for(g=0;g<d._callbacks[a].length;++g)if(e=d._callbacks[a][g],(f||!e.seq||q[e.seq]==e.level)&&m==e.action){var k;(k="keypress"==m&&!n.metaKey&&!n.ctrlKey)||(k=e.modifiers,k=b.sort().join(",")===k.sort().join(","));k&&(k=f&&e.seq==f&&e.level==h,(!f&&e.combo==c||k)&&d._callbacks[a].splice(g,1),l.push(e))}return l}function g(a,b,n,f){d.stopCallback(b,
+b.target||b.srcElement,n,f)||!1!==a(b,n)||(b.preventDefault?b.preventDefault():b.returnValue=!1,b.stopPropagation?b.stopPropagation():b.cancelBubble=!0)}function e(a){"number"!==typeof a.which&&(a.which=a.keyCode);var b=x(a);b&&("keyup"==a.type&&w===b?w=!1:d.handleKey(b,D(a),a))}function l(a,c,n,f){function e(c){return function(){v=c;++q[a];clearTimeout(k);k=setTimeout(b,1E3)}}function h(c){g(n,c,a);"keyup"!==f&&(w=x(c));setTimeout(b,10)}for(var d=q[a]=0;d<c.length;++d){var p=d+1===c.length?h:e(f||
+y(c[d+1]).action);m(c[d],p,f,a,d)}}function m(a,b,c,f,e){d._directMap[a+":"+c]=b;a=a.replace(/\s+/g," ");var g=a.split(" ");1<g.length?l(a,g,b,c):(c=y(a,c),d._callbacks[c.key]=d._callbacks[c.key]||[],h(c.key,c.modifiers,{type:c.action},f,a,e),d._callbacks[c.key][f?"unshift":"push"]({callback:b,modifiers:c.modifiers,action:c.action,seq:f,level:e,combo:a}))}var d=this;a=a||r;if(!(d instanceof c))return new c(a);d.target=a;d._callbacks={};d._directMap={};var q={},k,w=!1,p=!1,v=!1;d._handleKey=function(a,
+c,e){var f=h(a,c,e),d;c={};var k=0,l=!1;for(d=0;d<f.length;++d)f[d].seq&&(k=Math.max(k,f[d].level));for(d=0;d<f.length;++d)f[d].seq?f[d].level==k&&(l=!0,c[f[d].seq]=1,g(f[d].callback,e,f[d].combo,f[d].seq)):l||g(f[d].callback,e,f[d].combo);f="keypress"==e.type&&p;e.type!=v||u(a)||f||b(c);p=l&&"keydown"==e.type};d._bindMultiple=function(a,b,c){for(var d=0;d<a.length;++d)m(a[d],b,c)};t(a,"keypress",e);t(a,"keydown",e);t(a,"keyup",e)}var l={8:"backspace",9:"tab",13:"enter",16:"shift",17:"ctrl",18:"alt",
+20:"capslock",27:"esc",32:"space",33:"pageup",34:"pagedown",35:"end",36:"home",37:"left",38:"up",39:"right",40:"down",45:"ins",46:"del",91:"meta",93:"meta",224:"meta"},p={106:"*",107:"+",109:"-",110:".",111:"/",186:";",187:"=",188:",",189:"-",190:".",191:"/",192:"`",219:"[",220:"\\",221:"]",222:"'"},A={"~":"`","!":"1","@":"2","#":"3",$:"4","%":"5","^":"6","&":"7","*":"8","(":"9",")":"0",_:"-","+":"=",":":";",'"':"'","<":",",">":".","?":"/","|":"\\"},z={option:"alt",command:"meta","return":"enter",
+escape:"esc",plus:"+",mod:/Mac|iPod|iPhone|iPad/.test(navigator.platform)?"meta":"ctrl"},k;for(g=1;20>g;++g)l[111+g]="f"+g;for(g=0;9>=g;++g)l[g+96]=g;c.prototype.bind=function(a,b,c){a=a instanceof Array?a:[a];this._bindMultiple.call(this,a,b,c);return this};c.prototype.unbind=function(a,b){return this.bind.call(this,a,function(){},b)};c.prototype.trigger=function(a,b){if(this._directMap[a+":"+b])this._directMap[a+":"+b]({},a);return this};c.prototype.reset=function(){this._callbacks={};this._directMap=
+{};return this};c.prototype.stopCallback=function(a,b){return-1<(" "+b.className+" ").indexOf(" mousetrap ")||B(b,this.target)?!1:"INPUT"==b.tagName||"SELECT"==b.tagName||"TEXTAREA"==b.tagName||b.isContentEditable};c.prototype.handleKey=function(){return this._handleKey.apply(this,arguments)};c.init=function(){var a=c(r),b;for(b in a)"_"!==b.charAt(0)&&(c[b]=function(b){return function(){return a[b].apply(a,arguments)}}(b))};c.init();C.Mousetrap=c;"undefined"!==typeof module&&module.exports&&(module.exports=
+c);"function"===typeof define&&define.amd&&define(function(){return c})})(window,document);
diff --git a/tools/get_third_party_scripts.sh b/tools/get_third_party_scripts.sh
index 2c7350e..ac21c5d 100755
--- a/tools/get_third_party_scripts.sh
+++ b/tools/get_third_party_scripts.sh
@@ -14,5 +14,6 @@
get async.min.js https://raw.githubusercontent.com/caolan/async/master/dist/async.min.js
get lodash.min.js https://raw.githubusercontent.com/lodash/lodash/3.10.0/lodash.min.js
get moment.min.js https://raw.githubusercontent.com/moment/moment/2.10.3/min/moment.min.js
+get mousetrap.min.js https://raw.githubusercontent.com/ccampbell/mousetrap/1.5.3/mousetrap.min.js
get react.min.js https://fb.me/react-0.13.3.min.js
get react-with-addons.js https://fb.me/react-with-addons-0.13.3.js