Blame output is now colored according to Commit (default), Author or Age. Both Commit and Author output uses random colors whereas Age uses a single color with varying tints applied to indicate the age. White indicates the eldest commit with the tint darkening as the commits get younger. Change-Id: I045458329af4765e91d5829ce3e8d28e21eeb66etags/v1.4.0
@@ -39,6 +39,7 @@ r20: { | |||
- Revised committer verification to require a matching displayname or account name AND the email address | |||
- Serve repositories on both /r and /git, displaying /r because it is shorter | |||
additions: | |||
- Added color modes for the blame page (issue-2) | |||
- Added an optional MirrorExecutor which will periodically fetch ref updates from source repositories for mirrors (issue-5). Repositories must be manually cloned using native git and "--mirror". | |||
- Added branch graph image servlet based on EGit's branch graph renderer (issue-194) | |||
- Added option to render Markdown commit messages (issue-203) | |||
@@ -82,6 +83,7 @@ r20: { | |||
- Guenter Dressel | |||
- fpeters.fae | |||
- David Ostrovsky | |||
- Alex Lewis | |||
} | |||
# |
@@ -0,0 +1,135 @@ | |||
/* | |||
* Copyright 2013 Alex Lewis. | |||
* Copyright 2013 gitblit.com. | |||
* | |||
* 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.gitblit.utils; | |||
import java.awt.Color; | |||
import java.util.HashMap; | |||
import java.util.Map; | |||
import java.util.Random; | |||
import java.util.Set; | |||
/** | |||
* Factory for creating color maps. | |||
* | |||
* @author Alex Lewis | |||
*/ | |||
public class ColorFactory { | |||
private static final double MAX_TINT_FACTOR = 1; | |||
private static final double MIN_TINT_FACTOR = 0.2; | |||
private static final double FIXED_TINT_FACTOR = 0.875; | |||
/** | |||
* Builds a map of the supplied keys to a random color tinted according to | |||
* the key's position in the set. | |||
* | |||
* Depending on the number of keys in the set a tint is calculated from 1.0 | |||
* (I.e. white) to a minimum tint. The keys are sorted such that the | |||
* "lowest" value will have a full tint applied to it (1.0) with an equally | |||
* decreasing tint applied to each key thereafter. | |||
* | |||
* @param keys | |||
* The keys to create a tinted color for. | |||
* @param baseColor | |||
* the base color (optional) | |||
* @return The map of key to tinted color. | |||
*/ | |||
public <T> Map<T, String> getGraduatedColorMap(Set<T> keys, Color baseColor) { | |||
Map<T, String> colorMap = new HashMap<T, String>(); | |||
if (baseColor == null) { | |||
baseColor = getRandomColor(); | |||
} | |||
double tintStep = (MAX_TINT_FACTOR - MIN_TINT_FACTOR) / keys.size(); | |||
double currentTint = MAX_TINT_FACTOR; | |||
for (T key : keys) { | |||
Color color = tintColor(baseColor, currentTint); | |||
colorMap.put(key, getColorString(color)); | |||
currentTint -= tintStep; | |||
} | |||
return colorMap; | |||
} | |||
/** | |||
* Builds a map of the supplied keys to random colors. | |||
* | |||
* Each color is selected randomly and tinted with a fixed tint. | |||
* | |||
* @param keys The keys to create the mapped colors. | |||
* @return The map of key to random color. | |||
*/ | |||
public <T> Map<T, String> getRandomColorMap(Set<T> keys) { | |||
Map<T, String> colorMap = new HashMap<T, String>(); | |||
for (T key : keys) { | |||
Color color = tintColor(getRandomColor(), FIXED_TINT_FACTOR); | |||
colorMap.put(key, getColorString(color)); | |||
} | |||
return colorMap; | |||
} | |||
private Color getRandomColor() { | |||
Random random = new Random(); | |||
Color randomColor = new Color(random.nextInt(256), random.nextInt(256), | |||
random.nextInt(256)); | |||
return randomColor; | |||
} | |||
private Color tintColor(Color origColor, double tintFactor) { | |||
int tintedRed = applyTint(origColor.getRed(), tintFactor); | |||
int tintedGreen = applyTint(origColor.getGreen(), tintFactor); | |||
int tintedBlue = applyTint(origColor.getBlue(), tintFactor); | |||
Color tintedColor = new Color(tintedRed, tintedGreen, tintedBlue); | |||
return tintedColor; | |||
} | |||
/** | |||
* Convert the color to an HTML compatible color string in hex format E.g. | |||
* #FF0000 | |||
* | |||
* @param color The color to convert | |||
* @return The string version of the color I.e. #RRGGBB | |||
*/ | |||
private String getColorString(Color color) { | |||
return "#" + Integer.toHexString(color.getRGB() & 0x00ffffff); | |||
} | |||
/** | |||
* Tint the supplied color with a tint factor (0 to 1 inclusive) to make the | |||
* colour more pale I.e. closer to white. | |||
* | |||
* A Tint of 0 has no effect, a Tint of 1 turns the color white. | |||
* | |||
* @param color The original color | |||
* @param tintFactor The factor - 0 to 1 inclusive | |||
* @return The tinted color. | |||
*/ | |||
private int applyTint(int color, double tintFactor) { | |||
return (int) (color + ((255 - color) * tintFactor)); | |||
} | |||
} |
@@ -8,6 +8,7 @@ gb.tags = tags | |||
gb.author = author | |||
gb.committer = committer | |||
gb.commit = commit | |||
gb.age = age | |||
gb.tree = tree | |||
gb.parent = parent | |||
gb.url = URL |
@@ -434,6 +434,16 @@ public class WicketUtils { | |||
parameterMap.put("pg", String.valueOf(pageNumber)); | |||
return new PageParameters(parameterMap); | |||
} | |||
public static PageParameters newBlameTypeParameter(String repositoryName, | |||
String commitId, String path, String blameType) { | |||
Map<String, String> parameterMap = new HashMap<String, String>(); | |||
parameterMap.put("r", repositoryName); | |||
parameterMap.put("h", commitId); | |||
parameterMap.put("f", path); | |||
parameterMap.put("blametype", blameType); | |||
return new PageParameters(parameterMap); | |||
} | |||
public static String getProjectName(PageParameters params) { | |||
return params.getString("p", ""); |
@@ -21,17 +21,19 @@ | |||
<div wicket:id="missingBlob">[missing blob]</div> | |||
<!-- blame content --> | |||
<table class="annotated" style="margin-bottom:5px;"> | |||
<table class="annotated" style="margin-bottom:0px;"> | |||
<tbody> | |||
<tr> | |||
<th><wicket:message key="gb.commit">[commit]</wicket:message></th> | |||
<th><wicket:message key="gb.line">[line]</wicket:message></th> | |||
<th><wicket:message key="gb.content">[content]</wicket:message></th> | |||
<td colspan="3" class="rightAlign" style="background-color:#fbfbfb;"> | |||
<span class="link" style="padding-right:10px;"> | |||
<a wicket:id="blameByCommitLink"><wicket:message key="gb.commit">[blamebycommit]</wicket:message></a> | <a wicket:id="blameByAuthorLink"><wicket:message key="gb.author">[blamebyauthor]</wicket:message></a> | <a wicket:id="blameByAgeLink"><wicket:message key="gb.age">[blamebyage]</wicket:message></a> | |||
</span> | |||
</td> | |||
</tr> | |||
<tr wicket:id="annotation"> | |||
<td><span class="sha1" wicket:id="commit"></span></td> | |||
<td><span class="sha1" wicket:id="line"></span></td> | |||
<td><span class="sha1" wicket:id="data"></span></td> | |||
<tr wicket:id="blameView"> | |||
<td class="lineCommit"><span class="sha1" wicket:id="commit"></span></td> | |||
<td class="lineNumber"><span class="sha1" wicket:id="line"></span></td> | |||
<td class="lineContent sha1" wicket:id="data"></td> | |||
</tr> | |||
</tbody> | |||
</table> |
@@ -15,12 +15,21 @@ | |||
*/ | |||
package com.gitblit.wicket.pages; | |||
import java.awt.Color; | |||
import java.text.DateFormat; | |||
import java.text.MessageFormat; | |||
import java.text.SimpleDateFormat; | |||
import java.util.Comparator; | |||
import java.util.Date; | |||
import java.util.HashSet; | |||
import java.util.List; | |||
import java.util.Map; | |||
import java.util.Set; | |||
import java.util.TreeSet; | |||
import org.apache.wicket.Component; | |||
import org.apache.wicket.PageParameters; | |||
import org.apache.wicket.behavior.SimpleAttributeModifier; | |||
import org.apache.wicket.markup.html.basic.Label; | |||
import org.apache.wicket.markup.html.link.BookmarkablePageLink; | |||
import org.apache.wicket.markup.repeater.Item; | |||
@@ -33,6 +42,7 @@ import org.eclipse.jgit.revwalk.RevCommit; | |||
import com.gitblit.Keys; | |||
import com.gitblit.models.AnnotatedLine; | |||
import com.gitblit.models.PathModel; | |||
import com.gitblit.utils.ColorFactory; | |||
import com.gitblit.utils.DiffUtils; | |||
import com.gitblit.utils.JGitUtils; | |||
import com.gitblit.utils.StringUtils; | |||
@@ -46,11 +56,43 @@ import com.gitblit.wicket.panels.PathBreadcrumbsPanel; | |||
@CacheControl(LastModified.BOOT) | |||
public class BlamePage extends RepositoryPage { | |||
/** | |||
* The different types of Blame visualizations. | |||
*/ | |||
private enum BlameType { | |||
COMMIT, | |||
AUTHOR, | |||
AGE; | |||
private BlameType() { | |||
} | |||
public static BlameType get(String name) { | |||
for (BlameType blameType : BlameType.values()) { | |||
if (blameType.name().equalsIgnoreCase(name)) { | |||
return blameType; | |||
} | |||
} | |||
throw new IllegalArgumentException("Unknown Blame Type [" + name | |||
+ "]"); | |||
} | |||
@Override | |||
public String toString() { | |||
return name().toLowerCase(); | |||
} | |||
} | |||
public BlamePage(PageParameters params) { | |||
super(params); | |||
final String blobPath = WicketUtils.getPath(params); | |||
final String blameTypeParam = params.getString("blametype", BlameType.COMMIT.toString()); | |||
final BlameType activeBlameType = BlameType.get(blameTypeParam); | |||
RevCommit commit = getCommit(); | |||
add(new BookmarkablePageLink<Void>("blobLink", BlobPage.class, | |||
@@ -66,6 +108,26 @@ public class BlamePage extends RepositoryPage { | |||
add(new BookmarkablePageLink<Void>("historyLink", HistoryPage.class, | |||
WicketUtils.newPathParameter(repositoryName, objectId, blobPath))); | |||
// "Blame by" links | |||
for (BlameType type : BlameType.values()) { | |||
String typeString = type.toString(); | |||
PageParameters blameTypePageParam = | |||
WicketUtils.newBlameTypeParameter(repositoryName, commit.getName(), | |||
WicketUtils.getPath(params), typeString); | |||
String blameByLinkText = "blameBy" | |||
+ Character.toUpperCase(typeString.charAt(0)) + typeString.substring(1) | |||
+ "Link"; | |||
BookmarkablePageLink<Void> blameByPageLink = | |||
new BookmarkablePageLink<Void>(blameByLinkText, BlamePage.class, blameTypePageParam); | |||
if (activeBlameType == type) { | |||
blameByPageLink.add(new SimpleAttributeModifier("style", "font-weight:bold;")); | |||
} | |||
add(blameByPageLink); | |||
} | |||
add(new CommitHeaderPanel("commitHeader", repositoryName, commit)); | |||
add(new PathBreadcrumbsPanel("breadcrumbs", repositoryName, blobPath, objectId)); | |||
@@ -93,23 +155,21 @@ public class BlamePage extends RepositoryPage { | |||
add(new Label("missingBlob").setVisible(false)); | |||
List<AnnotatedLine> lines = DiffUtils.blame(getRepository(), blobPath, objectId); | |||
final Map<?, String> colorMap = initializeColors(activeBlameType, lines); | |||
ListDataProvider<AnnotatedLine> blameDp = new ListDataProvider<AnnotatedLine>(lines); | |||
DataView<AnnotatedLine> blameView = new DataView<AnnotatedLine>("annotation", blameDp) { | |||
DataView<AnnotatedLine> blameView = new DataView<AnnotatedLine>("blameView", blameDp) { | |||
private static final long serialVersionUID = 1L; | |||
private int count; | |||
private String lastCommitId = ""; | |||
private boolean showInitials = true; | |||
private String zeroId = ObjectId.zeroId().getName(); | |||
@Override | |||
public void populateItem(final Item<AnnotatedLine> item) { | |||
AnnotatedLine entry = item.getModelObject(); | |||
item.add(new Label("line", "" + entry.lineNumber)); | |||
item.add(new Label("data", StringUtils.escapeForHtml(entry.data, true)) | |||
.setEscapeModelStrings(false)); | |||
final AnnotatedLine entry = item.getModelObject(); | |||
// commit id and author | |||
if (!lastCommitId.equals(entry.commitId)) { | |||
lastCommitId = entry.commitId; | |||
count++; | |||
if (zeroId.equals(entry.commitId)) { | |||
// unknown commit | |||
item.add(new Label("commit", "<?>")); | |||
@@ -122,6 +182,7 @@ public class BlamePage extends RepositoryPage { | |||
WicketUtils.setHtmlTooltip(commitLink, | |||
MessageFormat.format("{0}, {1}", entry.author, df.format(entry.when))); | |||
item.add(commitLink); | |||
WicketUtils.setCssStyle(item, "border-top: 1px solid #ddd;"); | |||
showInitials = true; | |||
} | |||
} else { | |||
@@ -134,11 +195,26 @@ public class BlamePage extends RepositoryPage { | |||
item.add(new Label("commit").setVisible(false)); | |||
} | |||
} | |||
if (count % 2 == 0) { | |||
WicketUtils.setCssClass(item, "even"); | |||
} else { | |||
WicketUtils.setCssClass(item, "odd"); | |||
// line number | |||
item.add(new Label("line", "" + entry.lineNumber)); | |||
// line content | |||
String color; | |||
switch (activeBlameType) { | |||
case AGE: | |||
color = colorMap.get(entry.when); | |||
break; | |||
case AUTHOR: | |||
color = colorMap.get(entry.author); | |||
break; | |||
default: | |||
color = colorMap.get(entry.commitId); | |||
break; | |||
} | |||
Component data = new Label("data", StringUtils.escapeForHtml(entry.data, true)).setEscapeModelStrings(false); | |||
data.add(new SimpleAttributeModifier("style", "background-color: " + color + ";")); | |||
item.add(data); | |||
} | |||
}; | |||
add(blameView); | |||
@@ -171,4 +247,40 @@ public class BlamePage extends RepositoryPage { | |||
sb.append("</div>"); | |||
return sb.toString(); | |||
} | |||
private Map<?, String> initializeColors(BlameType blameType, List<AnnotatedLine> lines) { | |||
ColorFactory colorFactory = new ColorFactory(); | |||
Map<?, String> colorMap; | |||
if (BlameType.AGE == blameType) { | |||
Set<Date> keys = new TreeSet<Date>(new Comparator<Date>() { | |||
@Override | |||
public int compare(Date o1, Date o2) { | |||
// younger code has a brighter, older code lightens to white | |||
return o1.compareTo(o2); | |||
} | |||
}); | |||
for (AnnotatedLine line : lines) { | |||
keys.add(line.when); | |||
} | |||
// TODO consider making this a setting | |||
colorMap = colorFactory.getGraduatedColorMap(keys, Color.decode("#FFA63A")); | |||
} else { | |||
Set<String> keys = new HashSet<String>(); | |||
for (AnnotatedLine line : lines) { | |||
if (blameType == BlameType.AUTHOR) { | |||
keys.add(line.author); | |||
} else { | |||
keys.add(line.commitId); | |||
} | |||
} | |||
colorMap = colorFactory.getRandomColorMap(keys); | |||
} | |||
return colorMap; | |||
} | |||
} |
@@ -1318,6 +1318,7 @@ table.gitnotes td.message { | |||
} | |||
table.annotated { | |||
width: 100%; | |||
border:1px solid #ddd; | |||
} | |||
@@ -1334,6 +1335,24 @@ table.annotated td { | |||
border: 0; | |||
} | |||
table.annotated td.lineCommit { | |||
padding-left: 5px; | |||
padding-right: 5px; | |||
} | |||
table.annotated td.lineNumber { | |||
border-right: 1px solid #ddd; | |||
border-left: 1px solid #ddd; | |||
padding-left: 5px; | |||
padding-right: 5px; | |||
text-align: right; | |||
} | |||
table.annotated td.lineContent { | |||
padding-left: 5px; | |||
font: monospace; | |||
} | |||
table.activity { | |||
width: 100%; | |||
margin-top: 10px; | |||
@@ -1379,7 +1398,7 @@ td.date { | |||
white-space: nowrap; | |||
} | |||
span.sha1, span.sha1 a, span.sha1 a span, .commit_message, span.shortsha1 { | |||
span.sha1, span.sha1 a, span.sha1 a span, .commit_message, span.shortsha1, td.sha1 { | |||
font-family: consolas, monospace; | |||
font-size: 13px; | |||
} |