Browse Source

Add watch task for gulp

Gerald 10 years ago
parent
commit
0d8b8a7ee4

+ 61 - 22
gulpfile.js

@@ -5,31 +5,76 @@ const concat = require('gulp-concat');
 const merge2 = require('merge2');
 const minifyCss = require('gulp-minify-css');
 const gulpFilter = require('gulp-filter');
+const order = require('gulp-order');
 const del = require('del');
 const templateCache = require('./scripts/templateCache');
 const i18n = require('./scripts/i18n');
 
+const paths = {
+  cache: 'src/cache.js',
+  templates: 'src/**/templates/*.html',
+  jsOptions: 'src/options/**/*.js',
+  jsPopup: 'src/popup/**/*.js',
+  locales: [
+    'src/**/*.js',
+    'src/**/*.html',
+    'src/**/*.json',
+  ],
+  copy: [
+    'src/**',
+    '!src/cache.js',
+    '!src/**/templates/**',
+    '!src/**/templates',
+    '!src/**/views',
+    '!src/options/**/*.js',
+    '!src/popup/**/*.js',
+    '!src/_locales/**',
+  ],
+};
+
+gulp.task('watch', function () {
+  gulp.watch([].concat(paths.cache, paths.templates), ['templates']);
+  gulp.watch(paths.jsOptions, ['js-options']);
+  gulp.watch(paths.jsPopup, ['js-popup']);
+  gulp.watch(paths.copy, ['copy-files']);
+  gulp.watch(paths.locales, ['copy-i18n']);
+});
+
+gulp.task('clean', function () {
+  return del(['dist']);
+});
+
 gulp.task('templates', function () {
   return merge2([
-    gulp.src('src/cache.js'),
-    gulp.src('src/**/templates/*.html').pipe(templateCache()),
+    gulp.src(paths.cache),
+    gulp.src(paths.templates).pipe(templateCache()),
   ]).pipe(concat('cache.js'))
   .pipe(gulp.dest('dist'));
 });
 
-gulp.task('clean', function () {
-  return del(['dist']);
+gulp.task('js-options', function () {
+  return gulp.src(paths.jsOptions)
+  .pipe(order([
+    '**/tab-*.js',
+    '!**/app.js',
+  ]))
+  .pipe(concat('options/app.js'))
+  .pipe(gulp.dest('dist'));
 });
 
+gulp.task('js-popup', function () {
+  return gulp.src(paths.jsPopup)
+  .pipe(order([
+    '**/base.js',
+    '!**/app.js',
+  ]))
+  .pipe(concat('popup/app.js'))
+  .pipe(gulp.dest('dist'));
+})
+
 gulp.task('copy-files', function () {
   const cssFilter = gulpFilter(['**/*.css'], {restore: true});
-  return gulp.src([
-    'src/**',
-    '!src/cache.js',
-    '!src/**/templates/**',
-    '!src/**/templates',
-    '!src/_locales/**',
-  ])
+  return gulp.src(paths.copy)
   .pipe(cssFilter)
   .pipe(minifyCss())
   .pipe(cssFilter.restore)
@@ -37,11 +82,8 @@ gulp.task('copy-files', function () {
 });
 
 gulp.task('copy-i18n', function () {
-  return gulp.src([
-    'src/**/*.js',
-    'src/**/*.html',
-    'src/**/*.json',
-  ]).pipe(i18n.extract({
+  return gulp.src(paths.locales)
+  .pipe(i18n.extract({
     base: 'src',
     prefix: '_locales',
     touchedOnly: true,
@@ -51,14 +93,11 @@ gulp.task('copy-i18n', function () {
   .pipe(gulp.dest('dist'));
 });
 
-gulp.task('default', ['templates', 'copy-files', 'copy-i18n']);
+gulp.task('default', ['templates', 'js-options', 'js-popup', 'copy-files', 'copy-i18n']);
 
 gulp.task('i18n', function () {
-  return gulp.src([
-    'src/**/*.js',
-    'src/**/*.html',
-    'src/**/*.json',
-  ]).pipe(i18n.extract({
+  return gulp.src(paths.locales)
+  .pipe(i18n.extract({
     base: 'src',
     prefix: '_locales',
     touchedOnly: false,

+ 1 - 0
package.json

@@ -12,6 +12,7 @@
     "gulp-concat": "^2.6.0",
     "gulp-filter": "^3.0.1",
     "gulp-minify-css": "^1.2.2",
+    "gulp-order": "^1.1.1",
     "gulp-util": "^3.0.7",
     "html-minifier": "^1.0.0",
     "merge2": "^0.3.6",

+ 0 - 3
src/options/index.html

@@ -12,9 +12,6 @@
     <script src="/lib/zip.js/zip.js"></script>
     <script src="/common.js"></script>
     <script src="/cache.js"></script>
-    <script src="model.js"></script>
-    <script src="view.js"></script>
-    <script src="editor.js"></script>
   </head>
   <body>
     <div id="app"></div>

+ 0 - 889
src/options/view.js

@@ -1,889 +0,0 @@
-var DEFAULT_ICON = '/images/icon48.png';
-var ScriptView = BaseView.extend({
-  className: 'script',
-  attributes: {
-    draggable: true,
-  },
-  templateUrl: '/options/templates/script.html',
-  events: {
-    'click [data-id=edit]': 'onEdit',
-    'click [data-id=remove]': 'onRemove',
-    'click [data-id=enable]': 'onEnable',
-    'click [data-id=update]': 'onUpdate',
-    'dragstart': 'onDragStart',
-  },
-  initialize: function () {
-    var _this = this;
-    _this.model.set('_icon', DEFAULT_ICON);
-    // MUST call `super` before `render`
-    BaseView.prototype.initialize.call(_this);
-    _this.listenTo(_this.model, 'change', _this.render);
-    _this.listenTo(_this.model, 'remove', _this.onRemoved);
-  },
-  loadIcon: function () {
-    var _this = this;
-    var icon = _this.model.get('meta').icon;
-    if (icon && icon !== _this.model.get('_icon'))
-      _this.loadImage(icon).then(function (url) {
-        _this.model.set('_icon', url);
-      }, function (url) {
-        _this.model.set('_icon', DEFAULT_ICON);
-      });
-  },
-  render: function () {
-    var _this = this;
-    var model = _this.model;
-    var it = model.toJSON();
-    it.getLocaleString = model.getLocaleString.bind(model);
-    it.canUpdate = model.canUpdate();
-    it.homepageURL = it.custom.homepageURL || it.meta.homepageURL || it.meta.homepage;
-    it.author = _this.getAuthor(it.meta.author);
-    _this.$el.html(_this.templateFn(it));
-    if (!it.enabled) _this.$el.addClass('disabled');
-    else _this.$el.removeClass('disabled');
-    _this.loadIcon();
-    return _this;
-  },
-  getAuthor: function (text) {
-    if (!text) return '';
-    var matches = text.match(/^(.*?)\s<(\S*?@\S*?)>$/);
-    var label = _.i18n('labelAuthor');
-    return matches
-      ? label + '<a href=mailto:' + matches[2] + '>' + matches[1] + '</a>'
-      : label + _.escape(text);
-  },
-  images: {},
-  loadImage: function (url) {
-    if (!url) return;
-    var promise = this.images[url];
-    if (!promise) {
-      var cache = scriptList.cache[url];
-      promise = cache ? Promise.resolve(cache)
-      : new Promise(function (resolve, reject) {
-        var img = new Image;
-        img.onload = function () {
-          resolve(url);
-        };
-        img.onerror = function () {
-          reject(url);
-        };
-        img.src = url;
-      });
-      this.images[url] = promise;
-    }
-    return promise;
-  },
-  onEdit: function () {
-    scriptList.trigger('edit:open', this.model);
-  },
-  onRemove: function () {
-    var _this = this;
-    _.sendMessage({
-      cmd: 'RemoveScript',
-      data: _this.model.id,
-    }).then(function () {
-      scriptList.remove(_this.model);
-    });
-  },
-  onRemoved: function () {
-    this.$el.remove();
-  },
-  onEnable: function () {
-    var _this = this;
-    _.sendMessage({
-      cmd: 'UpdateScriptInfo',
-      data: {
-        id: _this.model.id,
-        enabled: _this.model.get('enabled') ? 0 : 1,
-      },
-    });
-  },
-  onUpdate: function () {
-    _.sendMessage({
-      cmd: 'CheckUpdate',
-      data: this.model.id,
-    });
-  },
-  onDragStart: function (e) {
-    var model = this.model;
-    new DND(e, function (data) {
-      if (data.from === data.to) return;
-      _.sendMessage({
-        cmd: 'Move',
-        data: {
-          id: model.id,
-          offset: data.to - data.from,
-        }
-      }).then(function () {
-        var collection = model.collection;
-        var models = collection.models;
-        var i = Math.min(data.from, data.to);
-        var j = Math.max(data.from, data.to);
-        var seq = [
-          models.slice(0, i),
-          models.slice(i, j + 1),
-          models.slice(j + 1),
-        ];
-        i === data.to
-        ? seq[1].unshift(seq[1].pop())
-        : seq[1].push(seq[1].shift());
-        collection.models = seq.concat.apply([], seq);
-      });
-    });
-  },
-});
-
-function DND(e, cb) {
-  this.mousemove = this.mousemove.bind(this);
-  this.mouseup = this.mouseup.bind(this);
-  if (e) {
-    e.preventDefault();
-    this.start(e);
-  }
-  this.onDrop = cb;
-}
-DND.prototype.start = function (e) {
-  var dragging = this.dragging = {
-    el: e.currentTarget,
-  };
-  var $el = dragging.$el = $(dragging.el);
-  var parent = $el.parent();
-  var offset = $el.offset();
-  dragging.offset = {
-    x: e.clientX - offset.left,
-    y: e.clientY - offset.top,
-  };
-  var next = $el.next();
-  dragging.delta = (next.length ? next.offset().top : parent.height()) - offset.top;
-  var children = parent.children();
-  dragging.lastIndex = dragging.index = children.index($el);
-  dragging.$elements = children.not($el);
-  dragging.$dragged = $el.clone().addClass('dragging').css({
-    left: offset.left,
-    top: offset.top,
-    width: offset.width,
-  }).appendTo(parent);
-  $el.addClass('dragging-placeholder');
-  $(document).on('mousemove', this.mousemove).on('mouseup', this.mouseup);
-};
-DND.prototype.mousemove = function (e) {
-  var dragging = this.dragging;
-  dragging.$dragged.css({
-    left: e.clientX - dragging.offset.x,
-    top: e.clientY - dragging.offset.y,
-  });
-  var hovered = null;
-  dragging.$elements.each(function (i, el) {
-    var $el = $(el);
-    if ($el.hasClass('dragging-moving')) return;
-    var offset = $el.offset();
-    var pad = 10;
-    if (
-      e.clientX >= offset.left + pad
-      && e.clientX <= offset.left + offset.width - pad
-      && e.clientY >= offset.top + pad
-      && e.clientY <= offset.top + offset.height - pad
-    ) {
-      hovered = {
-        index: i,
-        el: el,
-      };
-      return false;
-    }
-  });
-  if (hovered) {
-    var lastIndex = dragging.lastIndex;
-    var index = hovered.index;
-    var isDown = index >= lastIndex;
-    var $el = dragging.$el;
-    var delta = dragging.delta;
-    if (isDown) {
-      // If moving down, the actual index should be `index + 1`
-      index ++;
-      $el.insertAfter(hovered.el);
-    } else {
-      delta = -delta;
-      $el.insertBefore(hovered.el);
-    }
-    dragging.lastIndex = index;
-    this.animate(dragging.$elements.slice(
-      isDown ? lastIndex : index,
-      isDown ? index : lastIndex
-    ), delta);
-  }
-};
-DND.prototype.animate = function ($elements, delta) {
-  $elements.each(function (i, el) {
-    var $el = $(el);
-    $el.addClass('dragging-moving').css({
-      transition: 'none',
-      transform: 'translateY(' + delta + 'px)',
-    }).one('transitionend', function (e) {
-      $(e.target).removeClass('dragging-moving');
-    });
-    setTimeout(function () {
-      $el.css({
-        transition: '',
-        transform: '',
-      });
-    }, 20);
-  });
-};
-DND.prototype.mouseup = function (e) {
-  $(document).off('mousemove', this.mousemove).off('mouseup', this.mouseup);
-  var dragging = this.dragging;
-  dragging.$dragged.remove();
-  dragging.$el.removeClass('dragging-placeholder');
-  this.dragging = null;
-  this.onDrop && this.onDrop({
-    from: dragging.index,
-    to: dragging.lastIndex,
-  });
-};
-
-var MainTab = BaseView.extend({
-  el: '#tab',
-  name: 'main',
-  templateUrl: '/options/templates/tab-installed.html',
-  events: {
-    'click #bNew': 'newScript',
-    'click #bUpdate': 'updateAll',
-  },
-  initialize: function () {
-    var _this = this;
-    BaseView.prototype.initialize.call(_this);
-    _this.listenTo(scriptList, 'reset', _this.render);
-    _this.listenTo(scriptList, 'add', _this.addOne);
-    _this.listenTo(scriptList, 'add update', _this.setBackdrop);
-    _this.listenTo(scriptList, 'edit:open', function (model) {
-      _this.closeEdit();
-      _this.editView = new EditView({model: model.clone()});
-      _this.$el.append(_this.editView.$el);
-    });
-    _this.listenTo(scriptList, 'edit:close', _this.closeEdit);
-  },
-  closeEdit: function () {
-    var _this = this;
-    if (_this.editView) {
-      _this.editView.remove();
-      _this.editView = null;
-    }
-  },
-  render: function () {
-    this.$el.html(this.templateFn());
-    this.$list = this.$('.scripts');
-    this.$bd = this.$('.backdrop');
-    this.$bdm = this.$('.backdrop > div');
-    this.setBackdrop();
-    this.addAll();
-    return this;
-  },
-  setBackdrop: function () {
-    if (scriptList.loading) {
-      this.$bd.addClass('mask').show();
-      this.$bdm.html(_.i18n('msgLoading'));
-    } else if (!scriptList.length) {
-      this.$bd.removeClass('mask').show();
-      this.$bdm.html(_.i18n('labelNoScripts'));
-    } else {
-      this.$bd.hide();
-    }
-  },
-  addOne: function (script) {
-    var view = new ScriptView({model: script});
-    this.$list.append(view.$el);
-  },
-  addAll: function () {
-    scriptList.forEach(this.addOne, this);
-  },
-  newScript: function () {
-    _.sendMessage({cmd: 'NewScript'}).then(function (script) {
-      scriptList.trigger('edit:open', new Script(script));
-    });
-  },
-  updateAll: function () {
-    _.sendMessage({cmd: 'CheckUpdateAll'});
-  },
-});
-
-var ExportList = BaseView.extend({
-  el: '.export-list',
-  templateUrl: '/options/templates/option.html',
-  initialize: function () {
-    BaseView.prototype.initialize.call(this);
-    this.listenTo(scriptList, 'reset change', this.render);
-  },
-  render: function () {
-    var _this = this;
-    _this.$el.html(scriptList.map(function (script) {
-      return _this.templateFn(script.toJSON());
-    }).join(''));
-    return _this;
-  },
-  getSelected: function () {
-    var selected = [];
-    this.$('option').each(function (i, option) {
-      if (option.selected) selected.push(scriptList.at(i));
-    });
-    return selected;
-  },
-  toggleAll: function () {
-    var options = this.$('option');
-    var select = _.some(options, function (option) {
-      return !option.selected;
-    });
-    options.each(function (i, option) {
-      option.selected = select;
-    });
-  },
-});
-
-var SettingsTab = BaseView.extend({
-  el: '#tab',
-  name: 'settings',
-  events: {
-    'change [data-check]': 'updateCheckbox',
-    'change #sInjectMode': 'updateInjectMode',
-    'change #cUpdate': 'updateAutoUpdate',
-    'click #bSelect': 'toggleSelection',
-    'click #bImport': 'importFile',
-    'click #bExport': 'exportData',
-    'click #bVacuum': 'onVacuum',
-  },
-  templateUrl: '/options/templates/tab-settings.html',
-  render: function () {
-    var options = _.options.getAll();
-    this.$el.html(this.templateFn(options));
-    this.$('#sInjectMode').val(options.injectMode);
-    this.updateInjectHint();
-    this.exportList = new ExportList;
-    return this;
-  },
-  updateCheckbox: _.updateCheckbox,
-  updateAutoUpdate: function (e) {
-    _.sendMessage({cmd: 'AutoUpdate'});
-  },
-  updateInjectHint: function () {
-    this.$('#sInjectMode+span').text([
-      _.i18n('hintInjectModeNormal'),
-      _.i18n('hintInjectModeAdvanced'),
-    ][this.$('#sInjectMode').val()]);
-  },
-  updateInjectMode: function (e) {
-    _.options.set('injectMode', e.target.value);
-    this.updateInjectHint();
-  },
-  toggleSelection: function () {
-    this.exportList.toggleAll();
-  },
-  importData: function (file) {
-    function getVMConfig(text) {
-      var vm;
-      try {
-        vm = JSON.parse(text);
-      } catch (e) {
-        console.log('Error parsing ViolentMonkey configuration.');
-      }
-      vm = vm || {};
-      _.forEach(vm.values, function (value, key) {
-        _.sendMessage({
-          cmd: 'SetValue',
-          data: {
-            url: key,
-            values: value,
-          }
-        });
-      });
-      _.forEach(vm.settings, function (value, key) {
-        _.options.set(key, value);
-      });
-      return vm;
-    }
-    function getVMFile(entry, vm) {
-      if (!entry.filename.endsWith('.user.js')) return;
-      vm = vm || {};
-      return new Promise(function (resolve, reject) {
-        var writer = new zip.TextWriter;
-        entry.getData(writer, function (text) {
-          var script = {code: text};
-          if (vm.scripts) {
-            var more = vm.scripts[entry.filename.slice(0, -8)];
-            if (more) script.more = _.omit(more, ['id']);
-          }
-          _.sendMessage({
-            cmd: 'ParseScript',
-            data: script,
-          }).then(function () {
-            resolve(true);
-          });
-        });
-      });
-    }
-    function getVMFiles(entries) {
-      var i = entries.findIndex(function (entry) {
-        return entry.filename === 'ViolentMonkey';
-      });
-      if (~i) return new Promise(function (resolve, reject) {
-        var writer = new zip.TextWriter;
-        entries[i].getData(writer, function (text) {
-          entries.splice(i, 1);
-          resolve({
-            vm: getVMConfig(text),
-            entries: entries,
-          });
-        });
-      });
-      return {
-        entries: entries,
-      };
-    }
-    function readZip(file) {
-      return new Promise(function (resolve, reject) {
-        zip.createReader(new zip.BlobReader(file), function (res) {
-          res.getEntries(function (entries) {
-            resolve(entries);
-          });
-        }, function (err) {reject(err);});
-      });
-    }
-    readZip(file).then(getVMFiles).then(function (data) {
-      var vm = data.vm;
-      var entries = data.entries;
-      return Promise.all(entries.map(function (entry) {
-        return getVMFile(entry, vm);
-      })).then(function (res) {
-        return _.filter(res).length;
-      });
-    }).then(function (count) {
-      scriptList.reload();
-      alert(_.i18n('msgImported', [count]));
-    });
-  },
-  importFile: function () {
-    var _this = this;
-    $('<input type=file accept=".zip">')
-    .change(function (e) {
-      if (this.files && this.files.length)
-        _this.importData(this.files[0]);
-    })
-    .trigger('click');
-  },
-  exportData: function () {
-    function getWriter() {
-      return new Promise(function (resolve, reject) {
-        zip.createWriter(new zip.BlobWriter, function (writer) {
-          resolve(writer);
-        });
-      });
-    }
-    function addFile(writer, file) {
-      return new Promise(function (resolve, reject) {
-        writer.add(file.name, new zip.TextReader(file.content), function () {
-          resolve(writer);
-        });
-      });
-    }
-    function download(writer) {
-      return new Promise(function (resolve, reject) {
-        writer.close(function (blob) {
-          var url = URL.createObjectURL(blob);
-          $('<a>').attr({
-            href: url,
-            download: 'scripts.zip',
-          }).trigger('click');
-          URL.revokeObjectURL(url);
-          resolve();
-        });
-      });
-    }
-    var bExport = this.$('#bExport');
-    bExport.prop('disabled', true);
-    var selected = this.exportList.getSelected();
-    if (!selected.length) return;
-    var withValues = this.$('#cbValues').prop('checked');
-    _.sendMessage({
-      cmd: 'ExportZip',
-      data: {
-        values: withValues,
-        ids: _.pluck(selected, 'id'),
-      }
-    }).then(function (data) {
-      var names = {};
-      var vm = {
-        scripts: {},
-        settings: _.options.getAll(),
-      };
-      if (withValues) vm.values = {};
-      var files = data.scripts.map(function (script) {
-        var name = script.custom.name || script.meta.name || 'Noname';
-        if (names[name]) name += '_' + (++ names[name]);
-        else names[name] = 1;
-        vm.scripts[name] = _.pick(script, ['id', 'custom', 'enabled', 'update']);
-        if (withValues) {
-          var values = data.values[script.uri];
-          if (values) vm.values[script.uri] = values;
-        }
-        return {
-          name: name + '.user.js',
-          content: script.code,
-        };
-      });
-      files.push({
-        name: 'ViolentMonkey',
-        content: JSON.stringify(vm),
-      });
-      return files;
-    }).then(function (files) {
-      return files.reduce(function (result, file) {
-        return result.then(function (writer) {
-          return addFile(writer, file);
-        });
-      }, getWriter()).then(download);
-    }).then(function () {
-      bExport.prop('disabled', false);
-    });
-  },
-  onVacuum: function (e) {
-    var button = $(e.target);
-    button.prop('disabled', true).html(_.i18n('buttonVacuuming'));
-    _.sendMessage({cmd: 'Vacuum'}).then(function () {
-      button.html(_.i18n('buttonVacuumed'));
-    });
-  },
-});
-
-var AboutTab = BaseView.extend({
-  el: '#tab',
-  name: 'about',
-  templateUrl: '/options/templates/tab-about.html',
-  render: function () {
-    this.$el.html(this.templateFn());
-    return this;
-  },
-});
-
-var MainView = BaseView.extend({
-  el: '#app',
-  templateUrl: '/options/templates/main.html',
-  tabs: {
-    '': MainTab,
-    settings: SettingsTab,
-    about: AboutTab,
-  },
-  initialize: function (tab) {
-    var _this = this;
-    _this.tab = _this.tabs[tab] || _this.tabs[''];
-    BaseView.prototype.initialize.call(_this);
-  },
-  render: function () {
-    this.$el.html(this.templateFn({tab: this.tab.prototype.name}));
-    this.view = new this.tab;
-    return this;
-  },
-});
-
-var ConfirmView = BaseView.extend({
-  el: '#app',
-  events: {
-    'click .button-toggle': 'toggleButton',
-    'click #btnInstall': 'installScript',
-    'click #btnClose': 'close',
-    'change [data-check]': 'updateCheckbox',
-    'change #cbClose': 'updateClose',
-  },
-  templateUrl: '/options/templates/confirm.html',
-  initialize: function (url, _from) {
-    this.url = url;
-    this.from = _from;
-    BaseView.prototype.initialize.call(this);
-  },
-  render: function () {
-    var _this = this;
-    _this.$el.html(_this.templateFn({
-      url: _this.url,
-      options: _.options.getAll(),
-    }));
-    _this.showMessage(_.i18n('msgLoadingData'));
-    _this.loadedEditor = _.initEditor({
-      container: _this.$('.editor-code')[0],
-      readonly: true,
-      onexit: _this.close,
-    }).then(function (editor) {
-      _this.editor = editor;
-    });
-    _this.loadData().then(function () {
-      _this.parseMeta();
-    });
-    return _this;
-  },
-  updateCheckbox: _.updateCheckbox,
-  loadData: function () {
-    var _this = this;
-    _this.$('#btnInstall').prop('disabled', true);
-    _this.data = {
-      require: {},
-      resources: {},
-      dependencyOK: false,
-      isLocal: false,
-    };
-    return _this.getScript(_this.url).then(function (res) {
-      _this.data.isLocal = !res.status;
-      _this.data.code = res.responseText;
-      _this.loadedEditor.then(function () {
-        _this.editor.setValueAndFocus(_this.data.code);
-      });
-    });
-  },
-  parseMeta: function () {
-    var _this = this;
-    return _.sendMessage({
-      cmd: 'ParseMeta',
-      data: _this.data.code,
-    }).then(function (script) {
-      var urls = _.values(script.resources);
-      var length = script.require.length + urls.length;
-      var finished = 0;
-      if (!length) return;
-      var error = [];
-      var updateStatus = function () {
-        _this.showMessage(_.i18n('msgLoadingDependency', [finished, length]));
-      };
-      updateStatus();
-      var promises = script.require.map(function (url) {
-        return _this.getFile(url).then(function (res) {
-          _this.data.require[url] = res;
-        });
-      });
-      promises = promises.concat(urls.map(function (url) {
-        return _this.getFile(url, true).then(function (res) {
-          _this.data.resources[url] = res;
-        });
-      }));
-      promises = promises.map(function (promise) {
-        return promise.then(function () {
-          finished += 1;
-          updateStatus();
-        }, function (url) {
-          error.push(url);
-        });
-      });
-      return Promise.all(promises).then(function () {
-        if (error.length) return Promise.reject(error.join('\n'));
-        _this.data.dependencyOK = true;
-      });
-    }).then(function () {
-      _this.showMessage(_.i18n('msgLoadedData'));
-      _this.$('#btnInstall').prop('disabled', false);
-    }, function (error) {
-      _this.showMessage(_.i18n('msgErrorLoadingDependency'), error);
-      return Promise.reject();
-    });
-  },
-  toggleButton: function (e) {
-    this.$(e.target).toggleClass('active');
-  },
-  close: function () {
-    window.close();
-  },
-  updateClose: function (e) {
-    this.$('#cbTrack').prop('disabled', e.target.checked);
-  },
-  showMessage: function (msg) {
-    this.$('#msg').html(msg);
-  },
-  getFile: function (url, isBlob) {
-    var xhr = new XMLHttpRequest;
-    xhr.open('GET', url, true);
-    if (isBlob) xhr.responseType = 'blob';
-    return new Promise(function (resolve, reject) {
-      xhr.onload = function () {
-        if (isBlob) {
-          var reader = new FileReader;
-          reader.onload = function (e) {
-            resolve(window.btoa(this.result));
-          };
-          reader.readAsBinaryString(this.response);
-        } else {
-          resolve(xhr.responseText);
-        }
-      };
-      xhr.onerror = function () {
-        reject(url);
-      };
-      xhr.send();
-    });
-  },
-  getScript: function (url) {
-    var _this = this;
-    var xhr = new XMLHttpRequest;
-    xhr.open('GET', url, true);
-    return new Promise(function (resolve, reject) {
-      xhr.onload = function () {
-        resolve(this);
-      };
-      xhr.onerror = function () {
-        _this.showMessage(_.i18n('msgErrorLoadingData'));
-        reject(this);
-      };
-      xhr.send();
-    });
-  },
-  getTimeString: function () {
-    var now = new Date();
-    return _.zfill(now.getHours(), 2) + ':' +
-      _.zfill(now.getMinutes(), 2) + ':' +
-      _.zfill(now.getSeconds(), 2);
-  },
-  installScript: function () {
-    var _this = this;
-    _this.$('#btnInstall').prop('disabled', true);
-    _.sendMessage({
-      cmd:'ParseScript',
-      data:{
-        url: _this.url,
-        from: _this.from,
-        code: _this.data.code,
-        require: _this.data.require,
-        resources: _this.data.resources,
-      },
-    }).then(function (res) {
-      _this.showMessage(res.message + '[' + _this.getTimeString() + ']');
-      if (res.code < 0) return;
-      if (_.options.get('closeAfterInstall')) _this.close();
-      else if (_this.data.isLocal && _.options.get('trackLocalFile')) _this.trackLocalFile();
-    });
-  },
-  trackLocalFile: function () {
-    var _this = this;
-    setTimeout(function () {
-      var code = _this.data.code;
-      _this.loadData().then(function () {
-        var track = _.options.get('trackLocalFile');
-        if (!track) return;
-        if (_this.data.code != code)
-          _this.parseMeta().then(function () {
-            track && _this.installScript();
-          });
-        else
-          _this.trackLocalFile();
-      });
-    }, 2000);
-  },
-});
-
-var MetaView = BaseView.extend({
-  className: 'button-panel',
-  templateUrl: '/options/templates/edit-meta.html',
-  events: {
-    'change [data-id]': 'onChange',
-  },
-  render: function () {
-    var model = this.model;
-    var it = model.toJSON();
-    it.__name = model.meta.name;
-    it.__homepageURL = model.meta.homepageURL;
-    it.__updateURL = model.meta.updateURL || _.i18n('hintUseDownloadURL');
-    it.__downloadURL = model.meta.downloadURL || it.lastInstallURL;
-    this.$el.html(this.templateFn(it));
-  },
-  onChange: function (e) {
-    e.stopPropagation();
-    var res = this.getValue(e.target);
-    this.model.set(res.key, res.value);
-  },
-});
-
-var EditView = BaseView.extend({
-  className: 'frame edit',
-  templateUrl: '/options/templates/edit.html',
-  events: {
-    'click .button-toggle': 'toggleButton',
-    'change [data-id]': 'updateCheckbox',
-    'click #editorSave': 'save',
-    'click #editorClose': 'close',
-    'click #editorSaveClose': 'saveClose',
-  },
-  initialize: function () {
-    var _this = this;
-    BaseView.prototype.initialize.call(_this);
-    _this.metaModel = new Meta(_this.model.toJSON(), {parse: true});
-    _this.listenTo(_this.metaModel, 'change', function (model) {
-      _this.model.set('custom', model.toJSON());
-    });
-    _this.listenTo(_this.model, 'change', function (model) {
-      _this.updateStatus(true);
-    });
-  },
-  render: function () {
-    var _this = this;
-    var it = _this.model.toJSON();
-    _this.$el.html(_this.templateFn(it));
-    var gotScript = it.id ? _.sendMessage({
-      cmd: 'GetScript',
-      data: it.id,
-    }) : Promise.resolve(it);
-    _this.loadedEditor = _.initEditor({
-      container: _this.$('.editor-code')[0],
-      onsave: _this.save.bind(_this),
-      onexit: _this.close,
-      onchange: function (e) {
-        _this.model.set('code', _this.editor.getValue());
-      },
-    });
-    Promise.all([
-      gotScript,
-      _this.loadedEditor,
-    ]).then(function (res) {
-      var script = res[0];
-      var editor = _this.editor = res[1];
-      editor.setValueAndFocus(script.code);
-      editor.clearHistory();
-      _this.updateStatus(false);
-    });
-  },
-  updateStatus: function (changed) {
-    this.changed = changed;
-    this.$('#editorSave').prop('disabled', !changed);
-    this.$('#editorSaveClose').prop('disabled', !changed);
-  },
-  save: function () {
-    var _this = this;
-    var data = _this.model.toJSON();
-    return _.sendMessage({
-      cmd: 'ParseScript',
-      data: {
-        id: data.id,
-        code: data.code,
-        message: '',
-        more: {
-          custom: data.custom,
-          update: data.update,
-        }
-      }
-    }).then(function () {
-      _this.updateStatus(false);
-    });
-  },
-  close: function () {
-    if (!this.changed || confirm(_.i18n('confirmNotSaved')))
-      scriptList.trigger('edit:close');
-  },
-  saveClose: function () {
-    this.save().then(this.close.bind(this));
-  },
-  toggleButton: function (e) {
-    if (this.metaView) {
-      this.$(e.target).removeClass('active');
-      this.metaView.remove();
-      this.metaView = null;
-    } else {
-      this.$(e.target).addClass('active');
-      this.metaView = new MetaView({model: this.metaModel});
-      this.metaView.$el.insertAfter(e.target);
-    }
-  },
-  updateCheckbox: function (e) {
-    var res = this.getValue(e.target);
-    this.model.set(res.key, res.value);
-  },
-});

+ 188 - 0
src/options/views/confirm.js

@@ -0,0 +1,188 @@
+var ConfirmView = BaseView.extend({
+  el: '#app',
+  events: {
+    'click .button-toggle': 'toggleButton',
+    'click #btnInstall': 'installScript',
+    'click #btnClose': 'close',
+    'change [data-check]': 'updateCheckbox',
+    'change #cbClose': 'updateClose',
+  },
+  templateUrl: '/options/templates/confirm.html',
+  initialize: function (url, _from) {
+    this.url = url;
+    this.from = _from;
+    BaseView.prototype.initialize.call(this);
+  },
+  render: function () {
+    var _this = this;
+    _this.$el.html(_this.templateFn({
+      url: _this.url,
+      options: _.options.getAll(),
+    }));
+    _this.showMessage(_.i18n('msgLoadingData'));
+    _this.loadedEditor = _.initEditor({
+      container: _this.$('.editor-code')[0],
+      readonly: true,
+      onexit: _this.close,
+    }).then(function (editor) {
+      _this.editor = editor;
+    });
+    _this.loadData().then(function () {
+      _this.parseMeta();
+    });
+    return _this;
+  },
+  updateCheckbox: _.updateCheckbox,
+  loadData: function () {
+    var _this = this;
+    _this.$('#btnInstall').prop('disabled', true);
+    _this.data = {
+      require: {},
+      resources: {},
+      dependencyOK: false,
+      isLocal: false,
+    };
+    return _this.getScript(_this.url).then(function (res) {
+      _this.data.isLocal = !res.status;
+      _this.data.code = res.responseText;
+      _this.loadedEditor.then(function () {
+        _this.editor.setValueAndFocus(_this.data.code);
+      });
+    });
+  },
+  parseMeta: function () {
+    var _this = this;
+    return _.sendMessage({
+      cmd: 'ParseMeta',
+      data: _this.data.code,
+    }).then(function (script) {
+      var urls = _.values(script.resources);
+      var length = script.require.length + urls.length;
+      var finished = 0;
+      if (!length) return;
+      var error = [];
+      var updateStatus = function () {
+        _this.showMessage(_.i18n('msgLoadingDependency', [finished, length]));
+      };
+      updateStatus();
+      var promises = script.require.map(function (url) {
+        return _this.getFile(url).then(function (res) {
+          _this.data.require[url] = res;
+        });
+      });
+      promises = promises.concat(urls.map(function (url) {
+        return _this.getFile(url, true).then(function (res) {
+          _this.data.resources[url] = res;
+        });
+      }));
+      promises = promises.map(function (promise) {
+        return promise.then(function () {
+          finished += 1;
+          updateStatus();
+        }, function (url) {
+          error.push(url);
+        });
+      });
+      return Promise.all(promises).then(function () {
+        if (error.length) return Promise.reject(error.join('\n'));
+        _this.data.dependencyOK = true;
+      });
+    }).then(function () {
+      _this.showMessage(_.i18n('msgLoadedData'));
+      _this.$('#btnInstall').prop('disabled', false);
+    }, function (error) {
+      _this.showMessage(_.i18n('msgErrorLoadingDependency'), error);
+      return Promise.reject();
+    });
+  },
+  toggleButton: function (e) {
+    this.$(e.target).toggleClass('active');
+  },
+  close: function () {
+    window.close();
+  },
+  updateClose: function (e) {
+    this.$('#cbTrack').prop('disabled', e.target.checked);
+  },
+  showMessage: function (msg) {
+    this.$('#msg').html(msg);
+  },
+  getFile: function (url, isBlob) {
+    var xhr = new XMLHttpRequest;
+    xhr.open('GET', url, true);
+    if (isBlob) xhr.responseType = 'blob';
+    return new Promise(function (resolve, reject) {
+      xhr.onload = function () {
+        if (isBlob) {
+          var reader = new FileReader;
+          reader.onload = function (e) {
+            resolve(window.btoa(this.result));
+          };
+          reader.readAsBinaryString(this.response);
+        } else {
+          resolve(xhr.responseText);
+        }
+      };
+      xhr.onerror = function () {
+        reject(url);
+      };
+      xhr.send();
+    });
+  },
+  getScript: function (url) {
+    var _this = this;
+    var xhr = new XMLHttpRequest;
+    xhr.open('GET', url, true);
+    return new Promise(function (resolve, reject) {
+      xhr.onload = function () {
+        resolve(this);
+      };
+      xhr.onerror = function () {
+        _this.showMessage(_.i18n('msgErrorLoadingData'));
+        reject(this);
+      };
+      xhr.send();
+    });
+  },
+  getTimeString: function () {
+    var now = new Date();
+    return _.zfill(now.getHours(), 2) + ':' +
+      _.zfill(now.getMinutes(), 2) + ':' +
+      _.zfill(now.getSeconds(), 2);
+  },
+  installScript: function () {
+    var _this = this;
+    _this.$('#btnInstall').prop('disabled', true);
+    _.sendMessage({
+      cmd:'ParseScript',
+      data:{
+        url: _this.url,
+        from: _this.from,
+        code: _this.data.code,
+        require: _this.data.require,
+        resources: _this.data.resources,
+      },
+    }).then(function (res) {
+      _this.showMessage(res.message + '[' + _this.getTimeString() + ']');
+      if (res.code < 0) return;
+      if (_.options.get('closeAfterInstall')) _this.close();
+      else if (_this.data.isLocal && _.options.get('trackLocalFile')) _this.trackLocalFile();
+    });
+  },
+  trackLocalFile: function () {
+    var _this = this;
+    setTimeout(function () {
+      var code = _this.data.code;
+      _this.loadData().then(function () {
+        var track = _.options.get('trackLocalFile');
+        if (!track) return;
+        if (_this.data.code != code)
+          _this.parseMeta().then(function () {
+            track && _this.installScript();
+          });
+        else
+          _this.trackLocalFile();
+      });
+    }, 2000);
+  },
+});

+ 21 - 0
src/options/views/edit-meta.js

@@ -0,0 +1,21 @@
+var MetaView = BaseView.extend({
+  className: 'button-panel',
+  templateUrl: '/options/templates/edit-meta.html',
+  events: {
+    'change [data-id]': 'onChange',
+  },
+  render: function () {
+    var model = this.model;
+    var it = model.toJSON();
+    it.__name = model.meta.name;
+    it.__homepageURL = model.meta.homepageURL;
+    it.__updateURL = model.meta.updateURL || _.i18n('hintUseDownloadURL');
+    it.__downloadURL = model.meta.downloadURL || it.lastInstallURL;
+    this.$el.html(this.templateFn(it));
+  },
+  onChange: function (e) {
+    e.stopPropagation();
+    var res = this.getValue(e.target);
+    this.model.set(res.key, res.value);
+  },
+});

+ 94 - 0
src/options/views/edit.js

@@ -0,0 +1,94 @@
+var EditView = BaseView.extend({
+  className: 'frame edit',
+  templateUrl: '/options/templates/edit.html',
+  events: {
+    'click .button-toggle': 'toggleButton',
+    'change [data-id]': 'updateCheckbox',
+    'click #editorSave': 'save',
+    'click #editorClose': 'close',
+    'click #editorSaveClose': 'saveClose',
+  },
+  initialize: function () {
+    var _this = this;
+    BaseView.prototype.initialize.call(_this);
+    _this.metaModel = new Meta(_this.model.toJSON(), {parse: true});
+    _this.listenTo(_this.metaModel, 'change', function (model) {
+      _this.model.set('custom', model.toJSON());
+    });
+    _this.listenTo(_this.model, 'change', function (model) {
+      _this.updateStatus(true);
+    });
+  },
+  render: function () {
+    var _this = this;
+    var it = _this.model.toJSON();
+    _this.$el.html(_this.templateFn(it));
+    var gotScript = it.id ? _.sendMessage({
+      cmd: 'GetScript',
+      data: it.id,
+    }) : Promise.resolve(it);
+    _this.loadedEditor = _.initEditor({
+      container: _this.$('.editor-code')[0],
+      onsave: _this.save.bind(_this),
+      onexit: _this.close,
+      onchange: function (e) {
+        _this.model.set('code', _this.editor.getValue());
+      },
+    });
+    Promise.all([
+      gotScript,
+      _this.loadedEditor,
+    ]).then(function (res) {
+      var script = res[0];
+      var editor = _this.editor = res[1];
+      editor.setValueAndFocus(script.code);
+      editor.clearHistory();
+      _this.updateStatus(false);
+    });
+  },
+  updateStatus: function (changed) {
+    this.changed = changed;
+    this.$('#editorSave').prop('disabled', !changed);
+    this.$('#editorSaveClose').prop('disabled', !changed);
+  },
+  save: function () {
+    var _this = this;
+    var data = _this.model.toJSON();
+    return _.sendMessage({
+      cmd: 'ParseScript',
+      data: {
+        id: data.id,
+        code: data.code,
+        message: '',
+        more: {
+          custom: data.custom,
+          update: data.update,
+        }
+      }
+    }).then(function () {
+      _this.updateStatus(false);
+    });
+  },
+  close: function () {
+    if (!this.changed || confirm(_.i18n('confirmNotSaved')))
+      scriptList.trigger('edit:close');
+  },
+  saveClose: function () {
+    this.save().then(this.close.bind(this));
+  },
+  toggleButton: function (e) {
+    if (this.metaView) {
+      this.$(e.target).removeClass('active');
+      this.metaView.remove();
+      this.metaView = null;
+    } else {
+      this.$(e.target).addClass('active');
+      this.metaView = new MetaView({model: this.metaModel});
+      this.metaView.$el.insertAfter(e.target);
+    }
+  },
+  updateCheckbox: function (e) {
+    var res = this.getValue(e.target);
+    this.model.set(res.key, res.value);
+  },
+});

+ 19 - 0
src/options/views/main.js

@@ -0,0 +1,19 @@
+var MainView = BaseView.extend({
+  el: '#app',
+  templateUrl: '/options/templates/main.html',
+  tabs: {
+    '': MainTab,
+    settings: SettingsTab,
+    about: AboutTab,
+  },
+  initialize: function (tab) {
+    var _this = this;
+    _this.tab = _this.tabs[tab] || _this.tabs[''];
+    BaseView.prototype.initialize.call(_this);
+  },
+  render: function () {
+    this.$el.html(this.templateFn({tab: this.tab.prototype.name}));
+    this.view = new this.tab;
+    return this;
+  },
+});

+ 242 - 0
src/options/views/script.js

@@ -0,0 +1,242 @@
+var DEFAULT_ICON = '/images/icon48.png';
+var ScriptView = BaseView.extend({
+  className: 'script',
+  attributes: {
+    draggable: true,
+  },
+  templateUrl: '/options/templates/script.html',
+  events: {
+    'click [data-id=edit]': 'onEdit',
+    'click [data-id=remove]': 'onRemove',
+    'click [data-id=enable]': 'onEnable',
+    'click [data-id=update]': 'onUpdate',
+    'dragstart': 'onDragStart',
+  },
+  initialize: function () {
+    var _this = this;
+    _this.model.set('_icon', DEFAULT_ICON);
+    // MUST call `super` before `render`
+    BaseView.prototype.initialize.call(_this);
+    _this.listenTo(_this.model, 'change', _this.render);
+    _this.listenTo(_this.model, 'remove', _this.onRemoved);
+  },
+  loadIcon: function () {
+    var _this = this;
+    var icon = _this.model.get('meta').icon;
+    if (icon && icon !== _this.model.get('_icon'))
+      _this.loadImage(icon).then(function (url) {
+        _this.model.set('_icon', url);
+      }, function (url) {
+        _this.model.set('_icon', DEFAULT_ICON);
+      });
+  },
+  render: function () {
+    var _this = this;
+    var model = _this.model;
+    var it = model.toJSON();
+    it.getLocaleString = model.getLocaleString.bind(model);
+    it.canUpdate = model.canUpdate();
+    it.homepageURL = it.custom.homepageURL || it.meta.homepageURL || it.meta.homepage;
+    it.author = _this.getAuthor(it.meta.author);
+    _this.$el.html(_this.templateFn(it));
+    if (!it.enabled) _this.$el.addClass('disabled');
+    else _this.$el.removeClass('disabled');
+    _this.loadIcon();
+    return _this;
+  },
+  getAuthor: function (text) {
+    if (!text) return '';
+    var matches = text.match(/^(.*?)\s<(\S*?@\S*?)>$/);
+    var label = _.i18n('labelAuthor');
+    return matches
+      ? label + '<a href=mailto:' + matches[2] + '>' + matches[1] + '</a>'
+      : label + _.escape(text);
+  },
+  images: {},
+  loadImage: function (url) {
+    if (!url) return;
+    var promise = this.images[url];
+    if (!promise) {
+      var cache = scriptList.cache[url];
+      promise = cache ? Promise.resolve(cache)
+      : new Promise(function (resolve, reject) {
+        var img = new Image;
+        img.onload = function () {
+          resolve(url);
+        };
+        img.onerror = function () {
+          reject(url);
+        };
+        img.src = url;
+      });
+      this.images[url] = promise;
+    }
+    return promise;
+  },
+  onEdit: function () {
+    scriptList.trigger('edit:open', this.model);
+  },
+  onRemove: function () {
+    var _this = this;
+    _.sendMessage({
+      cmd: 'RemoveScript',
+      data: _this.model.id,
+    }).then(function () {
+      scriptList.remove(_this.model);
+    });
+  },
+  onRemoved: function () {
+    this.$el.remove();
+  },
+  onEnable: function () {
+    var _this = this;
+    _.sendMessage({
+      cmd: 'UpdateScriptInfo',
+      data: {
+        id: _this.model.id,
+        enabled: _this.model.get('enabled') ? 0 : 1,
+      },
+    });
+  },
+  onUpdate: function () {
+    _.sendMessage({
+      cmd: 'CheckUpdate',
+      data: this.model.id,
+    });
+  },
+  onDragStart: function (e) {
+    var model = this.model;
+    new DND(e, function (data) {
+      if (data.from === data.to) return;
+      _.sendMessage({
+        cmd: 'Move',
+        data: {
+          id: model.id,
+          offset: data.to - data.from,
+        }
+      }).then(function () {
+        var collection = model.collection;
+        var models = collection.models;
+        var i = Math.min(data.from, data.to);
+        var j = Math.max(data.from, data.to);
+        var seq = [
+          models.slice(0, i),
+          models.slice(i, j + 1),
+          models.slice(j + 1),
+        ];
+        i === data.to
+        ? seq[1].unshift(seq[1].pop())
+        : seq[1].push(seq[1].shift());
+        collection.models = seq.concat.apply([], seq);
+      });
+    });
+  },
+});
+
+function DND(e, cb) {
+  this.mousemove = this.mousemove.bind(this);
+  this.mouseup = this.mouseup.bind(this);
+  if (e) {
+    e.preventDefault();
+    this.start(e);
+  }
+  this.onDrop = cb;
+}
+DND.prototype.start = function (e) {
+  var dragging = this.dragging = {
+    el: e.currentTarget,
+  };
+  var $el = dragging.$el = $(dragging.el);
+  var parent = $el.parent();
+  var offset = $el.offset();
+  dragging.offset = {
+    x: e.clientX - offset.left,
+    y: e.clientY - offset.top,
+  };
+  var next = $el.next();
+  dragging.delta = (next.length ? next.offset().top : parent.height()) - offset.top;
+  var children = parent.children();
+  dragging.lastIndex = dragging.index = children.index($el);
+  dragging.$elements = children.not($el);
+  dragging.$dragged = $el.clone().addClass('dragging').css({
+    left: offset.left,
+    top: offset.top,
+    width: offset.width,
+  }).appendTo(parent);
+  $el.addClass('dragging-placeholder');
+  $(document).on('mousemove', this.mousemove).on('mouseup', this.mouseup);
+};
+DND.prototype.mousemove = function (e) {
+  var dragging = this.dragging;
+  dragging.$dragged.css({
+    left: e.clientX - dragging.offset.x,
+    top: e.clientY - dragging.offset.y,
+  });
+  var hovered = null;
+  dragging.$elements.each(function (i, el) {
+    var $el = $(el);
+    if ($el.hasClass('dragging-moving')) return;
+    var offset = $el.offset();
+    var pad = 10;
+    if (
+      e.clientX >= offset.left + pad
+      && e.clientX <= offset.left + offset.width - pad
+      && e.clientY >= offset.top + pad
+      && e.clientY <= offset.top + offset.height - pad
+    ) {
+      hovered = {
+        index: i,
+        el: el,
+      };
+      return false;
+    }
+  });
+  if (hovered) {
+    var lastIndex = dragging.lastIndex;
+    var index = hovered.index;
+    var isDown = index >= lastIndex;
+    var $el = dragging.$el;
+    var delta = dragging.delta;
+    if (isDown) {
+      // If moving down, the actual index should be `index + 1`
+      index ++;
+      $el.insertAfter(hovered.el);
+    } else {
+      delta = -delta;
+      $el.insertBefore(hovered.el);
+    }
+    dragging.lastIndex = index;
+    this.animate(dragging.$elements.slice(
+      isDown ? lastIndex : index,
+      isDown ? index : lastIndex
+    ), delta);
+  }
+};
+DND.prototype.animate = function ($elements, delta) {
+  $elements.each(function (i, el) {
+    var $el = $(el);
+    $el.addClass('dragging-moving').css({
+      transition: 'none',
+      transform: 'translateY(' + delta + 'px)',
+    }).one('transitionend', function (e) {
+      $(e.target).removeClass('dragging-moving');
+    });
+    setTimeout(function () {
+      $el.css({
+        transition: '',
+        transform: '',
+      });
+    }, 20);
+  });
+};
+DND.prototype.mouseup = function (e) {
+  $(document).off('mousemove', this.mousemove).off('mouseup', this.mouseup);
+  var dragging = this.dragging;
+  dragging.$dragged.remove();
+  dragging.$el.removeClass('dragging-placeholder');
+  this.dragging = null;
+  this.onDrop && this.onDrop({
+    from: dragging.index,
+    to: dragging.lastIndex,
+  });
+};

+ 9 - 0
src/options/views/tab-about.js

@@ -0,0 +1,9 @@
+var AboutTab = BaseView.extend({
+  el: '#tab',
+  name: 'about',
+  templateUrl: '/options/templates/tab-about.html',
+  render: function () {
+    this.$el.html(this.templateFn());
+    return this;
+  },
+});

+ 64 - 0
src/options/views/tab-installed.js

@@ -0,0 +1,64 @@
+var MainTab = BaseView.extend({
+  el: '#tab',
+  name: 'main',
+  templateUrl: '/options/templates/tab-installed.html',
+  events: {
+    'click #bNew': 'newScript',
+    'click #bUpdate': 'updateAll',
+  },
+  initialize: function () {
+    var _this = this;
+    BaseView.prototype.initialize.call(_this);
+    _this.listenTo(scriptList, 'reset', _this.render);
+    _this.listenTo(scriptList, 'add', _this.addOne);
+    _this.listenTo(scriptList, 'add update', _this.setBackdrop);
+    _this.listenTo(scriptList, 'edit:open', function (model) {
+      _this.closeEdit();
+      _this.editView = new EditView({model: model.clone()});
+      _this.$el.append(_this.editView.$el);
+    });
+    _this.listenTo(scriptList, 'edit:close', _this.closeEdit);
+  },
+  closeEdit: function () {
+    var _this = this;
+    if (_this.editView) {
+      _this.editView.remove();
+      _this.editView = null;
+    }
+  },
+  render: function () {
+    this.$el.html(this.templateFn());
+    this.$list = this.$('.scripts');
+    this.$bd = this.$('.backdrop');
+    this.$bdm = this.$('.backdrop > div');
+    this.setBackdrop();
+    this.addAll();
+    return this;
+  },
+  setBackdrop: function () {
+    if (scriptList.loading) {
+      this.$bd.addClass('mask').show();
+      this.$bdm.html(_.i18n('msgLoading'));
+    } else if (!scriptList.length) {
+      this.$bd.removeClass('mask').show();
+      this.$bdm.html(_.i18n('labelNoScripts'));
+    } else {
+      this.$bd.hide();
+    }
+  },
+  addOne: function (script) {
+    var view = new ScriptView({model: script});
+    this.$list.append(view.$el);
+  },
+  addAll: function () {
+    scriptList.forEach(this.addOne, this);
+  },
+  newScript: function () {
+    _.sendMessage({cmd: 'NewScript'}).then(function (script) {
+      scriptList.trigger('edit:open', new Script(script));
+    });
+  },
+  updateAll: function () {
+    _.sendMessage({cmd: 'CheckUpdateAll'});
+  },
+});

+ 245 - 0
src/options/views/tab-settings.js

@@ -0,0 +1,245 @@
+var ExportList = BaseView.extend({
+  el: '.export-list',
+  templateUrl: '/options/templates/option.html',
+  initialize: function () {
+    BaseView.prototype.initialize.call(this);
+    this.listenTo(scriptList, 'reset change', this.render);
+  },
+  render: function () {
+    var _this = this;
+    _this.$el.html(scriptList.map(function (script) {
+      return _this.templateFn(script.toJSON());
+    }).join(''));
+    return _this;
+  },
+  getSelected: function () {
+    var selected = [];
+    this.$('option').each(function (i, option) {
+      if (option.selected) selected.push(scriptList.at(i));
+    });
+    return selected;
+  },
+  toggleAll: function () {
+    var options = this.$('option');
+    var select = _.some(options, function (option) {
+      return !option.selected;
+    });
+    options.each(function (i, option) {
+      option.selected = select;
+    });
+  },
+});
+
+var SettingsTab = BaseView.extend({
+  el: '#tab',
+  name: 'settings',
+  events: {
+    'change [data-check]': 'updateCheckbox',
+    'change #sInjectMode': 'updateInjectMode',
+    'change #cUpdate': 'updateAutoUpdate',
+    'click #bSelect': 'toggleSelection',
+    'click #bImport': 'importFile',
+    'click #bExport': 'exportData',
+    'click #bVacuum': 'onVacuum',
+  },
+  templateUrl: '/options/templates/tab-settings.html',
+  render: function () {
+    var options = _.options.getAll();
+    this.$el.html(this.templateFn(options));
+    this.$('#sInjectMode').val(options.injectMode);
+    this.updateInjectHint();
+    this.exportList = new ExportList;
+    return this;
+  },
+  updateCheckbox: _.updateCheckbox,
+  updateAutoUpdate: function (e) {
+    _.sendMessage({cmd: 'AutoUpdate'});
+  },
+  updateInjectHint: function () {
+    this.$('#sInjectMode+span').text([
+      _.i18n('hintInjectModeNormal'),
+      _.i18n('hintInjectModeAdvanced'),
+    ][this.$('#sInjectMode').val()]);
+  },
+  updateInjectMode: function (e) {
+    _.options.set('injectMode', e.target.value);
+    this.updateInjectHint();
+  },
+  toggleSelection: function () {
+    this.exportList.toggleAll();
+  },
+  importData: function (file) {
+    function getVMConfig(text) {
+      var vm;
+      try {
+        vm = JSON.parse(text);
+      } catch (e) {
+        console.log('Error parsing ViolentMonkey configuration.');
+      }
+      vm = vm || {};
+      _.forEach(vm.values, function (value, key) {
+        _.sendMessage({
+          cmd: 'SetValue',
+          data: {
+            url: key,
+            values: value,
+          }
+        });
+      });
+      _.forEach(vm.settings, function (value, key) {
+        _.options.set(key, value);
+      });
+      return vm;
+    }
+    function getVMFile(entry, vm) {
+      if (!entry.filename.endsWith('.user.js')) return;
+      vm = vm || {};
+      return new Promise(function (resolve, reject) {
+        var writer = new zip.TextWriter;
+        entry.getData(writer, function (text) {
+          var script = {code: text};
+          if (vm.scripts) {
+            var more = vm.scripts[entry.filename.slice(0, -8)];
+            if (more) script.more = _.omit(more, ['id']);
+          }
+          _.sendMessage({
+            cmd: 'ParseScript',
+            data: script,
+          }).then(function () {
+            resolve(true);
+          });
+        });
+      });
+    }
+    function getVMFiles(entries) {
+      var i = entries.findIndex(function (entry) {
+        return entry.filename === 'ViolentMonkey';
+      });
+      if (~i) return new Promise(function (resolve, reject) {
+        var writer = new zip.TextWriter;
+        entries[i].getData(writer, function (text) {
+          entries.splice(i, 1);
+          resolve({
+            vm: getVMConfig(text),
+            entries: entries,
+          });
+        });
+      });
+      return {
+        entries: entries,
+      };
+    }
+    function readZip(file) {
+      return new Promise(function (resolve, reject) {
+        zip.createReader(new zip.BlobReader(file), function (res) {
+          res.getEntries(function (entries) {
+            resolve(entries);
+          });
+        }, function (err) {reject(err);});
+      });
+    }
+    readZip(file).then(getVMFiles).then(function (data) {
+      var vm = data.vm;
+      var entries = data.entries;
+      return Promise.all(entries.map(function (entry) {
+        return getVMFile(entry, vm);
+      })).then(function (res) {
+        return _.filter(res).length;
+      });
+    }).then(function (count) {
+      scriptList.reload();
+      alert(_.i18n('msgImported', [count]));
+    });
+  },
+  importFile: function () {
+    var _this = this;
+    $('<input type=file accept=".zip">')
+    .change(function (e) {
+      if (this.files && this.files.length)
+        _this.importData(this.files[0]);
+    })
+    .trigger('click');
+  },
+  exportData: function () {
+    function getWriter() {
+      return new Promise(function (resolve, reject) {
+        zip.createWriter(new zip.BlobWriter, function (writer) {
+          resolve(writer);
+        });
+      });
+    }
+    function addFile(writer, file) {
+      return new Promise(function (resolve, reject) {
+        writer.add(file.name, new zip.TextReader(file.content), function () {
+          resolve(writer);
+        });
+      });
+    }
+    function download(writer) {
+      return new Promise(function (resolve, reject) {
+        writer.close(function (blob) {
+          var url = URL.createObjectURL(blob);
+          $('<a>').attr({
+            href: url,
+            download: 'scripts.zip',
+          }).trigger('click');
+          URL.revokeObjectURL(url);
+          resolve();
+        });
+      });
+    }
+    var bExport = this.$('#bExport');
+    bExport.prop('disabled', true);
+    var selected = this.exportList.getSelected();
+    if (!selected.length) return;
+    var withValues = this.$('#cbValues').prop('checked');
+    _.sendMessage({
+      cmd: 'ExportZip',
+      data: {
+        values: withValues,
+        ids: _.pluck(selected, 'id'),
+      }
+    }).then(function (data) {
+      var names = {};
+      var vm = {
+        scripts: {},
+        settings: _.options.getAll(),
+      };
+      if (withValues) vm.values = {};
+      var files = data.scripts.map(function (script) {
+        var name = script.custom.name || script.meta.name || 'Noname';
+        if (names[name]) name += '_' + (++ names[name]);
+        else names[name] = 1;
+        vm.scripts[name] = _.pick(script, ['id', 'custom', 'enabled', 'update']);
+        if (withValues) {
+          var values = data.values[script.uri];
+          if (values) vm.values[script.uri] = values;
+        }
+        return {
+          name: name + '.user.js',
+          content: script.code,
+        };
+      });
+      files.push({
+        name: 'ViolentMonkey',
+        content: JSON.stringify(vm),
+      });
+      return files;
+    }).then(function (files) {
+      return files.reduce(function (result, file) {
+        return result.then(function (writer) {
+          return addFile(writer, file);
+        });
+      }, getWriter()).then(download);
+    }).then(function () {
+      bExport.prop('disabled', false);
+    });
+  },
+  onVacuum: function (e) {
+    var button = $(e.target);
+    button.prop('disabled', true).html(_.i18n('buttonVacuuming'));
+    _.sendMessage({cmd: 'Vacuum'}).then(function () {
+      button.html(_.i18n('buttonVacuumed'));
+    });
+  },
+});

+ 0 - 2
src/popup/index.html

@@ -10,8 +10,6 @@
     <script src="/lib/backbone-min.js"></script>
 		<script src="/common.js"></script>
     <script src="/cache.js"></script>
-    <script src="model.js"></script>
-		<script src="view.js"></script>
 	</head>
   <body>
     <div id="popup"></div>

+ 9 - 0
src/popup/views/base.js

@@ -0,0 +1,9 @@
+var MenuBaseView = BaseView.extend({
+  el: '#popup',
+  templateUrl: '/popup/templates/menu.html',
+  addMenuItem: function (obj, parent) {
+    if (!(obj instanceof MenuItem)) obj = new MenuItem(obj);
+    var item = new MenuItemView({model: obj});
+    parent.append(item.$el);
+  },
+});

+ 27 - 0
src/popup/views/command.js

@@ -0,0 +1,27 @@
+var CommandsView = MenuBaseView.extend({
+  initialize: function () {
+    MenuBaseView.prototype.initialize.call(this);
+    this.listenTo(commandsMenu, 'reset', this.render);
+  },
+  render: function () {
+    if (!commandsMenu.length)
+      return app.navigate('', {trigger: true, replace: true});
+    var _this = this;
+    _this.$el.html(_this.templateFn({
+      hasSep: true
+    }));
+    var children = _this.$el.children();
+    var top = children.first();
+    var bot = children.last();
+    _this.addMenuItem({
+      name: _.i18n('menuBack'),
+      symbol: 'fa-arrow-left',
+      onClick: function (e) {
+        app.navigate('', {trigger: true});
+      },
+    }, top);
+    commandsMenu.each(function (item) {
+      _this.addMenuItem(item, bot);
+    });
+  },
+});

+ 24 - 0
src/popup/views/item.js

@@ -0,0 +1,24 @@
+var MenuItemView = BaseView.extend({
+  className: 'menu-item',
+  templateUrl: '/popup/templates/menuitem.html',
+  events: {
+    'click': 'onClick',
+  },
+  initialize: function () {
+    BaseView.prototype.initialize.call(this);
+    this.listenTo(this.model, 'change', this.render);
+  },
+  render: function () {
+    var it = this.model.toJSON();
+    if (typeof it.symbol === 'function')
+      it.symbol = it.symbol(it.data);
+    this.$el.html(this.templateFn(it))
+    .attr('title', it.title === true ? it.name : it.title);
+    if (it.data === false) this.$el.addClass('disabled');
+    else this.$el.removeClass('disabled');
+  },
+  onClick: function (e) {
+    var onClick = this.model.get('onClick');
+    onClick && onClick(e, this.model);
+  },
+})

+ 0 - 63
src/popup/view.js → src/popup/views/menu.js

@@ -1,38 +1,3 @@
-var MenuItemView = BaseView.extend({
-  className: 'menu-item',
-  templateUrl: '/popup/templates/menuitem.html',
-  events: {
-    'click': 'onClick',
-  },
-  initialize: function () {
-    BaseView.prototype.initialize.call(this);
-    this.listenTo(this.model, 'change', this.render);
-  },
-  render: function () {
-    var it = this.model.toJSON();
-    if (typeof it.symbol === 'function')
-      it.symbol = it.symbol(it.data);
-    this.$el.html(this.templateFn(it))
-    .attr('title', it.title === true ? it.name : it.title);
-    if (it.data === false) this.$el.addClass('disabled');
-    else this.$el.removeClass('disabled');
-  },
-  onClick: function (e) {
-    var onClick = this.model.get('onClick');
-    onClick && onClick(e, this.model);
-  },
-})
-
-var MenuBaseView = BaseView.extend({
-  el: '#popup',
-  templateUrl: '/popup/templates/menu.html',
-  addMenuItem: function (obj, parent) {
-    if (!(obj instanceof MenuItem)) obj = new MenuItem(obj);
-    var item = new MenuItemView({model: obj});
-    parent.append(item.$el);
-  },
-});
-
 var MenuView = MenuBaseView.extend({
   initialize: function () {
     MenuBaseView.prototype.initialize.call(this);
@@ -99,31 +64,3 @@ var MenuView = MenuBaseView.extend({
     });
   },
 });
-
-var CommandsView = MenuBaseView.extend({
-  initialize: function () {
-    MenuBaseView.prototype.initialize.call(this);
-    this.listenTo(commandsMenu, 'reset', this.render);
-  },
-  render: function () {
-    if (!commandsMenu.length)
-      return app.navigate('', {trigger: true, replace: true});
-    var _this = this;
-    _this.$el.html(_this.templateFn({
-      hasSep: true
-    }));
-    var children = _this.$el.children();
-    var top = children.first();
-    var bot = children.last();
-    _this.addMenuItem({
-      name: _.i18n('menuBack'),
-      symbol: 'fa-arrow-left',
-      onClick: function (e) {
-        app.navigate('', {trigger: true});
-      },
-    }, top);
-    commandsMenu.each(function (item) {
-      _this.addMenuItem(item, bot);
-    });
-  },
-});