diff --git a/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala b/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala index 629410a..45a6ef6 100644 --- a/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala +++ b/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala @@ -27,7 +27,6 @@ with RepositoryService with AccountService with ActivityService with IssuesService with WebHookService with CommitsService with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService - /** * The repository viewer. */ @@ -207,10 +206,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)) val lastModifiedCommit = JGitUtil.getLastModifiedCommit(git, revCommit, path) @@ -222,12 +220,44 @@ } } else { html.blob(id, repository, path.split("/").toList, JGitUtil.getContentInfo(git, path, objectId), - new JGitUtil.CommitInfo(lastModifiedCommit), hasWritePermission(repository.owner, repository.name, context.loginAccount)) + new JGitUtil.CommitInfo(lastModifiedCommit),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, + "parent" -> blame.parent, + "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 d2d531b..2c0259e 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, + parent: Option[String], commitTime:java.util.Date, message:String, lines:Set[Int]) + /** * Returns RevCommit from the commit or tag id. * @@ -748,4 +751,32 @@ } } } + + 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, + c.getParents().sortBy(_.getCommitTime()).lastOption.map(_.name), + 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) + } } diff --git a/src/main/twirl/gitbucket/core/repo/blob.scala.html b/src/main/twirl/gitbucket/core/repo/blob.scala.html index 99f1062..c5e11df 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,104 @@ updateHighlighting(); }).hashchange(); - $('pre.prettyprint ol.linenums li').each(function(i, e){ + function updateSourceLineNum(){ + $('.source-line-num').remove(); var pre = $('pre.prettyprint'); - pre.append($('
') - .data('line', (i + 1)) - .css({ - 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; + $('pre.prettyprint ol.linenums li').each(function(i, e){ + var p = $(e).position(); + var left = pre.position().left + pre.append($('
') + .data('line', (i + 1)) + .css({ + cursor : 'pointer', + position: 'absolute', + top : p.top + 'px', + width : (p.left - left) + 'px', + left : left, + 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; + } + }); + } + updateSourceLineNum(); + 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]); + var pre = $('pre.prettyprint'); + if(pre.parents("td").find(".blame").length){ + pre.parents("div.container").toggleClass("blame-container", mode=='blame'); + updateSourceLineNum(); + return; + } + if(mode=='blob'){ + return; + } + $('.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+'/commits/'+blame.id).text(blame.id.substr(0,7))); + if(blame.parent){ + sha.append($('
')) + .append($('
').text('prev').attr("href",data.root+'/blame/'+blame.parent+'/'+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(); }); /** diff --git a/src/main/webapp/assets/common/css/gitbucket.css b/src/main/webapp/assets/common/css/gitbucket.css index 6888dfd..c9b6f95 100644 --- a/src/main/webapp/assets/common/css/gitbucket.css +++ b/src/main/webapp/assets/common/css/gitbucket.css @@ -1328,3 +1328,104 @@ h6 a.markdown-anchor-link { top: 6px; } + +/****************************************************************************/ +/* blame */ +/****************************************************************************/ +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; + list-style: none; + margin: 0 5px; +} +.blame-container .line-age-legend ol li { + display: inline-block; + 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; +} +.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}