blob: 05c31a79cb3a5eebff6e9ff7dba9d3ff97259f08 [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
17import static com.google.common.base.Preconditions.checkNotNull;
18import static com.google.common.base.Preconditions.checkState;
19import static com.google.gitiles.GitilesUrls.NAME_ESCAPER;
20
21import com.google.common.base.Charsets;
22import com.google.common.base.Objects;
23import com.google.common.base.Strings;
24import com.google.common.collect.ImmutableList;
25import com.google.common.collect.ImmutableMap;
26import com.google.common.collect.LinkedListMultimap;
27import com.google.common.collect.ListMultimap;
28import com.google.common.collect.Multimaps;
29
30import org.eclipse.jgit.revwalk.RevObject;
31
32import java.io.UnsupportedEncodingException;
33import java.net.URLEncoder;
34import java.util.List;
35import java.util.Map;
36
37import javax.servlet.http.HttpServletRequest;
38
39/**
40 * Information about a view in Gitiles.
41 * <p>
42 * Views are uniquely identified by a type, and dispatched to servlet types by
43 * {@link GitilesServlet}. This class contains the list of all types, as
44 * well as some methods containing basic information parsed from the URL.
45 * Construction happens in {@link ViewFilter}.
46 */
47public class GitilesView {
48 /** All the possible view types supported in the application. */
49 public static enum Type {
50 HOST_INDEX,
51 REPOSITORY_INDEX,
52 REVISION,
53 PATH,
54 DIFF,
55 LOG;
56 }
57
58 /** Builder for views. */
59 public static class Builder {
60 private final Type type;
61 private final ListMultimap<String, String> params = LinkedListMultimap.create();
62
63 private String hostName;
64 private String servletPath;
65 private String repositoryName;
66 private Revision revision = Revision.NULL;
67 private Revision oldRevision = Revision.NULL;
68 private String path;
69 private String anchor;
70
71 private Builder(Type type) {
72 this.type = type;
73 }
74
75 public Builder copyFrom(GitilesView other) {
76 hostName = other.hostName;
77 servletPath = other.servletPath;
78 switch (type) {
79 case LOG:
80 case DIFF:
81 oldRevision = other.oldRevision;
82 // Fallthrough.
83 case PATH:
84 path = other.path;
85 // Fallthrough.
86 case REVISION:
87 revision = other.revision;
88 // Fallthrough.
89 case REPOSITORY_INDEX:
90 repositoryName = other.repositoryName;
Chad Horohoead23f142012-11-12 09:45:39 -080091 // Fallthrough.
92 default:
93 break;
Dave Borowitz9de65952012-08-13 16:09:45 -070094 }
95 // Don't copy params.
96 return this;
97 }
98
99 public Builder copyFrom(HttpServletRequest req) {
100 return copyFrom(ViewFilter.getView(req));
101 }
102
103 public Builder setHostName(String hostName) {
104 this.hostName = checkNotNull(hostName);
105 return this;
106 }
107
108 public String getHostName() {
109 return hostName;
110 }
111
112 public Builder setServletPath(String servletPath) {
113 this.servletPath = checkNotNull(servletPath);
114 return this;
115 }
116
117 public String getServletPath() {
118 return servletPath;
119 }
120
121 public Builder setRepositoryName(String repositoryName) {
122 switch (type) {
123 case HOST_INDEX:
124 throw new IllegalStateException(String.format(
125 "cannot set repository name on %s view", type));
126 default:
127 this.repositoryName = checkNotNull(repositoryName);
128 return this;
129 }
130 }
131
132 public String getRepositoryName() {
133 return repositoryName;
134 }
135
136 public Builder setRevision(Revision revision) {
137 switch (type) {
138 case HOST_INDEX:
139 case REPOSITORY_INDEX:
140 throw new IllegalStateException(String.format("cannot set revision on %s view", type));
141 default:
142 this.revision = checkNotNull(revision);
143 return this;
144 }
145 }
146
147 public Builder setRevision(String name) {
148 return setRevision(Revision.named(name));
149 }
150
151 public Builder setRevision(RevObject obj) {
152 return setRevision(Revision.peeled(obj.name(), obj));
153 }
154
155 public Builder setRevision(String name, RevObject obj) {
156 return setRevision(Revision.peeled(name, obj));
157 }
158
159 public Revision getRevision() {
160 return revision;
161 }
162
163 public Builder setOldRevision(Revision revision) {
164 switch (type) {
165 case DIFF:
166 case LOG:
167 this.oldRevision = checkNotNull(revision);
168 return this;
169 default:
170 throw new IllegalStateException(
171 String.format("cannot set old revision on %s view", type));
172 }
173 }
174
175 public Builder setOldRevision(RevObject obj) {
176 return setOldRevision(Revision.peeled(obj.name(), obj));
177 }
178
179 public Builder setOldRevision(String name, RevObject obj) {
180 return setOldRevision(Revision.peeled(name, obj));
181 }
182
183 public Revision getOldRevision() {
184 return revision;
185 }
186
187 public Builder setTreePath(String path) {
188 switch (type) {
189 case PATH:
190 case DIFF:
191 this.path = maybeTrimLeadingAndTrailingSlash(checkNotNull(path));
192 return this;
193 case LOG:
194 this.path = path != null ? maybeTrimLeadingAndTrailingSlash(path) : null;
195 return this;
196 default:
197 throw new IllegalStateException(String.format("cannot set path on %s view", type));
198 }
199 }
200
201 public String getTreePath() {
202 return path;
203 }
204
205 public Builder putParam(String key, String value) {
206 params.put(key, value);
207 return this;
208 }
209
210 public Builder replaceParam(String key, String value) {
211 params.replaceValues(key, ImmutableList.of(value));
212 return this;
213 }
214
215 public Builder putAllParams(Map<String, String[]> params) {
216 for (Map.Entry<String, String[]> e : params.entrySet()) {
217 for (String v : e.getValue()) {
218 this.params.put(e.getKey(), v);
219 }
220 }
221 return this;
222 }
223
224 public ListMultimap<String, String> getParams() {
225 return params;
226 }
227
228 public Builder setAnchor(String anchor) {
229 this.anchor = anchor;
230 return this;
231 }
232
233 public String getAnchor() {
234 return anchor;
235 }
236
237 public GitilesView build() {
238 switch (type) {
239 case HOST_INDEX:
240 checkHostIndex();
241 break;
242 case REPOSITORY_INDEX:
243 checkRepositoryIndex();
244 break;
245 case REVISION:
246 checkRevision();
247 break;
248 case PATH:
249 checkPath();
250 break;
251 case DIFF:
252 checkDiff();
253 break;
254 case LOG:
255 checkLog();
256 break;
257 }
258 return new GitilesView(type, hostName, servletPath, repositoryName, revision,
259 oldRevision, path, params, anchor);
260 }
261
262 public String toUrl() {
263 return build().toUrl();
264 }
265
266 private void checkHostIndex() {
267 checkState(hostName != null, "missing hostName on %s view", type);
268 checkState(servletPath != null, "missing hostName on %s view", type);
269 }
270
271 private void checkRepositoryIndex() {
272 checkState(repositoryName != null, "missing repository name on %s view", type);
273 checkHostIndex();
274 }
275
276 private void checkRevision() {
277 checkState(revision != Revision.NULL, "missing revision on %s view", type);
278 checkRepositoryIndex();
279 }
280
281 private void checkDiff() {
282 checkPath();
283 }
284
285 private void checkLog() {
286 checkRevision();
287 }
288
289 private void checkPath() {
290 checkState(path != null, "missing path on %s view", type);
291 checkRevision();
292 }
293 }
294
295 public static Builder hostIndex() {
296 return new Builder(Type.HOST_INDEX);
297 }
298
299 public static Builder repositoryIndex() {
300 return new Builder(Type.REPOSITORY_INDEX);
301 }
302
303 public static Builder revision() {
304 return new Builder(Type.REVISION);
305 }
306
307 public static Builder path() {
308 return new Builder(Type.PATH);
309 }
310
311 public static Builder diff() {
312 return new Builder(Type.DIFF);
313 }
314
315 public static Builder log() {
316 return new Builder(Type.LOG);
317 }
318
319 private static String maybeTrimLeadingAndTrailingSlash(String str) {
320 if (str.startsWith("/")) {
321 str = str.substring(1);
322 }
323 return !str.isEmpty() && str.endsWith("/") ? str.substring(0, str.length() - 1) : str;
324 }
325
326 private final Type type;
327 private final String hostName;
328 private final String servletPath;
329 private final String repositoryName;
330 private final Revision revision;
331 private final Revision oldRevision;
332 private final String path;
333 private final ListMultimap<String, String> params;
334 private final String anchor;
335
336 private GitilesView(Type type,
337 String hostName,
338 String servletPath,
339 String repositoryName,
340 Revision revision,
341 Revision oldRevision,
342 String path,
343 ListMultimap<String, String> params,
344 String anchor) {
345 this.type = type;
346 this.hostName = hostName;
347 this.servletPath = servletPath;
348 this.repositoryName = repositoryName;
349 this.revision = Objects.firstNonNull(revision, Revision.NULL);
350 this.oldRevision = Objects.firstNonNull(oldRevision, Revision.NULL);
351 this.path = path;
352 this.params = Multimaps.unmodifiableListMultimap(params);
353 this.anchor = anchor;
354 }
355
356 public String getHostName() {
357 return hostName;
358 }
359
360 public String getServletPath() {
361 return servletPath;
362 }
363
364 public String getRepositoryName() {
365 return repositoryName;
366 }
367
368 public Revision getRevision() {
369 return revision;
370 }
371
372 public Revision getOldRevision() {
373 return oldRevision;
374 }
375
376 public String getRevisionRange() {
377 if (oldRevision == Revision.NULL) {
378 switch (type) {
379 case LOG:
380 case DIFF:
381 // For types that require two revisions, NULL indicates the empty
382 // tree/commit.
383 return revision.getName() + "^!";
384 default:
385 // For everything else NULL indicates it is not a range, just a single
386 // revision.
387 return null;
388 }
389 } else if (type == Type.DIFF && isFirstParent(revision, oldRevision)) {
390 return revision.getName() + "^!";
391 } else {
392 return oldRevision.getName() + ".." + revision.getName();
393 }
394 }
395
396 public String getTreePath() {
397 return path;
398 }
399
400 public ListMultimap<String, String> getParameters() {
401 return params;
402 }
403
404 public String getAnchor() {
405 return anchor;
406 }
407
408 public Type getType() {
409 return type;
410 }
411
412 /** @return an escaped, relative URL representing this view. */
413 public String toUrl() {
414 StringBuilder url = new StringBuilder(servletPath).append('/');
415 ListMultimap<String, String> params = this.params;
416 switch (type) {
417 case HOST_INDEX:
418 params = LinkedListMultimap.create();
419 if (!this.params.containsKey("format")) {
420 params.put("format", FormatType.HTML.toString());
421 }
422 params.putAll(this.params);
423 break;
424 case REPOSITORY_INDEX:
425 url.append(repositoryName).append('/');
426 break;
427 case REVISION:
428 url.append(repositoryName).append("/+");
429 if (!getRevision().nameIsId()) {
430 url.append("show"); // Default for /+/master is +log.
431 }
432 url.append('/').append(revision.getName());
433 break;
434 case PATH:
435 url.append(repositoryName).append("/+/").append(revision.getName()).append('/')
436 .append(path);
437 break;
438 case DIFF:
439 url.append(repositoryName).append("/+/");
440 if (isFirstParent(revision, oldRevision)) {
441 url.append(revision.getName()).append("^!");
442 } else {
443 url.append(oldRevision.getName()).append("..").append(revision.getName());
444 }
445 url.append('/').append(path);
446 break;
447 case LOG:
448 url.append(repositoryName).append("/+");
449 if (getRevision().nameIsId() || oldRevision != Revision.NULL || path != null) {
450 // Default for /+/c0ffee/(...) is +show.
451 // Default for /+/c0ffee..deadbeef(/...) is +diff.
452 url.append("log");
453 }
454 url.append('/');
455 if (oldRevision != Revision.NULL) {
456 url.append(oldRevision.getName()).append("..");
457 }
458 url.append(revision.getName());
459 if (path != null) {
460 url.append('/').append(path);
461 }
462 break;
463 default:
464 throw new IllegalStateException("Unknown view type: " + type);
465 }
466 String baseUrl = NAME_ESCAPER.apply(url.toString());
467 url = new StringBuilder();
468 if (!params.isEmpty()) {
469 url.append('?').append(paramsToString(params));
470 }
471 if (!Strings.isNullOrEmpty(anchor)) {
472 url.append('#').append(NAME_ESCAPER.apply(anchor));
473 }
474 return baseUrl + url.toString();
475 }
476
477 public List<Map<String, String>> getBreadcrumbs() {
478 String path = this.path;
479 ImmutableList.Builder<Map<String, String>> breadcrumbs = ImmutableList.builder();
480 breadcrumbs.add(breadcrumb(hostName, hostIndex().copyFrom(this)));
481 if (repositoryName != null) {
482 breadcrumbs.add(breadcrumb(repositoryName, repositoryIndex().copyFrom(this)));
483 }
484 if (type == Type.DIFF) {
485 // TODO(dborowitz): Tweak the breadcrumbs template to allow us to render
486 // separate links in "old..new".
487 breadcrumbs.add(breadcrumb(getRevisionRange(), diff().copyFrom(this).setTreePath("")));
488 } else if (type == Type.LOG) {
489 // TODO(dborowitz): Add something in the navigation area (probably not
490 // a breadcrumb) to allow switching between /+log/ and /+/.
491 if (oldRevision == Revision.NULL) {
492 breadcrumbs.add(breadcrumb(revision.getName(), log().copyFrom(this).setTreePath(null)));
493 } else {
494 breadcrumbs.add(breadcrumb(getRevisionRange(), log().copyFrom(this).setTreePath(null)));
495 }
496 path = Strings.emptyToNull(path);
497 } else if (revision != Revision.NULL) {
498 breadcrumbs.add(breadcrumb(revision.getName(), revision().copyFrom(this)));
499 }
500 if (path != null) {
501 if (type != Type.LOG) { // The "." breadcrumb would be no different for LOG.
502 breadcrumbs.add(breadcrumb(".", copyWithPath().setTreePath("")));
503 }
504 StringBuilder cur = new StringBuilder();
505 boolean first = true;
506 for (String part : RevisionParser.PATH_SPLITTER.omitEmptyStrings().split(path)) {
507 if (!first) {
508 cur.append('/');
509 } else {
510 first = false;
511 }
512 cur.append(part);
513 breadcrumbs.add(breadcrumb(part, copyWithPath().setTreePath(cur.toString())));
514 }
515 }
516 return breadcrumbs.build();
517 }
518
519 private static Map<String, String> breadcrumb(String text, Builder url) {
520 return ImmutableMap.of("text", text, "url", url.toUrl());
521 }
522
523 private Builder copyWithPath() {
524 Builder copy;
525 switch (type) {
526 case DIFF:
527 copy = diff();
528 break;
529 case LOG:
530 copy = log();
531 break;
532 default:
533 copy = path();
534 break;
535 }
536 return copy.copyFrom(this);
537 }
538
539 private static boolean isFirstParent(Revision rev1, Revision rev2) {
540 return rev2 == Revision.NULL
541 || rev2.getName().equals(rev1.getName() + "^")
542 || rev2.getName().equals(rev1.getName() + "~1");
543 }
544
545 private static String paramsToString(ListMultimap<String, String> params) {
546 try {
547 StringBuilder sb = new StringBuilder();
548 boolean first = true;
549 for (Map.Entry<String, String> e : params.entries()) {
550 if (!first) {
551 sb.append('&');
552 } else {
553 first = false;
554 }
555 sb.append(URLEncoder.encode(e.getKey(), Charsets.UTF_8.name()));
556 if (!"".equals(e.getValue())) {
557 sb.append('=')
558 .append(URLEncoder.encode(e.getValue(), Charsets.UTF_8.name()));
559 }
560 }
561 return sb.toString();
562 } catch (UnsupportedEncodingException e) {
563 throw new IllegalStateException(e);
564 }
565 }
566}