Render blame in a table with server-side syntax highlighting

This is likely the fastest pure HTML layout possible. The main
downside is the lack of multi-line selection, but since this is
available in the default blob view it's unlikely to be missed. The
Chrome team's experience with ViewVC (see Issue 5) supports this.

Change-Id: I9a67373f32c85a12516e92121a7ebdcbe07088ed
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/blame/BlameServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/blame/BlameServlet.java
index 8259a9d..34c9016 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/blame/BlameServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/blame/BlameServlet.java
@@ -15,11 +15,12 @@
 package com.google.gitiles.blame;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.gitiles.BaseServlet;
 import com.google.gitiles.BlobSoyData;
@@ -28,6 +29,8 @@
 import com.google.gitiles.GitilesView;
 import com.google.gitiles.Renderer;
 import com.google.gitiles.ViewFilter;
+import com.google.template.soy.data.SoyListData;
+import com.google.template.soy.data.SoyMapData;
 
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.http.server.ServletUtils;
@@ -76,13 +79,13 @@
 
       String title = "Blame - " + view.getPathPart();
       Map<String, ?> blobData = new BlobSoyData(rw, view).toSoyData(view.getPathPart(), blobId);
-      if (blobData.get("data") != null) {
+      if (blobData.get("lines") != null) {
         List<Region> regions = cache.get(repo, commit, view.getPathPart());
         if (regions.isEmpty()) {
           res.setStatus(SC_NOT_FOUND);
           return;
         }
-        GitDateFormatter df = new GitDateFormatter(Format.DEFAULT);
+        GitDateFormatter df = new GitDateFormatter(Format.ISO);
         renderHtml(req, res, "gitiles.blameDetail", ImmutableMap.of(
             "title", title,
             "breadcrumbs", view.getBreadcrumbs(),
@@ -115,15 +118,28 @@
     }
   }
 
-  private static List<Map<String, ?>> toSoyData(GitilesView view, ObjectReader reader,
+  private static final ImmutableList<String> CLASSES = ImmutableList.of("bg1", "bg2");
+  private static final ImmutableList<SoyMapData> NULLS;
+  static {
+    ImmutableList.Builder<SoyMapData> nulls = ImmutableList.builder();
+    for (String clazz : CLASSES) {
+      nulls.add(new SoyMapData("class", clazz));
+    }
+    NULLS = nulls.build();
+  }
+
+  private static SoyListData toSoyData(GitilesView view, ObjectReader reader,
       List<Region> regions, GitDateFormatter df) throws IOException {
     Map<ObjectId, String> abbrevShas = Maps.newHashMap();
-    List<Map<String, ?>> result = Lists.newArrayListWithCapacity(regions.size());
-    for (Region r : regions) {
+    SoyListData result = new SoyListData();
+
+    for (int i = 0; i < regions.size(); i++) {
+      Region r = regions.get(i);
+      int c = i % CLASSES.size();
       if (r.getSourceCommit() == null) {
         // JGit bug may fail to blame some regions. We should fix this
         // upstream, but handle it for now.
-        result.add(ImmutableMap.of("count", r.getCount()));
+        result.add(NULLS.get(c));
       } else {
         String abbrevSha = abbrevShas.get(r.getSourceCommit());
         if (abbrevSha == null) {
@@ -144,9 +160,16 @@
             .setPathPart(r.getSourcePath())
             .toUrl());
         e.put("author", CommitSoyData.toSoyData(r.getSourceAuthor(), df));
-        e.put("count", r.getCount());
+        e.put("class", CLASSES.get(c));
         result.add(e);
       }
+      // Pad the list with null regions so we can iterate in parallel in the
+      // template. We can't do this by maintaining an index variable into the
+      // regions list because Soy {let} is an unmodifiable alias scoped to a
+      // single block.
+      for (int j = 0; j < r.getCount() - 1; j++) {
+        result.add(NULLS.get(c));
+      }
     }
     return result;
   }
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css b/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
index f3a3a35..d0cc951 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
@@ -340,64 +340,44 @@
   padding: 0;
 }
 #blame td {
+  line-height: 1.4;
   padding: 0;
-}
-#regions {
-  padding-top: 2px;
-  padding-right: 2px;
-  padding-bottom: 5px;
-  padding-left: 2px;
+  font-size: 8pt;
   white-space: nowrap;
-  list-style-type: none;
-
-  /* Matching pre.git-blob below. */
+}
+#blame .sha1, #blame .author, #blame .time {
+   /* TODO(dborowitz): Make 9pt values above more specific. */
+  font-size: 8pt;
+}
+#blame .sha1 {
   font-family: monospace;
-  font-size: 8pt;
-  border-bottom: #ddd solid 1px; /* BORDER */
-}
-#regions ul {
-  list-style-type: none;
-  margin-top: 0;
-  margin-bottom: 0;
-  padding-left: 0;
-}
-#blame .time, #blame .sha1 {
-  /* Smaller than SHORTLOG_SMALL_FONT_SIZE to match pre. */
-  font-size: 8pt;
 }
 #blame .author {
-  padding-left: 0px;
+  padding-left: 3px;
+  padding-right: 3px;
 }
-#blame pre.git-blob {
-  border-top: 0;
+#blame .time {
+  padding-left: 5px;
+  padding-right: 3px;
+}
+#blame .linenum {
+  text-align: right;
+  padding-left: 5px;
+  padding-right: 5px;
+}
+#blame .regionLink {
+  padding-right: 3px;
 }
 
-#regions li {
-  position: relative;
+#blame tr.bg1 {
+  background: #fff;
 }
-#regions li.line:hover {
-  background-color: #eee;
-}
-#regions li .regionMenu {
-  position: absolute;
-  top: 13px;
-  left: 0;
-  visibility: hidden;
-  padding-top: 4px;
-  padding-bottom: 4px;
-  padding-left: 8px;
-  padding-right: 8px;
-
-  background-color: #eee;
-  font-size: 8pt;
-  font-family: arial,sans-serif;
-  z-index: 101;
-}
-#regions li:hover .regionMenu {
-  visibility: visible;
+#blame tr.bg2 {
+  background: #f7f7f7;
 }
 
-/* Override some styles from the default prettify.css. */
+/* Styles for pretty-print regions, including overriding some defaults from
+ * prettify.css. */
 
 ol.prettyprint {
   border-top: #ddd solid 1px; /* BORDER */
@@ -406,10 +386,9 @@
   border-right: none;
   padding-left: 5em;
   padding-bottom: 5px;
+}
+.prettyprint, #blame .linenum {
   font-family: monospace;
   font-size: 8pt;
-  white-space: pre;
-}
-pre.prettyprint ol {
-  color: grey;
+  white-space: pre !important;
 }
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/BlameDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/BlameDetail.soy
index 0118d92..942da68 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/BlameDetail.soy
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/BlameDetail.soy
@@ -22,17 +22,18 @@
  * @param? headerVariant variant name for custom header.
  * @param breadcrumbs breadcrumbs for this page.
  * @param data blob data, matching the params for .blobBox.
- * @param? regions for non-binary files, list of blame regions with the
+ * @param? regions for non-binary files, list of regions, one per line, with the
  *     following keys:
  *       abbrevSha: abbreviated SHA-1 of revision for this line; if missing,
  *           assume blame info is missing.
  *       author: author information with at least "name" and "relativeTime"
  *           keys.
- *       relativeTime: relative time of the revision
- *       count: line count
- *       blameUrl: URL for a blame of this file at this commit
- *       commitUrl: URL for detail about the commit
- *       diffUrl: URL for a diff of this file at this commit
+ *       time: time of the revision.
+ *       blameUrl: URL for a blame of this file at this commit.
+ *       commitUrl: URL for detail about the commit.
+ *       diffUrl: URL for a diff of this file at this commit.
+ *       class: class name for tr.
+ *     All keys but "class" are optional.
  */
 {template .blameDetail}
 {if $regions}
@@ -48,39 +49,37 @@
   {/call}
 
   <table id="blame">
-    <tr>
-      <td>
-        <ul id="regions">
-          {foreach $region in $regions}
-            <li class="line">
-              {if $region.abbrevSha}
-                <a href="{$region.commitUrl}">
-                  <span class="sha1">{$region.abbrevSha}</span>
-                  {sp}<span class="author">{$region.author.name}</span>
-                  {sp}<span class="time">- {$region.author.relativeTime}</span>
-                </a>
-                <div class="regionMenu">
-                  [<a href="{$region.commitUrl}">commit</a>]
-                  {sp}[<a href="{$region.diffUrl}">diff</a>]
-                  {sp}[<a href="{$region.blameUrl}">blame</a>]
-                </div>
-              {else}
-                &nbsp;
-              {/if}
-            </li>
-            {for $i in range($region.count - 1)}
-              <li>&nbsp;</li>
-            {/for}
+    {let $regionIdx: 0 /}
+    {foreach $line in $data.lines}
+      {let $i: index($line) /}
+      {let $region: $regions[index($line)] /}
+      <tr class="{$region.class}">
+        {if isNonnull($region.abbrevSha)}
+          <td class="region">
+            <a href="{$region.commitUrl}">
+              <span class="sha1">{$region.abbrevSha}</span>
+              <span class="author">{$region.author.name}</span>
+            </a>
+          </td>
+          <td class="region">
+            <span class="time">{$region.author.time}</span>
+          </td>
+          <td class="regionLink">
+            [<a href="{$region.diffUrl}">{msg desc="text for diff URL"}diff{/msg}</a>]
+            [<a href="{$region.blameUrl}">{msg desc="text for blame URL"}blame{/msg}</a>]
+          </td>
+        {else}
+          <td colspan="3"></td>
+        {/if}
+        <td class="linenum">{index($line) + 1}.</td>
+        <td class="prettyprint">
+          {foreach $span in $line}
+            <span class="{$span.classes}">{$span.text}</span>
           {/foreach}
-        </ul>
-      </td>
-      <td>
-        {call .blobBox data="$data" /}
-      </td>
-    </tr>
+        </td>
+      </tr>
+    {/foreach}
   </table>
-
-  </div>
 {else}
   {call .header data="all" /}
   {call .blobDetail data="$data" /}