blob: f48ec3b1a2cc6fce2d54ea5d99ad9eadf17886ae [file] [log] [blame]
Dave Borowitz9de65952012-08-13 16:09:45 -07001// Copyright 2012 Google Inc. All Rights Reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15package com.google.gitiles;
16
Dave Borowitz4e8ffd82012-12-26 16:01:06 -080017import static com.google.common.base.Preconditions.checkArgument;
Dave Borowitz9de65952012-08-13 16:09:45 -070018import static com.google.common.base.Preconditions.checkNotNull;
Dave Borowitz9de65952012-08-13 16:09:45 -070019import static com.google.gitiles.GitilesUrls.NAME_ESCAPER;
Dave Borowitz4e8ffd82012-12-26 16:01:06 -080020import static com.google.gitiles.RevisionParser.PATH_SPLITTER;
Dave Borowitz9de65952012-08-13 16:09:45 -070021
22import com.google.common.base.Charsets;
23import com.google.common.base.Objects;
24import com.google.common.base.Strings;
25import com.google.common.collect.ImmutableList;
26import com.google.common.collect.ImmutableMap;
27import com.google.common.collect.LinkedListMultimap;
28import com.google.common.collect.ListMultimap;
29import com.google.common.collect.Multimaps;
30
Dave Borowitz80334b22013-01-11 14:19:11 -080031import org.eclipse.jgit.lib.Constants;
Dave Borowitz9de65952012-08-13 16:09:45 -070032import org.eclipse.jgit.revwalk.RevObject;
33
34import java.io.UnsupportedEncodingException;
35import java.net.URLEncoder;
36import java.util.List;
37import java.util.Map;
38
39import javax.servlet.http.HttpServletRequest;
40
41/**
42 * Information about a view in Gitiles.
43 * <p>
44 * Views are uniquely identified by a type, and dispatched to servlet types by
45 * {@link GitilesServlet}. This class contains the list of all types, as
46 * well as some methods containing basic information parsed from the URL.
47 * Construction happens in {@link ViewFilter}.
48 */
49public class GitilesView {
50 /** All the possible view types supported in the application. */
51 public static enum Type {
52 HOST_INDEX,
53 REPOSITORY_INDEX,
Dave Borowitz209d0aa2012-12-28 14:28:53 -080054 REFS,
Dave Borowitz9de65952012-08-13 16:09:45 -070055 REVISION,
56 PATH,
57 DIFF,
58 LOG;
59 }
60
Dave Borowitz6221d982013-01-10 10:39:20 -080061 /** Exception thrown when building a view that is invalid. */
62 public static class InvalidViewException extends IllegalStateException {
63 private static final long serialVersionUID = 1L;
64
65 public InvalidViewException(String msg) {
66 super(msg);
67 }
68 }
69
Dave Borowitz9de65952012-08-13 16:09:45 -070070 /** Builder for views. */
71 public static class Builder {
72 private final Type type;
73 private final ListMultimap<String, String> params = LinkedListMultimap.create();
74
75 private String hostName;
76 private String servletPath;
77 private String repositoryName;
78 private Revision revision = Revision.NULL;
79 private Revision oldRevision = Revision.NULL;
80 private String path;
81 private String anchor;
82
83 private Builder(Type type) {
84 this.type = type;
85 }
86
87 public Builder copyFrom(GitilesView other) {
88 hostName = other.hostName;
89 servletPath = other.servletPath;
90 switch (type) {
91 case LOG:
92 case DIFF:
93 oldRevision = other.oldRevision;
94 // Fallthrough.
95 case PATH:
96 path = other.path;
97 // Fallthrough.
98 case REVISION:
99 revision = other.revision;
100 // Fallthrough.
Dave Borowitz209d0aa2012-12-28 14:28:53 -0800101 case REFS:
Dave Borowitz9de65952012-08-13 16:09:45 -0700102 case REPOSITORY_INDEX:
103 repositoryName = other.repositoryName;
Chad Horohoead23f142012-11-12 09:45:39 -0800104 // Fallthrough.
105 default:
106 break;
Dave Borowitz9de65952012-08-13 16:09:45 -0700107 }
108 // Don't copy params.
109 return this;
110 }
111
112 public Builder copyFrom(HttpServletRequest req) {
113 return copyFrom(ViewFilter.getView(req));
114 }
115
116 public Builder setHostName(String hostName) {
117 this.hostName = checkNotNull(hostName);
118 return this;
119 }
120
121 public String getHostName() {
122 return hostName;
123 }
124
125 public Builder setServletPath(String servletPath) {
126 this.servletPath = checkNotNull(servletPath);
127 return this;
128 }
129
130 public String getServletPath() {
131 return servletPath;
132 }
133
134 public Builder setRepositoryName(String repositoryName) {
135 switch (type) {
136 case HOST_INDEX:
137 throw new IllegalStateException(String.format(
138 "cannot set repository name on %s view", type));
139 default:
140 this.repositoryName = checkNotNull(repositoryName);
141 return this;
142 }
143 }
144
145 public String getRepositoryName() {
146 return repositoryName;
147 }
148
149 public Builder setRevision(Revision revision) {
150 switch (type) {
151 case HOST_INDEX:
152 case REPOSITORY_INDEX:
Dave Borowitz209d0aa2012-12-28 14:28:53 -0800153 case REFS:
Dave Borowitz9de65952012-08-13 16:09:45 -0700154 throw new IllegalStateException(String.format("cannot set revision on %s view", type));
155 default:
156 this.revision = checkNotNull(revision);
157 return this;
158 }
159 }
160
161 public Builder setRevision(String name) {
162 return setRevision(Revision.named(name));
163 }
164
165 public Builder setRevision(RevObject obj) {
166 return setRevision(Revision.peeled(obj.name(), obj));
167 }
168
169 public Builder setRevision(String name, RevObject obj) {
170 return setRevision(Revision.peeled(name, obj));
171 }
172
173 public Revision getRevision() {
174 return revision;
175 }
176
177 public Builder setOldRevision(Revision revision) {
178 switch (type) {
179 case DIFF:
180 case LOG:
181 this.oldRevision = checkNotNull(revision);
182 return this;
183 default:
184 throw new IllegalStateException(
185 String.format("cannot set old revision on %s view", type));
186 }
187 }
188
189 public Builder setOldRevision(RevObject obj) {
190 return setOldRevision(Revision.peeled(obj.name(), obj));
191 }
192
193 public Builder setOldRevision(String name, RevObject obj) {
194 return setOldRevision(Revision.peeled(name, obj));
195 }
196
197 public Revision getOldRevision() {
198 return revision;
199 }
200
201 public Builder setTreePath(String path) {
202 switch (type) {
203 case PATH:
204 case DIFF:
205 this.path = maybeTrimLeadingAndTrailingSlash(checkNotNull(path));
206 return this;
207 case LOG:
208 this.path = path != null ? maybeTrimLeadingAndTrailingSlash(path) : null;
209 return this;
210 default:
211 throw new IllegalStateException(String.format("cannot set path on %s view", type));
212 }
213 }
214
215 public String getTreePath() {
216 return path;
217 }
218
219 public Builder putParam(String key, String value) {
220 params.put(key, value);
221 return this;
222 }
223
224 public Builder replaceParam(String key, String value) {
225 params.replaceValues(key, ImmutableList.of(value));
226 return this;
227 }
228
229 public Builder putAllParams(Map<String, String[]> params) {
230 for (Map.Entry<String, String[]> e : params.entrySet()) {
231 for (String v : e.getValue()) {
232 this.params.put(e.getKey(), v);
233 }
234 }
235 return this;
236 }
237
238 public ListMultimap<String, String> getParams() {
239 return params;
240 }
241
242 public Builder setAnchor(String anchor) {
243 this.anchor = anchor;
244 return this;
245 }
246
247 public String getAnchor() {
248 return anchor;
249 }
250
251 public GitilesView build() {
252 switch (type) {
253 case HOST_INDEX:
254 checkHostIndex();
255 break;
256 case REPOSITORY_INDEX:
257 checkRepositoryIndex();
258 break;
Dave Borowitz209d0aa2012-12-28 14:28:53 -0800259 case REFS:
260 checkRefs();
261 break;
Dave Borowitz9de65952012-08-13 16:09:45 -0700262 case REVISION:
263 checkRevision();
264 break;
265 case PATH:
266 checkPath();
267 break;
268 case DIFF:
269 checkDiff();
270 break;
271 case LOG:
272 checkLog();
273 break;
274 }
275 return new GitilesView(type, hostName, servletPath, repositoryName, revision,
276 oldRevision, path, params, anchor);
277 }
278
279 public String toUrl() {
280 return build().toUrl();
281 }
282
Dave Borowitz6221d982013-01-10 10:39:20 -0800283 private void checkView(boolean expr, String msg, Object... args) {
284 if (!expr) {
285 throw new InvalidViewException(String.format(msg, args));
286 }
287 }
288
Dave Borowitz9de65952012-08-13 16:09:45 -0700289 private void checkHostIndex() {
Dave Borowitz6221d982013-01-10 10:39:20 -0800290 checkView(hostName != null, "missing hostName on %s view", type);
291 checkView(servletPath != null, "missing hostName on %s view", type);
Dave Borowitz9de65952012-08-13 16:09:45 -0700292 }
293
294 private void checkRepositoryIndex() {
Dave Borowitz6221d982013-01-10 10:39:20 -0800295 checkView(repositoryName != null, "missing repository name on %s view", type);
Dave Borowitz9de65952012-08-13 16:09:45 -0700296 checkHostIndex();
297 }
298
Dave Borowitz209d0aa2012-12-28 14:28:53 -0800299 private void checkRefs() {
300 checkRepositoryIndex();
301 }
302
Dave Borowitz9de65952012-08-13 16:09:45 -0700303 private void checkRevision() {
Dave Borowitz6221d982013-01-10 10:39:20 -0800304 checkView(revision != Revision.NULL, "missing revision on %s view", type);
Dave Borowitz9de65952012-08-13 16:09:45 -0700305 checkRepositoryIndex();
306 }
307
308 private void checkDiff() {
309 checkPath();
310 }
311
312 private void checkLog() {
Dave Borowitz80334b22013-01-11 14:19:11 -0800313 checkRepositoryIndex();
Dave Borowitz9de65952012-08-13 16:09:45 -0700314 }
315
316 private void checkPath() {
Dave Borowitz6221d982013-01-10 10:39:20 -0800317 checkView(path != null, "missing path on %s view", type);
Dave Borowitz9de65952012-08-13 16:09:45 -0700318 checkRevision();
319 }
320 }
321
322 public static Builder hostIndex() {
323 return new Builder(Type.HOST_INDEX);
324 }
325
326 public static Builder repositoryIndex() {
327 return new Builder(Type.REPOSITORY_INDEX);
328 }
329
Dave Borowitz209d0aa2012-12-28 14:28:53 -0800330 public static Builder refs() {
331 return new Builder(Type.REFS);
332 }
333
Dave Borowitz9de65952012-08-13 16:09:45 -0700334 public static Builder revision() {
335 return new Builder(Type.REVISION);
336 }
337
338 public static Builder path() {
339 return new Builder(Type.PATH);
340 }
341
342 public static Builder diff() {
343 return new Builder(Type.DIFF);
344 }
345
346 public static Builder log() {
347 return new Builder(Type.LOG);
348 }
349
350 private static String maybeTrimLeadingAndTrailingSlash(String str) {
351 if (str.startsWith("/")) {
352 str = str.substring(1);
353 }
354 return !str.isEmpty() && str.endsWith("/") ? str.substring(0, str.length() - 1) : str;
355 }
356
357 private final Type type;
358 private final String hostName;
359 private final String servletPath;
360 private final String repositoryName;
361 private final Revision revision;
362 private final Revision oldRevision;
363 private final String path;
364 private final ListMultimap<String, String> params;
365 private final String anchor;
366
367 private GitilesView(Type type,
368 String hostName,
369 String servletPath,
370 String repositoryName,
371 Revision revision,
372 Revision oldRevision,
373 String path,
374 ListMultimap<String, String> params,
375 String anchor) {
376 this.type = type;
377 this.hostName = hostName;
378 this.servletPath = servletPath;
379 this.repositoryName = repositoryName;
380 this.revision = Objects.firstNonNull(revision, Revision.NULL);
381 this.oldRevision = Objects.firstNonNull(oldRevision, Revision.NULL);
382 this.path = path;
383 this.params = Multimaps.unmodifiableListMultimap(params);
384 this.anchor = anchor;
385 }
386
387 public String getHostName() {
388 return hostName;
389 }
390
391 public String getServletPath() {
392 return servletPath;
393 }
394
395 public String getRepositoryName() {
396 return repositoryName;
397 }
398
399 public Revision getRevision() {
400 return revision;
401 }
402
403 public Revision getOldRevision() {
404 return oldRevision;
405 }
406
407 public String getRevisionRange() {
408 if (oldRevision == Revision.NULL) {
409 switch (type) {
410 case LOG:
411 case DIFF:
412 // For types that require two revisions, NULL indicates the empty
413 // tree/commit.
414 return revision.getName() + "^!";
415 default:
416 // For everything else NULL indicates it is not a range, just a single
417 // revision.
418 return null;
419 }
420 } else if (type == Type.DIFF && isFirstParent(revision, oldRevision)) {
421 return revision.getName() + "^!";
422 } else {
423 return oldRevision.getName() + ".." + revision.getName();
424 }
425 }
426
427 public String getTreePath() {
428 return path;
429 }
430
431 public ListMultimap<String, String> getParameters() {
432 return params;
433 }
434
435 public String getAnchor() {
436 return anchor;
437 }
438
439 public Type getType() {
440 return type;
441 }
442
443 /** @return an escaped, relative URL representing this view. */
444 public String toUrl() {
445 StringBuilder url = new StringBuilder(servletPath).append('/');
446 ListMultimap<String, String> params = this.params;
447 switch (type) {
448 case HOST_INDEX:
449 params = LinkedListMultimap.create();
450 if (!this.params.containsKey("format")) {
451 params.put("format", FormatType.HTML.toString());
452 }
453 params.putAll(this.params);
454 break;
455 case REPOSITORY_INDEX:
456 url.append(repositoryName).append('/');
457 break;
Dave Borowitz209d0aa2012-12-28 14:28:53 -0800458 case REFS:
459 url.append(repositoryName).append("/+refs");
460 break;
Dave Borowitz9de65952012-08-13 16:09:45 -0700461 case REVISION:
Dave Borowitzd3e6dd72012-12-20 15:48:24 -0800462 url.append(repositoryName).append("/+/").append(revision.getName());
Dave Borowitz9de65952012-08-13 16:09:45 -0700463 break;
464 case PATH:
465 url.append(repositoryName).append("/+/").append(revision.getName()).append('/')
466 .append(path);
467 break;
468 case DIFF:
469 url.append(repositoryName).append("/+/");
470 if (isFirstParent(revision, oldRevision)) {
471 url.append(revision.getName()).append("^!");
472 } else {
473 url.append(oldRevision.getName()).append("..").append(revision.getName());
474 }
475 url.append('/').append(path);
476 break;
477 case LOG:
Dave Borowitz80334b22013-01-11 14:19:11 -0800478 url.append(repositoryName).append("/+log");
479 if (revision != Revision.NULL) {
480 url.append('/');
481 if (oldRevision != Revision.NULL) {
482 url.append(oldRevision.getName()).append("..");
483 }
484 url.append(revision.getName());
485 if (path != null) {
486 url.append('/').append(path);
487 }
Dave Borowitz9de65952012-08-13 16:09:45 -0700488 }
489 break;
490 default:
491 throw new IllegalStateException("Unknown view type: " + type);
492 }
493 String baseUrl = NAME_ESCAPER.apply(url.toString());
494 url = new StringBuilder();
495 if (!params.isEmpty()) {
496 url.append('?').append(paramsToString(params));
497 }
498 if (!Strings.isNullOrEmpty(anchor)) {
499 url.append('#').append(NAME_ESCAPER.apply(anchor));
500 }
501 return baseUrl + url.toString();
502 }
503
Dave Borowitz4e8ffd82012-12-26 16:01:06 -0800504 /**
505 * @return a list of maps with "text" and "url" keys for all file paths
506 * leading up to the path represented by this view. All URLs allow
507 * auto-diving into one-entry subtrees; see also
508 * {@link #getBreadcrumbs(List<Boolean>)}.
509 */
Dave Borowitz9de65952012-08-13 16:09:45 -0700510 public List<Map<String, String>> getBreadcrumbs() {
Dave Borowitz4e8ffd82012-12-26 16:01:06 -0800511 return getBreadcrumbs(null);
512 }
513
514 /**
515 * @param hasSingleTree list of booleans, one per path entry in this view's
516 * path excluding the leaf. True entries indicate the tree at that path
517 * only has a single entry that is another tree.
518 * @return a list of maps with "text" and "url" keys for all file paths
519 * leading up to the path represented by this view. URLs whose
520 * corresponding entry in {@code hasSingleTree} is true will disable
521 * auto-diving into one-entry subtrees.
522 */
523 public List<Map<String, String>> getBreadcrumbs(List<Boolean> hasSingleTree) {
524 checkArgument(hasSingleTree == null || type == Type.PATH,
525 "hasSingleTree must be null for %s view", type);
Dave Borowitz9de65952012-08-13 16:09:45 -0700526 String path = this.path;
527 ImmutableList.Builder<Map<String, String>> breadcrumbs = ImmutableList.builder();
528 breadcrumbs.add(breadcrumb(hostName, hostIndex().copyFrom(this)));
529 if (repositoryName != null) {
530 breadcrumbs.add(breadcrumb(repositoryName, repositoryIndex().copyFrom(this)));
531 }
532 if (type == Type.DIFF) {
533 // TODO(dborowitz): Tweak the breadcrumbs template to allow us to render
534 // separate links in "old..new".
535 breadcrumbs.add(breadcrumb(getRevisionRange(), diff().copyFrom(this).setTreePath("")));
536 } else if (type == Type.LOG) {
Dave Borowitz80334b22013-01-11 14:19:11 -0800537 if (revision != Revision.NULL) {
538 // TODO(dborowitz): Add something in the navigation area (probably not
539 // a breadcrumb) to allow switching between /+log/ and /+/.
540 if (oldRevision == Revision.NULL) {
541 breadcrumbs.add(breadcrumb(revision.getName(), log().copyFrom(this).setTreePath(null)));
542 } else {
543 breadcrumbs.add(breadcrumb(getRevisionRange(), log().copyFrom(this).setTreePath(null)));
544 }
Dave Borowitz9de65952012-08-13 16:09:45 -0700545 } else {
Dave Borowitz80334b22013-01-11 14:19:11 -0800546 breadcrumbs.add(breadcrumb(Constants.HEAD, log().copyFrom(this)));
Dave Borowitz9de65952012-08-13 16:09:45 -0700547 }
548 path = Strings.emptyToNull(path);
549 } else if (revision != Revision.NULL) {
550 breadcrumbs.add(breadcrumb(revision.getName(), revision().copyFrom(this)));
551 }
552 if (path != null) {
553 if (type != Type.LOG) { // The "." breadcrumb would be no different for LOG.
554 breadcrumbs.add(breadcrumb(".", copyWithPath().setTreePath("")));
555 }
556 StringBuilder cur = new StringBuilder();
Dave Borowitz4e8ffd82012-12-26 16:01:06 -0800557 List<String> parts = ImmutableList.copyOf(PATH_SPLITTER.omitEmptyStrings().split(path));
Dave Borowitz44a15842013-01-07 09:39:05 -0800558 checkArgument(hasSingleTree == null
559 || (parts.isEmpty() && hasSingleTree.isEmpty())
560 || hasSingleTree.size() == parts.size() - 1,
Dave Borowitz4e8ffd82012-12-26 16:01:06 -0800561 "hasSingleTree has wrong number of entries");
562 for (int i = 0; i < parts.size(); i++) {
563 String part = parts.get(i);
564 cur.append(part).append('/');
565 String curPath = cur.toString();
566 Builder builder = copyWithPath().setTreePath(curPath);
567 if (hasSingleTree != null && i < parts.size() - 1 && hasSingleTree.get(i)) {
568 builder.replaceParam(PathServlet.AUTODIVE_PARAM, PathServlet.NO_AUTODIVE_VALUE);
Dave Borowitz9de65952012-08-13 16:09:45 -0700569 }
Dave Borowitz4e8ffd82012-12-26 16:01:06 -0800570 breadcrumbs.add(breadcrumb(part, builder));
Dave Borowitz9de65952012-08-13 16:09:45 -0700571 }
572 }
573 return breadcrumbs.build();
574 }
575
576 private static Map<String, String> breadcrumb(String text, Builder url) {
577 return ImmutableMap.of("text", text, "url", url.toUrl());
578 }
579
580 private Builder copyWithPath() {
581 Builder copy;
582 switch (type) {
583 case DIFF:
584 copy = diff();
585 break;
586 case LOG:
587 copy = log();
588 break;
589 default:
590 copy = path();
591 break;
592 }
593 return copy.copyFrom(this);
594 }
595
596 private static boolean isFirstParent(Revision rev1, Revision rev2) {
597 return rev2 == Revision.NULL
598 || rev2.getName().equals(rev1.getName() + "^")
599 || rev2.getName().equals(rev1.getName() + "~1");
600 }
601
602 private static String paramsToString(ListMultimap<String, String> params) {
603 try {
604 StringBuilder sb = new StringBuilder();
605 boolean first = true;
606 for (Map.Entry<String, String> e : params.entries()) {
607 if (!first) {
608 sb.append('&');
609 } else {
610 first = false;
611 }
612 sb.append(URLEncoder.encode(e.getKey(), Charsets.UTF_8.name()));
613 if (!"".equals(e.getValue())) {
614 sb.append('=')
615 .append(URLEncoder.encode(e.getValue(), Charsets.UTF_8.name()));
616 }
617 }
618 return sb.toString();
619 } catch (UnsupportedEncodingException e) {
620 throw new IllegalStateException(e);
621 }
622 }
623}