Auto-dive into trees that contain only a single subtree When walking into a tree in PathServlet, keep track of which path components only contained a single subtree entry, and pass this information along to GitilesView.getBreadcrumbs(). This creates URLs with a special sentinel ?stay to disable auto-diving. Bug: https://code.google.com/p/gitiles/issues/detail?id=8 Change-Id: I03bfb8a6e1974cc1c4b5d836c698311705569ff4
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 1d45101..e84bb2a 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -14,9 +14,11 @@ package com.google.gitiles; +import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; import static com.google.gitiles.GitilesUrls.NAME_ESCAPER; +import static com.google.gitiles.RevisionParser.PATH_SPLITTER; import com.google.common.base.Charsets; import com.google.common.base.Objects; @@ -464,7 +466,28 @@ return baseUrl + url.toString(); } + /** + * @return a list of maps with "text" and "url" keys for all file paths + * leading up to the path represented by this view. All URLs allow + * auto-diving into one-entry subtrees; see also + * {@link #getBreadcrumbs(List<Boolean>)}. + */ public List<Map<String, String>> getBreadcrumbs() { + return getBreadcrumbs(null); + } + + /** + * @param hasSingleTree list of booleans, one per path entry in this view's + * path excluding the leaf. True entries indicate the tree at that path + * only has a single entry that is another tree. + * @return a list of maps with "text" and "url" keys for all file paths + * leading up to the path represented by this view. URLs whose + * corresponding entry in {@code hasSingleTree} is true will disable + * auto-diving into one-entry subtrees. + */ + public List<Map<String, String>> getBreadcrumbs(List<Boolean> hasSingleTree) { + checkArgument(hasSingleTree == null || type == Type.PATH, + "hasSingleTree must be null for %s view", type); String path = this.path; ImmutableList.Builder<Map<String, String>> breadcrumbs = ImmutableList.builder(); breadcrumbs.add(breadcrumb(hostName, hostIndex().copyFrom(this))); @@ -492,15 +515,18 @@ breadcrumbs.add(breadcrumb(".", copyWithPath().setTreePath(""))); } StringBuilder cur = new StringBuilder(); - boolean first = true; - for (String part : RevisionParser.PATH_SPLITTER.omitEmptyStrings().split(path)) { - if (!first) { - cur.append('/'); - } else { - first = false; + List<String> parts = ImmutableList.copyOf(PATH_SPLITTER.omitEmptyStrings().split(path)); + checkArgument(hasSingleTree == null || hasSingleTree.size() == parts.size() - 1, + "hasSingleTree has wrong number of entries"); + for (int i = 0; i < parts.size(); i++) { + String part = parts.get(i); + cur.append(part).append('/'); + String curPath = cur.toString(); + Builder builder = copyWithPath().setTreePath(curPath); + if (hasSingleTree != null && i < parts.size() - 1 && hasSingleTree.get(i)) { + builder.replaceParam(PathServlet.AUTODIVE_PARAM, PathServlet.NO_AUTODIVE_VALUE); } - cur.append(part); - breadcrumbs.add(breadcrumb(part, copyWithPath().setTreePath(cur.toString()))); + breadcrumbs.add(breadcrumb(part, builder)); } } return breadcrumbs.build();
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java index 384a589..2594d08 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
@@ -22,12 +22,19 @@ import static org.eclipse.jgit.lib.Constants.OBJ_TREE; import com.google.common.base.Joiner; +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.common.primitives.Bytes; import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.errors.IncorrectObjectTypeException; import org.eclipse.jgit.errors.LargeObjectException; +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.errors.StopWalkException; import org.eclipse.jgit.http.server.ServletUtils; +import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.FileMode; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectLoader; @@ -37,12 +44,15 @@ import org.eclipse.jgit.revwalk.RevTree; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.submodule.SubmoduleWalk; +import org.eclipse.jgit.treewalk.CanonicalTreeParser; import org.eclipse.jgit.treewalk.TreeWalk; +import org.eclipse.jgit.treewalk.filter.TreeFilter; import org.eclipse.jgit.util.RawParseUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.regex.Pattern; @@ -66,6 +76,9 @@ "https?://code.google.com/p/.*", "https?://github.com/.*") + ")$", Pattern.CASE_INSENSITIVE); + static final String AUTODIVE_PARAM = "autodive"; + static final String NO_AUTODIVE_VALUE = "0"; + static enum FileType { TREE(FileMode.TREE), SYMLINK(FileMode.SYMLINK), @@ -116,43 +129,46 @@ return; } - TreeWalk tw; + TreeWalk tw = new TreeWalk(rw.getObjectReader()); + tw.addTree(root); + tw.setRecursive(false); FileType type; - ObjectId treeId = null; String path = view.getTreePath(); + List<Boolean> hasSingleTree; + if (path.isEmpty()) { - tw = new TreeWalk(rw.getObjectReader()); - tw.addTree(root); - tw.setRecursive(false); type = FileType.TREE; - treeId = root; + hasSingleTree = ImmutableList.<Boolean> of(); } else { - tw = TreeWalk.forPath(rw.getObjectReader(), path, root); - if (tw == null) { + hasSingleTree = walkToPath(tw, path); + if (hasSingleTree == null) { res.setStatus(SC_NOT_FOUND); return; } type = FileType.forEntry(tw); - if (type == FileType.TREE) { - treeId = tw.getObjectId(0); - tw.enterSubtree(); - tw.setRecursive(false); - } } switch (type) { case TREE: - showTree(req, res, rw, tw, treeId); + ObjectId treeId; + if (path.isEmpty()) { + treeId = root; + } else { + treeId = tw.getObjectId(0); + tw.enterSubtree(); + tw.setRecursive(false); + } + showTree(req, res, rw, tw, treeId, hasSingleTree); break; case SYMLINK: - showSymlink(req, res, rw, tw); + showSymlink(req, res, rw, tw, hasSingleTree); break; case REGULAR_FILE: case EXECUTABLE_FILE: - showFile(req, res, rw, tw); + showFile(req, res, rw, tw, hasSingleTree); break; case GITLINK: - showGitlink(req, res, rw, tw, root); + showGitlink(req, res, rw, tw, root, hasSingleTree); break; default: log.error("Bad file type: %s", type); @@ -166,28 +182,138 @@ } } + private static class AutoDiveFilter extends TreeFilter { + /** @see GitilesView#getBreadcrumbs(List<Boolean>) */ + List<Boolean> hasSingleTree; + + private final byte[] pathRaw; + private int count; + private boolean done; + + AutoDiveFilter(String pathStr) { + hasSingleTree = Lists.newArrayList(); + pathRaw = Constants.encode(pathStr); + } + + @Override + public boolean include(TreeWalk tw) throws MissingObjectException, + IncorrectObjectTypeException, IOException { + count++; + int cmp = tw.isPathPrefix(pathRaw, pathRaw.length); + if (cmp > 0) { + throw StopWalkException.INSTANCE; + } + boolean include; + if (cmp == 0) { + if (!isDone(tw)) { + hasSingleTree.add(hasSingleTreeEntry(tw)); + } + include = true; + } else { + include = false; + } + if (tw.isSubtree()) { + count = 0; + } + return include; + } + + private boolean hasSingleTreeEntry(TreeWalk tw) throws IOException { + if (count != 1 || !FileMode.TREE.equals(tw.getRawMode(0))) { + return false; + } + CanonicalTreeParser p = new CanonicalTreeParser(); + p.reset(tw.getObjectReader(), tw.getObjectId(0)); + p.next(); + return p.eof(); + } + + @Override + public boolean shouldBeRecursive() { + return Bytes.indexOf(pathRaw, (byte)'/') >= 0; + } + + @Override + public TreeFilter clone() { + return this; + } + + private boolean isDone(TreeWalk tw) { + if (!done) { + done = pathRaw.length == tw.getPathLength(); + } + return done; + } + } + + private List<Boolean> walkToPath(TreeWalk tw, String pathString) throws IOException { + AutoDiveFilter f = new AutoDiveFilter(pathString); + tw.setFilter(f); + while (tw.next()) { + if (f.isDone(tw)) { + return f.hasSingleTree; + } else if (tw.isSubtree()) { + tw.enterSubtree(); + } + } + return null; + } + private void showTree(HttpServletRequest req, HttpServletResponse res, RevWalk rw, TreeWalk tw, - ObjectId id) throws IOException { + ObjectId id, List<Boolean> hasSingleTree) throws IOException { GitilesView view = ViewFilter.getView(req); + List<String> autodive = view.getParameters().get(AUTODIVE_PARAM); + if (autodive.size() != 1 || !NO_AUTODIVE_VALUE.equals(autodive.get(0))) { + byte[] path = Constants.encode(view.getTreePath()); + CanonicalTreeParser child = getOnlyChildSubtree(rw, id, path); + if (child != null) { + while (true) { + path = new byte[child.getEntryPathLength()]; + System.arraycopy(child.getEntryPathBuffer(), 0, path, 0, child.getEntryPathLength()); + CanonicalTreeParser next = getOnlyChildSubtree(rw, child.getEntryObjectId(), path); + if (next == null) { + break; + } + child = next; + } + res.sendRedirect(GitilesView.path().copyFrom(view) + .setTreePath( + RawParseUtils.decode(child.getEntryPathBuffer(), 0, child.getEntryPathLength())) + .toUrl()); + return; + } + } // TODO(sop): Allow caching trees by SHA-1 when no S cookie is sent. render(req, res, "gitiles.pathDetail", ImmutableMap.of( "title", !view.getTreePath().isEmpty() ? view.getTreePath() : "/", + "breadcrumbs", view.getBreadcrumbs(hasSingleTree), "type", FileType.TREE.toString(), "data", new TreeSoyData(rw, view).toSoyData(id, tw))); } - private void showFile(HttpServletRequest req, HttpServletResponse res, RevWalk rw, TreeWalk tw) + private CanonicalTreeParser getOnlyChildSubtree(RevWalk rw, ObjectId id, byte[] prefix) throws IOException { + CanonicalTreeParser p = new CanonicalTreeParser(prefix, rw.getObjectReader(), id); + if (p.eof() || p.getEntryFileMode() != FileMode.TREE) { + return null; + } + p.next(1); + return p.eof() ? p : null; + } + + private void showFile(HttpServletRequest req, HttpServletResponse res, RevWalk rw, TreeWalk tw, + List<Boolean> hasSingleTree) throws IOException { GitilesView view = ViewFilter.getView(req); // TODO(sop): Allow caching files by SHA-1 when no S cookie is sent. render(req, res, "gitiles.pathDetail", ImmutableMap.of( "title", ViewFilter.getView(req).getTreePath(), + "breadcrumbs", view.getBreadcrumbs(hasSingleTree), "type", FileType.forEntry(tw).toString(), "data", new BlobSoyData(rw, view).toSoyData(tw.getPathString(), tw.getObjectId(0)))); } private void showSymlink(HttpServletRequest req, HttpServletResponse res, RevWalk rw, - TreeWalk tw) throws IOException { + TreeWalk tw, List<Boolean> hasSingleTree) throws IOException { GitilesView view = ViewFilter.getView(req); ObjectId id = tw.getObjectId(0); Map<String, Object> data = Maps.newHashMap(); @@ -204,6 +330,7 @@ data.put("size", Long.toString(loader.getSize())); render(req, res, "gitiles.pathDetail", ImmutableMap.of( "title", ViewFilter.getView(req).getTreePath(), + "breadcrumbs", view.getBreadcrumbs(hasSingleTree), "type", FileType.REGULAR_FILE.toString(), "data", data)); return; @@ -224,6 +351,7 @@ // TODO(sop): Allow caching files by SHA-1 when no S cookie is sent. render(req, res, "gitiles.pathDetail", ImmutableMap.of( "title", ViewFilter.getView(req).getTreePath(), + "breadcrumbs", view.getBreadcrumbs(hasSingleTree), "type", FileType.SYMLINK.toString(), "data", data)); } @@ -243,7 +371,7 @@ } private void showGitlink(HttpServletRequest req, HttpServletResponse res, RevWalk rw, - TreeWalk tw, RevTree root) throws IOException { + TreeWalk tw, RevTree root, List<Boolean> hasSingleTree) throws IOException { GitilesView view = ViewFilter.getView(req); SubmoduleWalk sw = SubmoduleWalk.forPath(ServletUtils.getRepository(req), root, view.getTreePath());
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 cf246b9..1862467 100644 --- a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java +++ b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
@@ -498,6 +498,40 @@ view.getBreadcrumbs()); } + public void testBreadcrumbsHasSingleTree() throws Exception { + ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"); + GitilesView view = GitilesView.path() + .copyFrom(HOST) + .setRepositoryName("foo/bar") + .setRevision(Revision.unpeeled("master", id)) + .setTreePath("/path/to/a/file") + .build(); + + assertEquals("/b/foo/bar/+/master/path/to/a/file", view.toUrl()); + assertEquals( + ImmutableList.of( + breadcrumb("host", "/b/?format=HTML"), + breadcrumb("foo/bar", "/b/foo/bar/"), + breadcrumb("master", "/b/foo/bar/+/master"), + breadcrumb(".", "/b/foo/bar/+/master/"), + breadcrumb("path", "/b/foo/bar/+/master/path"), + breadcrumb("to", "/b/foo/bar/+/master/path/to?autodive=0"), + breadcrumb("a", "/b/foo/bar/+/master/path/to/a?autodive=0"), + breadcrumb("file", "/b/foo/bar/+/master/path/to/a/file")), + view.getBreadcrumbs(ImmutableList.of(false, true, true))); + assertEquals( + ImmutableList.of( + breadcrumb("host", "/b/?format=HTML"), + breadcrumb("foo/bar", "/b/foo/bar/"), + breadcrumb("master", "/b/foo/bar/+/master"), + breadcrumb(".", "/b/foo/bar/+/master/"), + breadcrumb("path", "/b/foo/bar/+/master/path?autodive=0"), + breadcrumb("to", "/b/foo/bar/+/master/path/to"), + breadcrumb("a", "/b/foo/bar/+/master/path/to/a"), + breadcrumb("file", "/b/foo/bar/+/master/path/to/a/file")), + view.getBreadcrumbs(ImmutableList.of(true, false, false))); + } + private static ImmutableMap<String, String> breadcrumb(String text, String url) { return ImmutableMap.of("text", text, "url", url); }