mirror of
https://github.com/gitbucket/gitbucket.git
synced 2025-11-11 16:05:49 +01:00
Merge branch 'feature/blame' of https://github.com/team-lab/gitbucket into team-lab-feature/blame
# Conflicts: # src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala # src/main/webapp/assets/common/css/gitbucket.css
This commit is contained in:
@@ -32,7 +32,6 @@ class RepositoryViewerController extends RepositoryViewerControllerBase
|
||||
with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService with CommitStatusService
|
||||
with WebHookPullRequestService
|
||||
|
||||
|
||||
/**
|
||||
* The repository viewer.
|
||||
*/
|
||||
@@ -284,10 +283,9 @@ trait RepositoryViewerControllerBase extends ControllerBase {
|
||||
/**
|
||||
* Displays the file content of the specified branch or commit.
|
||||
*/
|
||||
get("/:owner/:repository/blob/*")(referrersOnly { repository =>
|
||||
val blobRoute = get("/:owner/:repository/blob/*")(referrersOnly { repository =>
|
||||
val (id, path) = splitPath(repository, multiParams("splat").head)
|
||||
val raw = params.get("raw").getOrElse("false").toBoolean
|
||||
|
||||
using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git =>
|
||||
val revCommit = JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(id))
|
||||
getPathObjectId(git, path, revCommit).map { objectId =>
|
||||
@@ -300,13 +298,45 @@ trait RepositoryViewerControllerBase extends ControllerBase {
|
||||
html.blob(id, repository, path.split("/").toList,
|
||||
JGitUtil.getContentInfo(git, path, objectId),
|
||||
new JGitUtil.CommitInfo(JGitUtil.getLastModifiedCommit(git, revCommit, path)),
|
||||
hasWritePermission(repository.owner, repository.name, context.loginAccount)
|
||||
)
|
||||
hasWritePermission(repository.owner, repository.name, context.loginAccount),
|
||||
request.paths(2) == "blame")
|
||||
}
|
||||
} getOrElse NotFound
|
||||
}
|
||||
})
|
||||
|
||||
get("/:owner/:repository/blame/*"){
|
||||
blobRoute.action()
|
||||
}
|
||||
|
||||
/**
|
||||
* Blame data.
|
||||
*/
|
||||
ajaxGet("/:owner/:repository/get-blame/*")(referrersOnly { repository =>
|
||||
val (id, path) = splitPath(repository, multiParams("splat").head)
|
||||
contentType = formats("json")
|
||||
using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git =>
|
||||
val last = git.log.add(git.getRepository.resolve(id)).addPath(path).setMaxCount(1).call.iterator.next.name
|
||||
Map(
|
||||
"root" -> s"${context.baseUrl}/${repository.owner}/${repository.name}",
|
||||
"id" -> id,
|
||||
"path" -> path,
|
||||
"last" -> last,
|
||||
"blame" -> JGitUtil.getBlame(git, id, path).map{ blame =>
|
||||
Map(
|
||||
"id" -> blame.id,
|
||||
"author" -> view.helpers.user(blame.authorName, blame.authorEmailAddress).toString,
|
||||
"avatar" -> view.helpers.avatarLink(blame.authorName, 32, blame.authorEmailAddress).toString,
|
||||
"authed" -> helper.html.datetimeago(blame.authorTime).toString,
|
||||
"prev" -> blame.prev,
|
||||
"prevPath" -> blame.prevPath,
|
||||
"commited" -> blame.commitTime.getTime,
|
||||
"message" -> blame.message,
|
||||
"lines" -> blame.lines)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Displays details of the specified commit.
|
||||
*/
|
||||
|
||||
@@ -138,6 +138,9 @@ object JGitUtil {
|
||||
|
||||
case class BranchInfo(name: String, committerName: String, commitTime: Date, committerEmailAddress:String, mergeInfo: Option[BranchMergeInfo], commitId: String)
|
||||
|
||||
case class BlameInfo(id: String, authorName: String, authorEmailAddress: String, authorTime:java.util.Date,
|
||||
prev: Option[String], prevPath: Option[String], commitTime:java.util.Date, message:String, lines:Set[Int])
|
||||
|
||||
/**
|
||||
* Returns RevCommit from the commit or tag id.
|
||||
*
|
||||
@@ -822,6 +825,36 @@ object JGitUtil {
|
||||
}
|
||||
}
|
||||
|
||||
def getBlame(git: Git, id: String, path: String): Iterable[BlameInfo] = {
|
||||
Option(git.getRepository.resolve(id)).map{ commitId =>
|
||||
val blamer = new org.eclipse.jgit.api.BlameCommand(git.getRepository);
|
||||
blamer.setStartCommit(commitId)
|
||||
blamer.setFilePath(path)
|
||||
val blame = blamer.call()
|
||||
var blameMap = Map[String, JGitUtil.BlameInfo]()
|
||||
var idLine = List[(String, Int)]()
|
||||
val commits = 0.to(blame.getResultContents().size()-1).map{ i =>
|
||||
val c = blame.getSourceCommit(i)
|
||||
if(!blameMap.contains(c.name)){
|
||||
blameMap += c.name -> JGitUtil.BlameInfo(
|
||||
c.name,
|
||||
c.getAuthorIdent.getName,
|
||||
c.getAuthorIdent.getEmailAddress,
|
||||
c.getAuthorIdent.getWhen,
|
||||
Option(git.log.add(c).addPath(blame.getSourcePath(i)).setSkip(1).setMaxCount(2).call.iterator.next)
|
||||
.map(_.name),
|
||||
if(blame.getSourcePath(i)==path){ None }else{ Some(blame.getSourcePath(i)) },
|
||||
c.getCommitterIdent.getWhen,
|
||||
c.getShortMessage,
|
||||
Set.empty)
|
||||
}
|
||||
idLine :+= (c.name, i)
|
||||
}
|
||||
val limeMap = idLine.groupBy(_._1).mapValues(_.map(_._2).toSet)
|
||||
blameMap.values.map{b => b.copy(lines=limeMap(b.id))}
|
||||
}.getOrElse(Seq.empty)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sha1
|
||||
* @param owner repository owner
|
||||
|
||||
@@ -122,8 +122,8 @@ object helpers extends AvatarImageProvider with LinkConverter with RequestCache
|
||||
* Returns <img> which displays the avatar icon for the given user name.
|
||||
* This method looks up Gravatar if avatar icon has not been configured in user settings.
|
||||
*/
|
||||
def avatar(userName: String, size: Int, tooltip: Boolean = false)(implicit context: Context): Html =
|
||||
getAvatarImageHtml(userName, size, "", tooltip)
|
||||
def avatar(userName: String, size: Int, tooltip: Boolean = false, mailAddress: String = "")(implicit context: Context): Html =
|
||||
getAvatarImageHtml(userName, size, mailAddress, tooltip)
|
||||
|
||||
/**
|
||||
* Returns <img> which displays the avatar icon for the given mail address.
|
||||
@@ -203,7 +203,7 @@ object helpers extends AvatarImageProvider with LinkConverter with RequestCache
|
||||
* If user does not exist or disabled, this method returns avatar image without link.
|
||||
*/
|
||||
def avatarLink(userName: String, size: Int, mailAddress: String = "", tooltip: Boolean = false)(implicit context: Context): Html =
|
||||
userWithContent(userName, mailAddress)(avatar(userName, size, tooltip))
|
||||
userWithContent(userName, mailAddress)(avatar(userName, size, tooltip, mailAddress))
|
||||
|
||||
private def userWithContent(userName: String, mailAddress: String = "", styleClass: String = "")(content: Html)(implicit context: Context): Html =
|
||||
(if(mailAddress.isEmpty){
|
||||
|
||||
@@ -3,12 +3,29 @@
|
||||
pathList: List[String],
|
||||
content: gitbucket.core.util.JGitUtil.ContentInfo,
|
||||
latestCommit: gitbucket.core.util.JGitUtil.CommitInfo,
|
||||
hasWritePermission: Boolean)(implicit context: gitbucket.core.controller.Context)
|
||||
hasWritePermission: Boolean,
|
||||
isBlame: Boolean)(implicit context: gitbucket.core.controller.Context)
|
||||
@import context._
|
||||
@import gitbucket.core.view.helpers._
|
||||
@html.main(s"${repository.owner}/${repository.name}", Some(repository)) {
|
||||
@html.menu("code", repository){
|
||||
<div class="head">
|
||||
<div class="line-age-legend">
|
||||
<span>Newer</span>
|
||||
<ol>
|
||||
<li class="heat1"></li>
|
||||
<li class="heat2"></li>
|
||||
<li class="heat3"></li>
|
||||
<li class="heat4"></li>
|
||||
<li class="heat5"></li>
|
||||
<li class="heat6"></li>
|
||||
<li class="heat7"></li>
|
||||
<li class="heat8"></li>
|
||||
<li class="heat9"></li>
|
||||
<li class="heat10"></li>
|
||||
</ol>
|
||||
<span>Older</span>
|
||||
</div>
|
||||
@helper.html.branchcontrol(
|
||||
branch,
|
||||
repository,
|
||||
@@ -42,6 +59,9 @@
|
||||
<a class="btn btn-mini" href="@url(repository)/edit/@encodeRefName(branch)/@pathList.mkString("/")">Edit</a>
|
||||
}
|
||||
<a class="btn btn-mini" href="?raw=true">Raw</a>
|
||||
@if(content.viewType == "text"){
|
||||
<a class="btn btn-mini blame-action" href="@url(repository)/blame/@latestCommit.id/@pathList.mkString("/")" data-url="@url(repository)/get-blame/@latestCommit.id/@pathList.mkString("/")" data-repository="@url(repository)">Blame</a>
|
||||
}
|
||||
<a class="btn btn-mini" href="@url(repository)/commits/@encodeRefName(branch)/@pathList.mkString("/")">History</a>
|
||||
@if(hasWritePermission){
|
||||
<a class="btn btn-mini btn-danger" href="@url(repository)/remove/@encodeRefName(branch)/@pathList.mkString("/")">Delete</a>
|
||||
@@ -52,13 +72,13 @@
|
||||
<tr>
|
||||
<td>
|
||||
@if(content.viewType == "text"){
|
||||
@defining(pathList.reverse.head) { file =>
|
||||
@if(renderableSuffixes.find(suffix => file.toLowerCase.endsWith(suffix))) {
|
||||
@defining(renderableSuffixes.find(suffix => pathList.reverse.head.toLowerCase.endsWith(suffix))) { isRrenderable =>
|
||||
@if(!isBlame && isRrenderable) {
|
||||
<div class="box-content markdown-body" style="border: none; padding-left: 16px; padding-right: 16px;">
|
||||
@renderMarkup(pathList, content.content.get, branch, repository, false, false)
|
||||
</div>
|
||||
} else {
|
||||
<pre class="prettyprint linenums blob">@content.content.get</pre>
|
||||
<pre class="prettyprint linenums blob @if(!isRrenderable){ no-renderable } ">@content.content.get</pre>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,30 +104,111 @@ $(window).load(function(){
|
||||
updateHighlighting();
|
||||
}).hashchange();
|
||||
|
||||
$('pre.prettyprint ol.linenums li').each(function(i, e){
|
||||
var pre = $('pre.prettyprint');
|
||||
pre.append($('<div class="source-line-num">')
|
||||
.data('line', (i + 1))
|
||||
.css({
|
||||
function updateSourceLineNum(){
|
||||
$('.source-line-num').remove();
|
||||
var pos = pre.find('ol.linenums').position();
|
||||
$('<div class="source-line-num">').css({
|
||||
height:pre.height(),
|
||||
width:'48px',
|
||||
cursor:'pointer',
|
||||
position: 'absolute',
|
||||
top : $(e).position().top + 'px',
|
||||
left : pre.position().left + 'px',
|
||||
width : ($(e).position().left - pre.position().left) + 'px',
|
||||
height : '16px'
|
||||
}));
|
||||
});
|
||||
|
||||
$('div.source-line-num').click(function(e){
|
||||
var line = $(e.target).data('line');
|
||||
top : pos.top + 'px',
|
||||
left : pos.left + 'px'
|
||||
}).click(function(e){
|
||||
$(window).hashchange(function(){})
|
||||
var pos = $(this).data("pos");
|
||||
if(!pos){
|
||||
pos = $('ol.linenums li').map(function(){ return {id:$(this).attr("id"),top:$(this).position().top} }).toArray();
|
||||
$(this).data("pos",pos);
|
||||
}
|
||||
for(var i=0;i<pos.length-1;i++){
|
||||
if(pos[i+1].top>e.pageY){
|
||||
break;
|
||||
}
|
||||
}
|
||||
var line = pos[i].id.replace(/^L/,'');
|
||||
var hash = location.hash;
|
||||
if(e.shiftKey == true && hash.match(/#L\d+(-L\d+)?/)){
|
||||
var lines = hash.split('-');
|
||||
location.hash = lines[0] + '-L' + line;
|
||||
} else {
|
||||
var p = $("#L"+line).attr('id',"");
|
||||
location.hash = '#L' + line;
|
||||
p.attr('id','L'+line);
|
||||
}
|
||||
}).appendTo(pre);
|
||||
}
|
||||
var repository = $('.blame-action').data('repository');
|
||||
$('.blame-action').click(function(e){
|
||||
if(history.pushState && $('pre.prettyprint.no-renderable').length){
|
||||
e.preventDefault();
|
||||
history.pushState(null, null, this.href);
|
||||
updateBlame();
|
||||
}
|
||||
});
|
||||
|
||||
function updateBlame(){
|
||||
var m = /^\/(blame|blob)(\/.*)$/.exec(location.pathname.substring(repository.length));
|
||||
var mode = m[1];
|
||||
$('.blame-action').toggleClass("active", mode=='blame').attr('href', repository + (m[1]=='blame'?'/blob':'/blame')+m[2]);
|
||||
if(pre.parents("td").find(".blame").length){
|
||||
pre.parents("div.container").toggleClass("blame-container", mode=='blame');
|
||||
updateSourceLineNum();
|
||||
return;
|
||||
}
|
||||
if(mode=='blob'){
|
||||
updateSourceLineNum();
|
||||
return;
|
||||
}
|
||||
$(document.body).toggleClass('no-box-shadow',document.body.style.boxShadow===undefined);
|
||||
$('.blame-action').addClass("active");
|
||||
var base = $('<div class="blame">').css({height:pre.height()}).prependTo(pre.parents("td")[0]);
|
||||
base.parents("div.container").addClass("blame-container");
|
||||
updateSourceLineNum();
|
||||
$.get($('.blame-action').data('url')).done(function(data){
|
||||
var blame = data.blame;
|
||||
var index = [];
|
||||
for(var i=0;i<blame.length;i++){
|
||||
for(var j=0;j<blame[i].lines.length;j++){
|
||||
index[blame[i].lines[j]]=blame[i];
|
||||
}
|
||||
}
|
||||
var blame, lastDiv, now=new Date().getTime();
|
||||
|
||||
$('pre.prettyprint ol.linenums li').each(function(i, e){
|
||||
var p=$(e).position();
|
||||
var h=$(e).height();
|
||||
if(blame == index[i]){
|
||||
lastDiv.css("min-height",(p.top+h+1) - lastDiv.position().top);
|
||||
}else{
|
||||
$(e).addClass('blame-sep')
|
||||
blame = index[i];
|
||||
var sha = $('<div class="blame-sha">')
|
||||
.append($('<a>').attr("href",data.root+'/commit/'+blame.id).text(blame.id.substr(0,7)));
|
||||
if(blame.prev){
|
||||
sha.append($('<br />'))
|
||||
.append($('<a class="muted-link">').text('prev').attr("href",data.root+'/blame/'+blame.prev+'/'+(blame.prevPath||data.path)));
|
||||
}
|
||||
lastDiv = $('<div class="blame-info">')
|
||||
.addClass('heat'+Math.min(10,Math.max(1,Math.ceil((now-blame.commited)/(24*3600*1000*70)))))
|
||||
.toggleClass('blame-last',blame.id==data.last)
|
||||
.data('line', (i + 1))
|
||||
.css({
|
||||
"top" : p.top + 'px',
|
||||
"min-height" : h+'px'
|
||||
})
|
||||
.append(sha)
|
||||
.append($(blame.avatar).addClass('avatar').css({"float":"left"}))
|
||||
.append($('<div class="blame-commit-title">').text(blame.message))
|
||||
.append($('<div class="muted">').html(blame.author+ " authed "+blame.authed))
|
||||
.appendTo(base);
|
||||
}
|
||||
});
|
||||
});
|
||||
return false;
|
||||
};
|
||||
updateBlame();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -120,15 +221,20 @@ function updateHighlighting(){
|
||||
var lines = hash.substr(1).split('-');
|
||||
if(lines.length == 1){
|
||||
$('#' + lines[0]).addClass('highlight');
|
||||
if(!updateHighlighting.scrolling){
|
||||
$(window).scrollTop($('#' + lines[0]).offset().top - 40);
|
||||
}
|
||||
} else if(lines.length > 1){
|
||||
var start = parseInt(lines[0].substr(1));
|
||||
var end = parseInt(lines[1].substr(1));
|
||||
for(var i = start; i <= end; i++){
|
||||
$('#L' + i).addClass('highlight');
|
||||
}
|
||||
if(!updateHighlighting.scrolling){
|
||||
$(window).scrollTop($('#L' + start).offset().top - 40);
|
||||
}
|
||||
}
|
||||
updateHighlighting.scrolling = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1440,3 +1440,122 @@ h6 a.markdown-anchor-link {
|
||||
#tree-finder-results .navigation-focus td{
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/****************************************************************************/
|
||||
/* blame */
|
||||
/****************************************************************************/
|
||||
.blobview pre.blob{
|
||||
padding-left: 0;
|
||||
}
|
||||
.blobview ol.linenums{
|
||||
margin-left: 0;
|
||||
padding-left: 50px;
|
||||
}
|
||||
|
||||
div.container.blame-container{
|
||||
width:1270px;
|
||||
}
|
||||
.line-age-legend {
|
||||
display: none;
|
||||
}
|
||||
.blame-container .line-age-legend {
|
||||
display: block;
|
||||
float: right;
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
.blame-container .line-age-legend ol {
|
||||
display: inline-block;
|
||||
*display: inline;
|
||||
*zoom: 1;
|
||||
list-style: none;
|
||||
margin: 0 5px;
|
||||
}
|
||||
.blame-container .line-age-legend ol li {
|
||||
display: inline-block;
|
||||
*display: inline;
|
||||
*zoom: 1;
|
||||
width: 8px;
|
||||
height: 10px;
|
||||
}
|
||||
.blame-container pre.blob{
|
||||
margin-left: 350px;
|
||||
}
|
||||
.blame-container pre.prettyprint ol.linenums li.blame-sep{
|
||||
border-top: 1px solid rgb(219, 219, 219);
|
||||
margin-top: -1px;
|
||||
}
|
||||
.blame{
|
||||
font-size: 12px;
|
||||
white-space: normal;
|
||||
width: 340px;
|
||||
float: left;
|
||||
min-height: 100px;
|
||||
display: none;
|
||||
}
|
||||
.blame-container .blame{
|
||||
display: block;
|
||||
}
|
||||
.blame .blame-commit-title{
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.blame .avatar{
|
||||
margin-right: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.blame .blame-info{
|
||||
background: white;
|
||||
box-shadow:rgba(113, 135, 164, 0.65098) 0px 0px 4px 0px;
|
||||
position: absolute;
|
||||
width: 340px;
|
||||
padding: 2px;
|
||||
border-right: 2px solid;
|
||||
}
|
||||
.no-box-shadow .blame .blame-info{
|
||||
border-top: 1px solid #888;
|
||||
border-bottom: 1px solid #888;
|
||||
border-left: 1px solid #888;
|
||||
}
|
||||
.blame-sha{
|
||||
font: 11px Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
float: right;
|
||||
text-align: right;
|
||||
}
|
||||
.blame-sha .muted-link{
|
||||
color: #777;
|
||||
}
|
||||
.blame-sha .muted-link:hover{
|
||||
color: #4183c4;
|
||||
}
|
||||
|
||||
.blame .blame-info:hover{
|
||||
z-index: 100;
|
||||
box-shadow:rgba(113, 135, 164, 0.65098) 0px 0px 4px 3px;
|
||||
}
|
||||
.blame .blame-info.blame-last{
|
||||
background: #FDFCED;
|
||||
}
|
||||
.blame-info.heat1{ border-right-color:#ffeca7}
|
||||
.blame-info.heat2{ border-right-color:#ffdd8c}
|
||||
.blame-info.heat3{ border-right-color:#ffdd7c}
|
||||
.blame-info.heat4{ border-right-color:#fba447}
|
||||
.blame-info.heat5{ border-right-color:#f68736}
|
||||
.blame-info.heat6{ border-right-color:#f37636}
|
||||
.blame-info.heat7{ border-right-color:#ca6632}
|
||||
.blame-info.heat8{ border-right-color:#c0513f}
|
||||
.blame-info.heat9{ border-right-color:#a2503a}
|
||||
.blame-info.heat10{border-right-color:#793738}
|
||||
|
||||
.heat1{background-color:#ffeca7}
|
||||
.heat2{background-color:#ffdd8c}
|
||||
.heat3{background-color:#ffdd7c}
|
||||
.heat4{background-color:#fba447}
|
||||
.heat5{background-color:#f68736}
|
||||
.heat6{background-color:#f37636}
|
||||
.heat7{background-color:#ca6632}
|
||||
.heat8{background-color:#c0513f}
|
||||
.heat9{background-color:#a2503a}
|
||||
.heat10{background-color:#793738}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user