Put a shortlog on the repository index page

The repo index page now shows an abbreviated list of branches and tags
along the left-hand side, with the main content area taken up by a
shortlog of HEAD. Since the ref list is now abbreviated, provide a new
/+refs page that shows the unabbreviated ref list. (This unabbreviated
list is still a bit ugly but ends up looking a lot like the old repo
index page.)

Change-Id: Ie72690d5e9b8162f68818782bfdaa568ad319983
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
index f463445..73bc426 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
@@ -224,6 +224,8 @@
         return new HostIndexServlet(renderer, urls, accessFactory);
       case REPOSITORY_INDEX:
         return new RepositoryIndexServlet(renderer, accessFactory, timeCache);
+      case REFS:
+        return new RefServlet(renderer, timeCache);
       case REVISION:
         return new RevisionServlet(renderer, linkifier());
       case PATH:
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
index e84bb2a..d5ac93b 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -51,6 +51,7 @@
   public static enum Type {
     HOST_INDEX,
     REPOSITORY_INDEX,
+    REFS,
     REVISION,
     PATH,
     DIFF,
@@ -88,6 +89,7 @@
         case REVISION:
           revision = other.revision;
           // Fallthrough.
+        case REFS:
         case REPOSITORY_INDEX:
           repositoryName = other.repositoryName;
           // Fallthrough.
@@ -139,6 +141,7 @@
       switch (type) {
         case HOST_INDEX:
         case REPOSITORY_INDEX:
+        case REFS:
           throw new IllegalStateException(String.format("cannot set revision on %s view", type));
         default:
           this.revision = checkNotNull(revision);
@@ -244,6 +247,9 @@
         case REPOSITORY_INDEX:
           checkRepositoryIndex();
           break;
+        case REFS:
+          checkRefs();
+          break;
         case REVISION:
           checkRevision();
           break;
@@ -275,6 +281,10 @@
       checkHostIndex();
     }
 
+    private void checkRefs() {
+      checkRepositoryIndex();
+    }
+
     private void checkRevision() {
       checkState(revision != Revision.NULL, "missing revision on %s view", type);
       checkRepositoryIndex();
@@ -302,6 +312,10 @@
     return new Builder(Type.REPOSITORY_INDEX);
   }
 
+  public static Builder refs() {
+    return new Builder(Type.REFS);
+  }
+
   public static Builder revision() {
     return new Builder(Type.REVISION);
   }
@@ -426,6 +440,9 @@
       case REPOSITORY_INDEX:
         url.append(repositoryName).append('/');
         break;
+      case REFS:
+        url.append(repositoryName).append("/+refs");
+        break;
       case REVISION:
         url.append(repositoryName).append("/+/").append(revision.getName());
         break;
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/LogSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/LogSoyData.java
index dbeb2e9..77bd36a 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/LogSoyData.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/LogSoyData.java
@@ -14,13 +14,9 @@
 
 package com.google.gitiles;
 
-import java.io.IOException;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-import javax.annotation.Nullable;
-import javax.servlet.http.HttpServletRequest;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.gitiles.CommitSoyData.KeySet;
 
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
@@ -29,9 +25,13 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.gitiles.CommitSoyData.KeySet;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nullable;
+import javax.servlet.http.HttpServletRequest;
 
 public class LogSoyData {
   private final HttpServletRequest req;
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RefServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RefServlet.java
new file mode 100644
index 0000000..82c5b46
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RefServlet.java
@@ -0,0 +1,105 @@
+// Copyright 2012 Google Inc. All Rights Reserved.
+//
+// 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 com.google.gitiles;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Ordering;
+import com.google.common.util.concurrent.UncheckedExecutionException;
+
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefComparator;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves an HTML page with all the refs in a repository. */
+public class RefServlet extends BaseServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final TimeCache timeCache;
+
+  protected RefServlet(Renderer renderer, TimeCache timeCache) {
+    super(renderer);
+    this.timeCache = checkNotNull(timeCache, "timeCache");
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    RevWalk walk = new RevWalk(ServletUtils.getRepository(req));
+    List<Map<String, String>> tags;
+    try {
+      tags = getTags(req, timeCache, walk, 0);
+    } finally {
+      walk.release();
+    }
+    render(req, res, "gitiles.refsDetail", ImmutableMap.of(
+        "branches", getBranches(req, 0),
+        "tags", tags));
+  }
+
+  static List<Map<String, String>> getBranches(HttpServletRequest req, int limit)
+      throws IOException {
+    return getRefs(req, Constants.R_HEADS, Ordering.from(RefComparator.INSTANCE), limit);
+  }
+
+  static List<Map<String, String>> getTags(HttpServletRequest req, TimeCache timeCache,
+     RevWalk walk, int limit) throws IOException {
+    return getRefs(req, Constants.R_TAGS, tagComparator(timeCache, walk), limit);
+  }
+
+  private static Ordering<Ref> tagComparator(final TimeCache timeCache, final RevWalk walk) {
+    return Ordering.natural().onResultOf(new Function<Ref, Long>() {
+      @Override
+      public Long apply(Ref ref) {
+        try {
+          return timeCache.getTime(walk, ref.getObjectId());
+        } catch (IOException e) {
+          throw new UncheckedExecutionException(e);
+        }
+      }
+    }).reverse().compound(RefComparator.INSTANCE);
+  }
+
+  private static List<Map<String, String>> getRefs(HttpServletRequest req, String prefix,
+      Ordering<Ref> ordering, int limit) throws IOException {
+    RefDatabase refdb = ServletUtils.getRepository(req).getRefDatabase();
+    Collection<Ref> refs = refdb.getRefs(prefix).values();
+    refs = ordering.leastOf(refs, limit > 0 ? limit + 1 : refs.size());
+    List<Map<String, String>> result = Lists.newArrayListWithCapacity(refs.size());
+
+    for (Ref ref : refs) {
+      String name = ref.getName().substring(prefix.length());
+      boolean needPrefix = !ref.getName().equals(refdb.getRef(name).getName());
+      result.add(ImmutableMap.of(
+          "url", GitilesView.revision().copyFrom(req).setRevision(
+              Revision.unpeeled(needPrefix ? ref.getName() : name, ref.getObjectId())).toUrl(),
+          "name", name));
+    }
+    return result;
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
index dc4f680..aa12869 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
@@ -42,6 +42,7 @@
       "LogDetail.soy",
       "ObjectDetail.soy",
       "PathDetail.soy",
+      "RefList.soy",
       "RevisionDetail.soy",
       "RepositoryIndex.soy");
 
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
index 11f0e9e..a0fea72 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
@@ -17,22 +17,18 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Ordering;
-import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.common.collect.Maps;
 
 import org.eclipse.jgit.http.server.ServletUtils;
 import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefComparator;
-import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 import java.io.IOException;
-import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 
@@ -43,6 +39,9 @@
 public class RepositoryIndexServlet extends BaseServlet {
   private static final long serialVersionUID = 1L;
 
+  static final int REF_LIMIT = 10;
+  private static final int LOG_LIMIT = 20;
+
   private final GitilesAccess.Factory accessFactory;
   private final TimeCache timeCache;
 
@@ -60,49 +59,48 @@
 
   @VisibleForTesting
   Map<String, ?> buildData(HttpServletRequest req) throws IOException {
+    GitilesView view = ViewFilter.getView(req);
+    Repository repo = ServletUtils.getRepository(req);
     RepositoryDescription desc = accessFactory.forRequest(req).getRepositoryDescription();
-    RevWalk walk = new RevWalk(ServletUtils.getRepository(req));
+    RevWalk walk = new RevWalk(repo);
     List<Map<String, String>> tags;
+    Map<String, Object> data;
     try {
-      tags = getRefs(req, Constants.R_TAGS, tagComparator(walk));
+      tags = RefServlet.getTags(req, timeCache, walk, REF_LIMIT);
+      ObjectId headId = repo.resolve(Constants.HEAD);
+      if (headId != null) {
+        RevObject head = walk.parseAny(repo.resolve(Constants.HEAD));
+        if (head.getType() == Constants.OBJ_COMMIT) {
+          walk.reset();
+          walk.markStart((RevCommit) head);
+          data = new LogSoyData(req, repo, view).toSoyData(walk, LOG_LIMIT, "HEAD", null);
+        } else {
+          // TODO(dborowitz): Handle non-commit or missing HEAD?
+          data = Maps.newHashMapWithExpectedSize(6);
+        }
+      } else {
+        data = Maps.newHashMapWithExpectedSize(6);
+      }
     } finally {
       walk.release();
     }
-    return ImmutableMap.of("cloneUrl", desc.cloneUrl,
-        "mirroredFromUrl", Strings.nullToEmpty(desc.mirroredFromUrl),
-        "description", Strings.nullToEmpty(desc.description),
-        "branches", getRefs(req, Constants.R_HEADS, Ordering.from(RefComparator.INSTANCE)),
-        "tags", tags);
-  }
+    List<Map<String, String>> branches = RefServlet.getBranches(req, REF_LIMIT);
 
-  private List<Map<String, String>> getRefs(HttpServletRequest req, String prefix,
-      Ordering<Ref> ordering) throws IOException {
-    RefDatabase refdb = ServletUtils.getRepository(req).getRefDatabase();
-    Collection<Ref> refs = ordering.sortedCopy(refdb.getRefs(prefix).values());
-    List<Map<String, String>> result = Lists.newArrayListWithCapacity(refs.size());
-
-    for (Ref ref : refs) {
-      String name = ref.getName().substring(prefix.length());
-      boolean needPrefix = !ref.getName().equals(refdb.getRef(name).getName());
-      result.add(ImmutableMap.of(
-          "url", GitilesView.revision().copyFrom(req).setRevision(
-              Revision.unpeeled(needPrefix ? ref.getName() : name, ref.getObjectId())).toUrl(),
-          "name", name));
+    data.put("cloneUrl", desc.cloneUrl);
+    data.put("mirroredFromUrl", Strings.nullToEmpty(desc.mirroredFromUrl));
+    data.put("description", Strings.nullToEmpty(desc.description));
+    data.put("branches", trim(branches));
+    if (branches.size() > REF_LIMIT) {
+      data.put("moreBranchesUrl", GitilesView.refs().copyFrom(view).toUrl());
     }
-
-    return result;
+    data.put("tags", trim(tags));
+    if (tags.size() > REF_LIMIT) {
+      data.put("moreTagsUrl", GitilesView.refs().copyFrom(view).toUrl());
+    }
+    return data;
   }
 
-  private Ordering<Ref> tagComparator(final RevWalk walk) {
-    return Ordering.natural().onResultOf(new Function<Ref, Long>() {
-      @Override
-      public Long apply(Ref ref) {
-        try {
-          return timeCache.getTime(walk, ref.getObjectId());
-        } catch (IOException e) {
-          throw new UncheckedExecutionException(e);
-        }
-      }
-    }).reverse().compound(RefComparator.INSTANCE);
+  private static <T> List<T> trim(List<T> list) {
+    return list.size() > REF_LIMIT ? list.subList(0, REF_LIMIT) : list;
   }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
index e7532ab..90cd94a 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
@@ -18,6 +18,9 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.http.server.glue.WrappedRequest;
+
 import java.io.IOException;
 import java.util.Map;
 
@@ -26,9 +29,6 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-import org.eclipse.jgit.http.server.ServletUtils;
-import org.eclipse.jgit.http.server.glue.WrappedRequest;
-
 /** Filter to parse URLs and convert them to {@link GitilesView}s. */
 public class ViewFilter extends AbstractHttpFilter {
   // TODO(dborowitz): Make this public in JGit (or implement getRegexGroup
@@ -41,6 +41,7 @@
   private static final String CMD_AUTO = "+";
   private static final String CMD_DIFF = "+diff";
   private static final String CMD_LOG = "+log";
+  private static final String CMD_REFS = "+refs";
   private static final String CMD_SHOW = "+show";
 
   public static GitilesView getView(HttpServletRequest req) {
@@ -101,10 +102,12 @@
     // Non-path cases.
     if (repoName.isEmpty()) {
       return GitilesView.hostIndex();
+    } else if (command.equals(CMD_REFS) && path.isEmpty()) {
+      return GitilesView.refs().setRepositoryName(repoName);
     } else if (command.isEmpty()) {
       return GitilesView.repositoryIndex().setRepositoryName(repoName);
     } else if (path.isEmpty()) {
-      return null; // Command but no path.
+      return null; // Command that requires a path, but no path.
     }
 
     path = trimLeadingSlash(path);
@@ -128,6 +131,8 @@
       }
     } else if (CMD_DIFF.equals(command)) {
       view = GitilesView.diff().setTreePath(path);
+    } else if (CMD_REFS.equals(command)) {
+      view = GitilesView.repositoryIndex();
     } else {
       return null; // Bad command.
     }
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 d898767..a41b88c 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
@@ -126,15 +126,17 @@
   padding-left: 1em;
 }
 .repository-refs {
-  width: 650px;
-}
-.repository-branches {
   float: left;
-  width: 300px;
+  width: 200px;
+  margin-left: -100%;
 }
-.repository-tags {
-  float: right;
-  width: 350px;
+.repository-shortlog-wrapper {
+  float: left;
+  width: 100%;
+}
+.repository-shortlog {
+  margin-top: 20px;
+  margin-left: 200px;
 }
 .clone-line {
   background-color: #e5ecf9; /* BOX_BACKGROUND */
@@ -144,6 +146,17 @@
   font-size: 9pt;
 }
 
+/* Styles for the ref detail page. */
+
+.refs-branches {
+  float: left;
+  width: 200px;
+}
+.refs-tags {
+  float: left;
+  width: 200px;
+}
+
 
 /* Styles for the object detail templates. */
 
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RefList.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RefList.soy
new file mode 100644
index 0000000..751ec86
--- /dev/null
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RefList.soy
@@ -0,0 +1,68 @@
+// Copyright 2012 Google Inc. All Rights Reserved.
+//
+// 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.
+{namespace gitiles autoescape="contextual"}
+
+
+/**
+ * List of all refs in a repository.
+ *
+ * @param repositoryName name of this repository.
+ * @param? menuEntries menu entries.
+ * @param breadcrumbs breadcrumbs for this page.
+ * @param branches list of branch objects with url and name keys.
+ * @param tags list of tag objects with url and name keys.
+ */
+{template .refsDetail}
+{call .header}
+  {param title: 'Refs' /}
+  {param repositoryName: $repositoryName /}
+  {param menuEntries: $menuEntries /}
+  {param breadcrumbs: $breadcrumbs /}
+{/call}
+
+{if length($branches)}
+  <div class="refs-branches">
+    {call .refList}
+      {param type: 'Branches' /}
+      {param refs: $branches /}
+    {/call}
+  </div>
+{/if}
+
+{if length($tags)}
+  <div class="refs-tags">
+    {call .refList}
+      {param type: 'Tags' /}
+      {param refs: $tags /}
+    {/call}
+  </div>
+{/if}
+
+{call .footer /}
+{/template}
+
+/**
+ * List of a single type of refs
+ *
+ * @param type name of this type of refs, e.g. "Branches"
+ * @param refs list of ref objects with url and name keys
+ */
+{template .refList}
+  <h3>{$type}</h3>
+  <ul>
+  {foreach $ref in $refs}
+    <li><a href="{$ref.url}">{$ref.name}</a></li>
+  {/foreach}
+  </ul>
+{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy
index 9dfe342..83b0304 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy
@@ -22,8 +22,13 @@
  * @param cloneUrl clone URL for this repository.
  * @param description description text of the repository.
  * @param? mirroredFromUrl URL this repository is mirrored from.
- * @param? branches list of branch objects with url and name keys.
- * @param? tags list of tag objects with url and name keys.
+ * @param branches list of branch objects with url and name keys.
+ * @param? moreBranchesUrl URL to show more branches, if necessary.
+ * @param tags list of tag objects with url and name keys.
+ * @param? moreTagsUrl URL to show more branches, if necessary.
+ * @param? nextUrl URL for the next page of log results.
+ * @param? previousUrl URL for the previous page of log results.
+ * @param entries list of log entries; see .logEntry.
  */
 {template .repositoryIndex}
 {call .header}
@@ -52,38 +57,59 @@
     git clone {$cloneUrl}
 </textarea>
 
-<div class="repository-refs">
-  {if $branches and length($branches)}
-    <div class="repository-branches">
-      <h3>Branches</h3>
-      <ul class="branch-list">
-      {foreach $branch in $branches}
-        {call .ref_ data="$branch" /}
-      {/foreach}
-      </ul>
+{if length($entries) and (length($branches) or length($tags))}
+  <div class="repository-shortlog-wrapper">
+    <div class="repository-shortlog">
+      {call .logEntries data="all" /}
     </div>
-  {/if}
+  </div>
 
-  {if $tags and length($tags)}
-    <div class="repository-tags">
-      <h3>Tags</h3>
-      <ul class="branch-list">
-      {foreach $tag in $tags}
-        {call .ref_ data="$tag" /}
-      {/foreach}
-      </ul>
-    </div>
-  {/if}
-</div>
+  <div class="repository-refs">
+    {call .branches_ data="all" /}
+    {call .tags_ data="all" /}
+  </div>
+{elseif length($entries)}
+  {call .logEntries data="all" /}
+{elseif length($branches) or length($tags)}
+  {call .branches_ data="all" /}
+  {call .tags_ data="all" /}
+{/if}
+
 {call .footer /}
 {/template}
 
 /**
- * Detail for a single ref.
+ * List of branches.
  *
- * @param url URL for ref detail page.
- * @param name ref name.
+ * @param? branches list of branch objects with url and name keys.
+ * @param? moreBranchesUrl URL to show more branches, if necessary.
  */
-{template .ref_}
-<li><a href="{$url}">{$name}</a></li>
+{template .branches_ private="true"}
+  {if length($branches)}
+    {call .refList}
+      {param type: 'Branches' /}
+      {param refs: $branches /}
+    {/call}
+    {if $moreBranchesUrl}
+      <a href="{$moreBranchesUrl}">{msg desc="link to view more branches"}More...{/msg}</a>
+    {/if}
+  {/if}
+{/template}
+
+/**
+ * List of tags.
+ *
+ * @param? tags list of branch objects with url and name keys.
+ * @param? moreTagsUrl URL to show more tags, if necessary.
+ */
+{template .tags_ private="true"}
+  {if length($tags)}
+    {call .refList}
+      {param type: 'Tags' /}
+      {param refs: $tags /}
+    {/call}
+    {if $moreTagsUrl}
+      <a href="{$moreTagsUrl}">{msg desc="link to view more tags"}More...{/msg}</a>
+    {/if}
+  {/if}
 {/template}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
index 1862467..b2b435a 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
@@ -117,6 +117,28 @@
         view.getBreadcrumbs());
   }
 
+  public void testRefs() throws Exception {
+    GitilesView view = GitilesView.refs()
+        .copyFrom(HOST)
+        .setRepositoryName("foo/bar")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.REFS, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals("foo/bar", view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertNull(view.getTreePath());
+    assertTrue(HOST.getParameters().isEmpty());
+
+    assertEquals("/b/foo/bar/+refs", view.toUrl());
+    assertEquals(
+        ImmutableList.of(
+            breadcrumb("host", "/b/?format=HTML"),
+            breadcrumb("foo/bar", "/b/foo/bar/")),
+        view.getBreadcrumbs());
+  }
+
   public void testRefWithRevision() throws Exception {
     ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     GitilesView view = GitilesView.revision()
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
index 800bd38..265c9cc 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
@@ -96,6 +96,15 @@
     assertNull(view.getTreePath());
   }
 
+  public void testRefs() throws Exception {
+    GitilesView view = getView("/repo/+refs");
+    assertEquals(Type.REFS, view.getType());
+    assertEquals("repo", view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertNull(view.getTreePath());
+  }
+
   public void testBranches() throws Exception {
     RevCommit master = repo.branch("refs/heads/master").commit().create();
     RevCommit stable = repo.branch("refs/heads/stable").commit().create();