diff --git a/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala b/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala index 5efcb6a..c4fd085 100644 --- a/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala +++ b/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala @@ -32,7 +32,6 @@ with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService with CommitStatusService with WebHookPullRequestService - /** * The repository viewer. */ @@ -284,10 +283,9 @@ /** * 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 @@ 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. */ diff --git a/src/main/scala/gitbucket/core/util/JGitUtil.scala b/src/main/scala/gitbucket/core/util/JGitUtil.scala index c2491e8..fc6db5c 100644 --- a/src/main/scala/gitbucket/core/util/JGitUtil.scala +++ b/src/main/scala/gitbucket/core/util/JGitUtil.scala @@ -138,6 +138,9 @@ 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 @@ } } + 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 diff --git a/src/main/scala/gitbucket/core/view/helpers.scala b/src/main/scala/gitbucket/core/view/helpers.scala index 3b7099f..3d52c21 100644 --- a/src/main/scala/gitbucket/core/view/helpers.scala +++ b/src/main/scala/gitbucket/core/view/helpers.scala @@ -122,8 +122,8 @@ * 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 @@ * 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){ diff --git a/src/main/twirl/gitbucket/core/repo/blob.scala.html b/src/main/twirl/gitbucket/core/repo/blob.scala.html index 99f1062..ffe865f 100644 --- a/src/main/twirl/gitbucket/core/repo/blob.scala.html +++ b/src/main/twirl/gitbucket/core/repo/blob.scala.html @@ -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){
+
+ Newer +
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
  7. +
  8. +
  9. +
  10. +
+ Older +
@helper.html.branchcontrol( branch, repository, @@ -42,6 +59,9 @@ Edit } Raw + @if(content.viewType == "text"){ + Blame + } History @if(hasWritePermission){ Delete @@ -52,13 +72,13 @@ @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) {
@renderMarkup(pathList, content.content.get, branch, repository, false, false)
} else { -
@content.content.get
+
@content.content.get
} } } @@ -84,30 +104,111 @@ updateHighlighting(); }).hashchange(); - $('pre.prettyprint ol.linenums li').each(function(i, e){ - var pre = $('pre.prettyprint'); - pre.append($('
') - .data('line', (i + 1)) - .css({ - cursor : 'pointer', + var pre = $('pre.prettyprint'); + function updateSourceLineNum(){ + $('.source-line-num').remove(); + var pos = pre.find('ol.linenums').position(); + $('
').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'); - 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 { - location.hash = '#L' + 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;ie.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 = $('
').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') + .append($('').attr("href",data.root+'/commit/'+blame.id).text(blame.id.substr(0,7))); + if(blame.prev){ + sha.append($('
')) + .append($('
').text('prev').attr("href",data.root+'/blame/'+blame.prev+'/'+(blame.prevPath||data.path))); + } + lastDiv = $('
') + .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($('
').text(blame.message)) + .append($('
').html(blame.author+ " authed "+blame.authed)) + .appendTo(base); + } + }); + }); + return false; + }; + updateBlame(); }); /** @@ -120,15 +221,20 @@ var lines = hash.substr(1).split('-'); if(lines.length == 1){ $('#' + lines[0]).addClass('highlight'); - $(window).scrollTop($('#' + lines[0]).offset().top - 40); + 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'); } - $(window).scrollTop($('#L' + start).offset().top - 40); + if(!updateHighlighting.scrolling){ + $(window).scrollTop($('#L' + start).offset().top - 40); + } } + updateHighlighting.scrolling = true; } } \ No newline at end of file diff --git a/src/main/webapp/assets/common/css/gitbucket.css b/src/main/webapp/assets/common/css/gitbucket.css index c7e4d38..36ca8ef 100644 --- a/src/main/webapp/assets/common/css/gitbucket.css +++ b/src/main/webapp/assets/common/css/gitbucket.css @@ -1440,3 +1440,122 @@ #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} +