Dynamically load CodeMirror themes

Most users run with the default theme, which does not require any
additional CSS. The total set of themes costs 7.3 KiB of download
when added into the default CSS file.

Split the themes into their own files and only load the theme when
it is required by the browser.

Change-Id: I48f274347e1ca94895c0756fa17479661c78fd57
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Theme.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Theme.java
index 1a665786..436f911 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Theme.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Theme.java
@@ -20,6 +20,7 @@
   ECLIPSE,
   ELEGANT,
   NEAT,
+
   // Dark themes
   MIDNIGHT,
   NIGHT,
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
index 6c2e646..6af93d9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
@@ -56,6 +56,7 @@
 
 import net.codemirror.mode.ModeInfo;
 import net.codemirror.mode.ModeInjector;
+import net.codemirror.theme.ThemeLoader;
 
 /** Displays current diff preferences. */
 class PreferencesBox extends Composite {
@@ -386,18 +387,30 @@
 
   @UiHandler("theme")
   void onTheme(@SuppressWarnings("unused") ChangeEvent e) {
-    prefs.theme(Theme.valueOf(theme.getValue(theme.getSelectedIndex())));
-    view.setThemeStyles(prefs.theme().isDark());
-    view.operation(new Runnable() {
+    final Theme newTheme = getSelectedTheme();
+    prefs.theme(newTheme);
+    ThemeLoader.loadTheme(newTheme, new GerritCallback<Void>() {
       @Override
-      public void run() {
-        String t = prefs.theme().name().toLowerCase();
-        view.getCmFromSide(DisplaySide.A).setOption("theme", t);
-        view.getCmFromSide(DisplaySide.B).setOption("theme", t);
+      public void onSuccess(Void result) {
+        view.operation(new Runnable() {
+          @Override
+          public void run() {
+            if (getSelectedTheme() == newTheme && isAttached()) {
+              String t = newTheme.name().toLowerCase();
+              view.getCmFromSide(DisplaySide.A).setOption("theme", t);
+              view.getCmFromSide(DisplaySide.B).setOption("theme", t);
+              view.setThemeStyles(newTheme.isDark());
+            }
+          }
+        });
       }
     });
   }
 
+  private Theme getSelectedTheme() {
+    return Theme.valueOf(theme.getValue(theme.getSelectedIndex()));
+  }
+
   @UiHandler("apply")
   void onApply(@SuppressWarnings("unused") ClickEvent e) {
     close();
@@ -493,26 +506,8 @@
   }
 
   private void initTheme() {
-    theme.addItem(
-        Theme.DEFAULT.name().toLowerCase(),
-        Theme.DEFAULT.name());
-    theme.addItem(
-        Theme.ECLIPSE.name().toLowerCase(),
-        Theme.ECLIPSE.name());
-    theme.addItem(
-        Theme.ELEGANT.name().toLowerCase(),
-        Theme.ELEGANT.name());
-    theme.addItem(
-        Theme.NEAT.name().toLowerCase(),
-        Theme.NEAT.name());
-    theme.addItem(
-        Theme.MIDNIGHT.name().toLowerCase(),
-        Theme.MIDNIGHT.name());
-    theme.addItem(
-        Theme.NIGHT.name().toLowerCase(),
-        Theme.NIGHT.name());
-    theme.addItem(
-        Theme.TWILIGHT.name().toLowerCase(),
-        Theme.TWILIGHT.name());
+    for (Theme t : Theme.values()) {
+      theme.addItem(t.name().toLowerCase(), t.name());
+    }
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide2.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide2.java
index bf5f8d8..3bdd32b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide2.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide2.java
@@ -78,6 +78,7 @@
 import net.codemirror.lib.Pos;
 import net.codemirror.mode.ModeInfo;
 import net.codemirror.mode.ModeInjector;
+import net.codemirror.theme.ThemeLoader;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -175,7 +176,10 @@
 
     CallbackGroup cmGroup = new CallbackGroup();
     CodeMirror.initLibrary(cmGroup.add(CallbackGroup.<Void> emptyCallback()));
+
     final CallbackGroup group = new CallbackGroup();
+    final AsyncCallback<Void> themeCallback =
+        group.add(CallbackGroup.<Void> emptyCallback());
     final AsyncCallback<Void> modeInjectorCb =
         group.add(CallbackGroup.<Void> emptyCallback());
 
@@ -189,6 +193,9 @@
         public void onSuccess(DiffInfo diffInfo) {
           diff = diffInfo;
           fileSize = bucketFileSize(diffInfo);
+
+          // Load theme after CM library to ensure theme can override CSS.
+          ThemeLoader.loadTheme(prefs.theme(), themeCallback);
           if (prefs.syntaxHighlighting()) {
             if (fileSize.compareTo(FileSize.SMALL) > 0) {
               modeInjectorCb.onSuccess(null);
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/CodeMirror.gwt.xml b/gerrit-gwtui/src/main/java/net/codemirror/CodeMirror.gwt.xml
index 24a0f57..add033f 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/CodeMirror.gwt.xml
+++ b/gerrit-gwtui/src/main/java/net/codemirror/CodeMirror.gwt.xml
@@ -20,4 +20,5 @@
   <source path='lib'/>
   <source path='keymap'/>
   <source path='mode'/>
+  <source path='theme'/>
 </module>
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java b/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java
new file mode 100644
index 0000000..53a2a02
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package net.codemirror.theme;
+
+import com.google.gerrit.extensions.common.Theme;
+import com.google.gwt.dom.client.StyleInjector;
+import com.google.gwt.resources.client.ExternalTextResource;
+import com.google.gwt.resources.client.ResourceCallback;
+import com.google.gwt.resources.client.ResourceException;
+import com.google.gwt.resources.client.TextResource;
+import com.google.gwt.user.client.rpc.AsyncCallback;
+
+import java.util.EnumSet;
+
+/** Dynamically loads a known CodeMirror theme's CSS */
+public class ThemeLoader {
+  private static final ExternalTextResource[] THEMES = {
+      Themes.I.eclipse(),
+      Themes.I.elegant(),
+      Themes.I.midnight(),
+      Themes.I.neat(),
+      Themes.I.night(),
+      Themes.I.twilight(),
+  };
+
+  private static final EnumSet<Theme> loaded = EnumSet.of(Theme.DEFAULT);
+
+  public static final void loadTheme(final Theme theme,
+      final AsyncCallback<Void> cb) {
+    if (loaded.contains(theme)) {
+      cb.onSuccess(null);
+      return;
+    }
+
+    ExternalTextResource resource = findTheme(theme);
+    if (resource == null) {
+      cb.onFailure(new Exception("unknown theme " + theme));
+      return;
+    }
+
+    try {
+      resource.getText(new ResourceCallback<TextResource>() {
+        @Override
+        public void onSuccess(TextResource resource) {
+          StyleInjector.inject(resource.getText());
+          loaded.add(theme);
+          cb.onSuccess(null);
+        }
+
+        @Override
+        public void onError(ResourceException e) {
+          cb.onFailure(e);
+        }
+      });
+    } catch (ResourceException e) {
+      cb.onFailure(e);
+    }
+  }
+
+  private static final ExternalTextResource findTheme(Theme theme) {
+    for (ExternalTextResource r : THEMES) {
+      if (theme.name().toLowerCase().equals(r.getName())) {
+        return r;
+      }
+    }
+    return null;
+  }
+
+  private ThemeLoader() {
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java b/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
new file mode 100644
index 0000000..ed0ffca
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package net.codemirror.theme;
+
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.resources.client.ClientBundle;
+import com.google.gwt.resources.client.ExternalTextResource;
+
+public interface Themes extends ClientBundle {
+  public static final Themes I = GWT.create(Themes.class);
+
+  @Source("eclipse.css") ExternalTextResource eclipse();
+  @Source("elegant.css") ExternalTextResource elegant();
+  @Source("midnight.css") ExternalTextResource midnight();
+  @Source("neat.css") ExternalTextResource neat();
+  @Source("night.css") ExternalTextResource night();
+  @Source("twilight.css") ExternalTextResource twilight();
+
+  // When adding a resource, update:
+  // - static initializer in ThemeLoader
+  // - enum value in com.google.gerrit.extensions.common.Theme
+}
diff --git a/lib/codemirror/BUCK b/lib/codemirror/BUCK
index 8015c5a..a311ab7 100644
--- a/lib/codemirror/BUCK
+++ b/lib/codemirror/BUCK
@@ -19,18 +19,29 @@
 genrule(
   name = 'css',
   cmd = ';'.join([
-      ':>$OUT',
-      "echo '/** @license' >>$OUT",
+      "echo '/** @license' >$OUT",
       'unzip -p $(location :zip) %s/LICENSE >>$OUT' % TOP,
       "echo '*/' >>$OUT",
     ] +
     ['unzip -p $(location :zip) %s/%s >>$OUT' % (TOP, n)
-     for n in CM_CSS + CM_THEMES]
+     for n in CM_CSS]
   ),
-  deps = [':zip'],
   out = 'cm.css',
 )
 
+for n in CM_THEMES:
+  genrule(
+    name = 'theme_%s' % n,
+    cmd = ';'.join([
+        "echo '/** @license' >$OUT",
+        'unzip -p $(location :zip) %s/LICENSE >>$OUT' % TOP,
+        "echo '*/' >>$OUT",
+        'unzip -p $(location :zip) %s/theme/%s.css >>$OUT' % (TOP, n)
+      ]
+    ),
+    out = 'theme_%s.css' % n,
+  )
+
 genrule(
   name = 'cm-verbose',
   cmd = ';'.join([
@@ -42,7 +53,6 @@
     ['unzip -p $(location :zip) %s/addon/%s >>$OUT' % (TOP, n)
      for n in CM_ADDONS]
   ),
-  deps = [':zip'],
   out = 'cm-verbose.js',
 )
 
@@ -62,7 +72,6 @@
       "echo '*/' >>$OUT",
       'unzip -p $(location :zip) %s/mode/%s/%s.js >>$OUT' % (TOP, n, n),
       ]),
-    deps = [':zip'],
     out = 'mode_%s_src.js' %n,
   )
   js_minify(
@@ -86,18 +95,14 @@
   name = 'jar',
   cmd = ';'.join([
     'cd $TMP',
-    'mkdir -p net/codemirror/{lib,mode}',
+    'mkdir -p net/codemirror/{lib,mode,theme}',
     'cp $(location :css) net/codemirror/lib',
     'cp $(location :js) net/codemirror/lib']
     + ['cp $(location :mode_%s_js) net/codemirror/mode/%s.js' % (n, n)
        for n in CM_MODES]
-    + ['zip -qr $OUT net/codemirror/{lib,mode}']),
-  deps = [
-    ':css',
-    ':js',
-    ':js_minifier',
-    ':zip',
-  ] + [':mode_%s_js' % n for n in CM_MODES],
+    + ['cp $(location :theme_%s) net/codemirror/theme/%s.css' % (n, n)
+       for n in CM_THEMES]
+    + ['zip -qr $OUT net/codemirror/{lib,mode,theme}']),
   out = 'codemirror.jar',
 )
 
@@ -107,7 +112,6 @@
     ' -o $OUT' +
     ' -u ' + URL +
     ' -v ' + SHA1,
-  deps = ['//tools:download_file'],
   out = ZIP,
 )
 
diff --git a/lib/codemirror/cm.defs b/lib/codemirror/cm.defs
index 66ed164..538dea8 100644
--- a/lib/codemirror/cm.defs
+++ b/lib/codemirror/cm.defs
@@ -4,15 +4,6 @@
   'addon/scroll/simplescrollbars.css',
 ]
 
-CM_THEMES = [
-  'theme/eclipse.css',
-  'theme/elegant.css',
-  'theme/midnight.css',
-  'theme/neat.css',
-  'theme/night.css',
-  'theme/twilight.css',
-]
-
 CM_JS = [
   'lib/codemirror.js',
   'mode/meta.js',
@@ -30,6 +21,21 @@
   'mode/simple.js',
 ]
 
+# Available themes must be enumerated here,
+# in gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Theme.java,
+# in gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
+CM_THEMES = [
+  'eclipse',
+  'elegant',
+  'midnight',
+  'neat',
+  'night',
+  'twilight',
+]
+
+# Available modes must be enumerated here,
+# in gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java,
+# and in CodeMirror's own mode/meta.js script.
 CM_MODES = [
   'clike',
   'clojure',
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
index 80373ef..39ffdc2 160000
--- a/plugins/cookbook-plugin
+++ b/plugins/cookbook-plugin
@@ -1 +1 @@
-Subproject commit 80373ef2e4c1978b8c9a68a12c05ec05c4063f8a
+Subproject commit 39ffdc20f021484c1ce1dca2ae9f00fa10a482f4