Link to previous blames in file blame page (#16259)

Adds a link to each blame hunk, to view the blame of an earlier version of the file, similar to GitHub. Also refactors the blame render from fmtstring based to template based.

* Fix blame bottom line and add blame prior button

* Jump to previous parent commit from the commit.

* Fix previous commit link

* Fix previous blame link

* Fix the given file not exist in the previous commit.

* Fix blameRow struct not export

* fix theming issues, rename template var

* remove unused LastCommit fetch

* fix location of blame-hunk divider

* rewrite previous commit checks

* remove duplicate commit lookup

its already resolved and stored in ctx.Repo.Commit!

* split out blamePart processing into function

Co-authored-by: rogerluo410 <rogerluo410@gmail.com>
tokarchuk/v1.17
Norwin 3 years ago committed by GitHub
parent 59c58553ba
commit 9c6aeb47f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      options/locale/locale_en-US.ini
  2. 179
      routers/web/repo/blame.go
  3. 39
      templates/repo/blame.tmpl
  4. 25
      web_src/js/index.js
  5. 17
      web_src/less/_base.less
  6. 6
      web_src/less/themes/theme-arc-green.less

@ -809,6 +809,7 @@ delete_preexisting_label = Delete
delete_preexisting = Delete pre-existing files delete_preexisting = Delete pre-existing files
delete_preexisting_content = Delete files in %s delete_preexisting_content = Delete files in %s
delete_preexisting_success = Deleted unadopted files in %s delete_preexisting_success = Deleted unadopted files in %s
blame_prior = View blame prior to this change
transfer.accept = Accept Transfer transfer.accept = Accept Transfer
transfer.accept_desc = Transfer to "%s" transfer.accept_desc = Transfer to "%s"

@ -5,7 +5,6 @@
package repo package repo
import ( import (
"bytes"
"container/list" "container/list"
"fmt" "fmt"
"html" "html"
@ -18,7 +17,6 @@ import (
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/highlight" "code.gitea.io/gitea/modules/highlight"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
) )
@ -27,6 +25,20 @@ const (
tplBlame base.TplName = "repo/home" tplBlame base.TplName = "repo/home"
) )
type blameRow struct {
RowNumber int
Avatar gotemplate.HTML
RepoLink string
PartSha string
PreviousSha string
PreviousShaURL string
IsFirstCommit bool
CommitURL string
CommitMessage string
CommitSince gotemplate.HTML
Code gotemplate.HTML
}
// RefBlame render blame page // RefBlame render blame page
func RefBlame(ctx *context.Context) { func RefBlame(ctx *context.Context) {
fileName := ctx.Repo.TreePath fileName := ctx.Repo.TreePath
@ -39,19 +51,6 @@ func RefBlame(ctx *context.Context) {
repoName := ctx.Repo.Repository.Name repoName := ctx.Repo.Repository.Name
commitID := ctx.Repo.CommitID commitID := ctx.Repo.CommitID
commit, err := ctx.Repo.GitRepo.GetCommit(commitID)
if err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound("Repo.GitRepo.GetCommit", err)
} else {
ctx.ServerError("Repo.GitRepo.GetCommit", err)
}
return
}
if len(commitID) != 40 {
commitID = commit.ID.String()
}
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()
treeLink := branchLink treeLink := branchLink
rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL()
@ -74,25 +73,6 @@ func RefBlame(ctx *context.Context) {
} }
} }
// Show latest commit info of repository in table header,
// or of directory if not in root directory.
latestCommit := ctx.Repo.Commit
if len(ctx.Repo.TreePath) > 0 {
latestCommit, err = ctx.Repo.Commit.GetCommitByPath(ctx.Repo.TreePath)
if err != nil {
ctx.ServerError("GetCommitByPath", err)
return
}
}
ctx.Data["LatestCommit"] = latestCommit
ctx.Data["LatestCommitVerification"] = models.ParseCommitWithSignature(latestCommit)
ctx.Data["LatestCommitUser"] = models.ValidateCommitWithEmail(latestCommit)
statuses, err := models.GetLatestCommitStatus(ctx.Repo.Repository.ID, ctx.Repo.Commit.ID.String(), models.ListOptions{})
if err != nil {
log.Error("GetLatestCommitStatus: %v", err)
}
// Get current entry user currently looking at. // Get current entry user currently looking at.
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
if err != nil { if err != nil {
@ -102,9 +82,6 @@ func RefBlame(ctx *context.Context) {
blob := entry.Blob() blob := entry.Blob()
ctx.Data["LatestCommitStatus"] = models.CalcCommitStatus(statuses)
ctx.Data["LatestCommitStatuses"] = statuses
ctx.Data["Paths"] = paths ctx.Data["Paths"] = paths
ctx.Data["TreeLink"] = treeLink ctx.Data["TreeLink"] = treeLink
ctx.Data["TreeNames"] = treeNames ctx.Data["TreeNames"] = treeNames
@ -145,8 +122,33 @@ func RefBlame(ctx *context.Context) {
blameParts = append(blameParts, *blamePart) blameParts = append(blameParts, *blamePart)
} }
// Get Topics of this repo
renderRepoTopics(ctx)
if ctx.Written() {
return
}
commitNames, previousCommits := processBlameParts(ctx, blameParts)
if ctx.Written() {
return
}
renderBlame(ctx, blameParts, commitNames, previousCommits)
ctx.HTML(http.StatusOK, tplBlame)
}
func processBlameParts(ctx *context.Context, blameParts []git.BlamePart) (map[string]models.UserCommit, map[string]string) {
// store commit data by SHA to look up avatar info etc
commitNames := make(map[string]models.UserCommit) commitNames := make(map[string]models.UserCommit)
// previousCommits contains links from SHA to parent SHA,
// if parent also contains the current TreePath.
previousCommits := make(map[string]string)
// and as blameParts can reference the same commits multiple
// times, we cache the lookup work locally
commits := list.New() commits := list.New()
commitCache := map[string]*git.Commit{}
commitCache[ctx.Repo.Commit.ID.String()] = ctx.Repo.Commit
for _, part := range blameParts { for _, part := range blameParts {
sha := part.Sha sha := part.Sha
@ -154,14 +156,38 @@ func RefBlame(ctx *context.Context) {
continue continue
} }
commit, err := ctx.Repo.GitRepo.GetCommit(sha) // find the blamePart commit, to look up parent & email address for avatars
if err != nil { commit, ok := commitCache[sha]
if git.IsErrNotExist(err) { var err error
ctx.NotFound("Repo.GitRepo.GetCommit", err) if !ok {
} else { commit, err = ctx.Repo.GitRepo.GetCommit(sha)
ctx.ServerError("Repo.GitRepo.GetCommit", err) if err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound("Repo.GitRepo.GetCommit", err)
} else {
ctx.ServerError("Repo.GitRepo.GetCommit", err)
}
return nil, nil
}
commitCache[sha] = commit
}
// find parent commit
if commit.ParentCount() > 0 {
psha := commit.Parents[0]
previousCommit, ok := commitCache[psha.String()]
if !ok {
previousCommit, _ = commit.Parent(0)
if previousCommit != nil {
commitCache[psha.String()] = previousCommit
}
}
// only store parent commit ONCE, if it has the file
if previousCommit != nil {
if haz1, _ := previousCommit.HasFile(ctx.Repo.TreePath); haz1 {
previousCommits[commit.ID.String()] = previousCommit.ID.String()
}
} }
return
} }
commits.PushBack(commit) commits.PushBack(commit)
@ -169,46 +195,39 @@ func RefBlame(ctx *context.Context) {
commitNames[commit.ID.String()] = models.UserCommit{} commitNames[commit.ID.String()] = models.UserCommit{}
} }
// populate commit email addresses to later look up avatars.
commits = models.ValidateCommitsWithEmails(commits) commits = models.ValidateCommitsWithEmails(commits)
for e := commits.Front(); e != nil; e = e.Next() { for e := commits.Front(); e != nil; e = e.Next() {
c := e.Value.(models.UserCommit) c := e.Value.(models.UserCommit)
commitNames[c.ID.String()] = c commitNames[c.ID.String()] = c
} }
// Get Topics of this repo return commitNames, previousCommits
renderRepoTopics(ctx)
if ctx.Written() {
return
}
renderBlame(ctx, blameParts, commitNames)
ctx.HTML(http.StatusOK, tplBlame)
} }
func renderBlame(ctx *context.Context, blameParts []git.BlamePart, commitNames map[string]models.UserCommit) { func renderBlame(ctx *context.Context, blameParts []git.BlamePart, commitNames map[string]models.UserCommit, previousCommits map[string]string) {
repoLink := ctx.Repo.RepoLink repoLink := ctx.Repo.RepoLink
var lines = make([]string, 0) var lines = make([]string, 0)
rows := make([]*blameRow, 0)
var commitInfo bytes.Buffer
var lineNumbers bytes.Buffer
var codeLines bytes.Buffer
var i = 0 var i = 0
for pi, part := range blameParts { var commitCnt = 0
for _, part := range blameParts {
for index, line := range part.Lines { for index, line := range part.Lines {
i++ i++
lines = append(lines, line) lines = append(lines, line)
var attr = "" br := &blameRow{
if len(part.Lines)-1 == index && len(blameParts)-1 != pi { RowNumber: i,
attr = " bottom-line"
} }
commit := commitNames[part.Sha] commit := commitNames[part.Sha]
previousSha := previousCommits[part.Sha]
if index == 0 { if index == 0 {
// Count commit number
commitCnt++
// User avatar image // User avatar image
commitSince := timeutil.TimeSinceUnix(timeutil.TimeStamp(commit.Author.When.Unix()), ctx.Data["Lang"].(string)) commitSince := timeutil.TimeSinceUnix(timeutil.TimeStamp(commit.Author.When.Unix()), ctx.Data["Lang"].(string))
@ -219,16 +238,14 @@ func renderBlame(ctx *context.Context, blameParts []git.BlamePart, commitNames m
avatar = string(templates.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18, "mr-3")) avatar = string(templates.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18, "mr-3"))
} }
commitInfo.WriteString(fmt.Sprintf(`<div class="blame-info%s"><div class="blame-data"><div class="blame-avatar">%s</div><div class="blame-message"><a href="%s/commit/%s" title="%[5]s">%[5]s</a></div><div class="blame-time">%s</div></div></div>`, attr, avatar, repoLink, part.Sha, html.EscapeString(commit.CommitMessage), commitSince)) br.Avatar = gotemplate.HTML(avatar)
} else { br.RepoLink = repoLink
commitInfo.WriteString(fmt.Sprintf(`<div class="blame-info%s">&#8203;</div>`, attr)) br.PartSha = part.Sha
} br.PreviousSha = previousSha
br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, previousSha, ctx.Repo.TreePath)
//Line number br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, part.Sha)
if len(part.Lines)-1 == index && len(blameParts)-1 != pi { br.CommitMessage = html.EscapeString(commit.CommitMessage)
lineNumbers.WriteString(fmt.Sprintf(`<span id="L%d" data-line-number="%d" class="bottom-line"></span>`, i, i)) br.CommitSince = commitSince
} else {
lineNumbers.WriteString(fmt.Sprintf(`<span id="L%d" data-line-number="%d"></span>`, i, i))
} }
if i != len(lines)-1 { if i != len(lines)-1 {
@ -236,16 +253,12 @@ func renderBlame(ctx *context.Context, blameParts []git.BlamePart, commitNames m
} }
fileName := fmt.Sprintf("%v", ctx.Data["FileName"]) fileName := fmt.Sprintf("%v", ctx.Data["FileName"])
line = highlight.Code(fileName, line) line = highlight.Code(fileName, line)
line = `<code class="code-inner">` + line + `</code>`
if len(part.Lines)-1 == index && len(blameParts)-1 != pi { br.Code = gotemplate.HTML(line)
codeLines.WriteString(fmt.Sprintf(`<li class="L%d bottom-line" rel="L%d">%s</li>`, i, i, line)) rows = append(rows, br)
} else {
codeLines.WriteString(fmt.Sprintf(`<li class="L%d" rel="L%d">%s</li>`, i, i, line))
}
} }
} }
ctx.Data["BlameContent"] = gotemplate.HTML(codeLines.String()) ctx.Data["BlameRows"] = rows
ctx.Data["BlameCommitInfo"] = gotemplate.HTML(commitInfo.String()) ctx.Data["CommitCnt"] = commitCnt
ctx.Data["BlameLineNums"] = gotemplate.HTML(lineNumbers.String())
} }

@ -23,11 +23,40 @@
<div class="file-view code-view"> <div class="file-view code-view">
<table> <table>
<tbody> <tbody>
<tr> {{range $row := .BlameRows}}
<td class="lines-commit">{{.BlameCommitInfo}}</td> <tr class="{{if and (gt $.CommitCnt 1) ($row.CommitMessage)}}top-line-blame{{end}}">
<td class="lines-num">{{.BlameLineNums}}</td> <td class="lines-commit">
<td class="lines-code"><code class="chroma"><ol class="linenums">{{.BlameContent}}</ol></code></td> <div class="blame-info">
</tr> <div class="blame-data">
<div class="blame-avatar">
{{$row.Avatar}}
</div>
<div class="blame-message">
<a href="{{$row.CommitURL}}" title="{{$row.CommitMessage}}">
{{$row.CommitMessage}}
</a>
</div>
<div class="blame-time">
{{$row.CommitSince}}
</div>
</div>
</div>
</td>
<td class="lines-blame-btn">
{{if $row.PreviousSha}}
<a href="{{$row.PreviousShaURL}}" class="poping up" data-content='{{$.i18n.Tr "repo.blame_prior"}}' data-variation="tiny inverted">
{{svg "octicon-versions"}}
</a>
{{end}}
</td>
<td class="lines-num">
<span id="L{{$row.RowNumber}}" data-line-number="{{$row.RowNumber}}"></span>
</td>
<td rel="L{{$row.RowNumber}}" rel="L{{$row.RowNumber}}" class="lines-code blame-code chroma">
<code class="code-inner pl-3">{{$row.Code}}</code>
</td>
</tr>
{{end}}
</tbody> </tbody>
</table> </table>
</div> </div>

@ -2283,20 +2283,24 @@ function initCodeView() {
const $select = $(this); const $select = $(this);
let $list; let $list;
if ($('div.blame').length) { if ($('div.blame').length) {
$list = $('.code-view td.lines-code li'); $list = $('.code-view td.lines-code.blame-code');
} else { } else {
$list = $('.code-view td.lines-code'); $list = $('.code-view td.lines-code');
} }
selectRange($list, $list.filter(`[rel=${$select.attr('id')}]`), (e.shiftKey ? $list.filter('.active').eq(0) : null)); selectRange($list, $list.filter(`[rel=${$select.attr('id')}]`), (e.shiftKey ? $list.filter('.active').eq(0) : null));
deSelect(); deSelect();
showLineButton();
// show code view menu marker (don't show in blame page)
if ($('div.blame').length === 0) {
showLineButton();
}
}); });
$(window).on('hashchange', () => { $(window).on('hashchange', () => {
let m = window.location.hash.match(/^#(L\d+)-(L\d+)$/); let m = window.location.hash.match(/^#(L\d+)-(L\d+)$/);
let $list; let $list;
if ($('div.blame').length) { if ($('div.blame').length) {
$list = $('.code-view td.lines-code li'); $list = $('.code-view td.lines-code.blame-code');
} else { } else {
$list = $('.code-view td.lines-code'); $list = $('.code-view td.lines-code');
} }
@ -2304,7 +2308,12 @@ function initCodeView() {
if (m) { if (m) {
$first = $list.filter(`[rel=${m[1]}]`); $first = $list.filter(`[rel=${m[1]}]`);
selectRange($list, $first, $list.filter(`[rel=${m[2]}]`)); selectRange($list, $first, $list.filter(`[rel=${m[2]}]`));
showLineButton();
// show code view menu marker (don't show in blame page)
if ($('div.blame').length === 0) {
showLineButton();
}
$('html, body').scrollTop($first.offset().top - 200); $('html, body').scrollTop($first.offset().top - 200);
return; return;
} }
@ -2312,7 +2321,12 @@ function initCodeView() {
if (m) { if (m) {
$first = $list.filter(`[rel=L${m[2]}]`); $first = $list.filter(`[rel=L${m[2]}]`);
selectRange($list, $first); selectRange($list, $first);
showLineButton();
// show code view menu marker (don't show in blame page)
if ($('div.blame').length === 0) {
showLineButton();
}
$('html, body').scrollTop($first.offset().top - 200); $('html, body').scrollTop($first.offset().top - 200);
} }
}).trigger('hashchange'); }).trigger('hashchange');
@ -2911,7 +2925,6 @@ function selectRange($list, $select, $from) {
} else { } else {
$issue.attr('href', `${$issue.attr('href')}%23L${a}-L${b}`); $issue.attr('href', `${$issue.attr('href')}%23L${a}-L${b}`);
} }
return; return;
} }
} }

@ -106,6 +106,7 @@
--color-markup-code-block: #00000010; --color-markup-code-block: #00000010;
--color-button: #ffffff; --color-button: #ffffff;
--color-code-bg: #ffffff; --color-code-bg: #ffffff;
--color-code-sidebar-bg: #f5f5f5;
--color-shadow: #00000030; --color-shadow: #00000030;
--color-secondary-bg: #f4f4f4; --color-secondary-bg: #f4f4f4;
--color-expand-button: #d8efff; --color-expand-button: #d8efff;
@ -1442,6 +1443,14 @@ a.ui.label:hover {
margin-right: 0; margin-right: 0;
} }
.lines-blame-btn {
padding-left: 10px;
padding-right: 10px;
text-align: right !important;
background-color: var(--color-code-sidebar-bg);
width: 2%;
}
.lines-num { .lines-num {
padding-left: 10px; padding-left: 10px;
padding-right: 10px; padding-right: 10px;
@ -1507,7 +1516,7 @@ a.ui.label:hover {
.blame .lines-num { .blame .lines-num {
padding: 0 !important; padding: 0 !important;
background-color: #f5f5f5; background-color: var(--color-code-sidebar-bg);
} }
.blame .lines-code { .blame .lines-code {
@ -1532,7 +1541,7 @@ a.ui.label:hover {
vertical-align: top; vertical-align: top;
color: #999999; color: #999999;
padding: 0 !important; padding: 0 !important;
background: #f5f5f5; background: var(--color-code-sidebar-bg);
width: 1%; width: 1%;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
@ -1574,6 +1583,10 @@ a.ui.label:hover {
} }
} }
.top-line-blame {
border-top: 1px solid var(--color-secondary);
}
.lines-code, .lines-code,
.lines-commit { .lines-commit {
.bottom-line { .bottom-line {

@ -101,6 +101,7 @@
--color-markup-code-block: #292d39; --color-markup-code-block: #292d39;
--color-button: #353846; --color-button: #353846;
--color-code-bg: #2a2e3a; --color-code-bg: #2a2e3a;
--color-code-sidebar-bg: #2e323e;
--color-shadow: #00000060; --color-shadow: #00000060;
--color-secondary-bg: #2a2e3a; --color-secondary-bg: #2a2e3a;
--color-text-focus: #fff; --color-text-focus: #fff;
@ -430,11 +431,6 @@ td.blob-hunk {
background-color: #bbbbbb !important; background-color: #bbbbbb !important;
} }
.lines-commit,
.blame .lines-num {
background: #2e323e !important;
}
.lines-num { .lines-num {
color: var(--color-secondary-dark-6) !important; color: var(--color-secondary-dark-6) !important;
border-color: var(--color-secondary) !important; border-color: var(--color-secondary) !important;

Loading…
Cancel
Save