0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-06-05 13:45:20 +02:00

feat(ui): add "follow rename" to file commit history list (#34994)

Fix #28253

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Thomas Sayen 2026-06-03 19:40:38 +02:00 committed by GitHub
parent 735e940a61
commit b2748d7654
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 169 additions and 82 deletions

View File

@ -222,17 +222,20 @@ type CommitsByFileAndRangeOptions struct {
Page int
Since string
Until string
// when using FollowRename, there is no quick way to know the total count, so use hasMore to indicate if there are more commits to load
FollowRename bool
}
// CommitsByFileAndRange return the commits according revision file and the page
func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions) ([]*Commit, error) {
gitCmd := gitcmd.NewCommand("rev-list").
AddOptionFormat("--max-count=%d", setting.Git.CommitsRangeSize).
func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions) (commits []*Commit, hasMore bool, _ error) {
limit := setting.Git.CommitsRangeSize
gitCmd := gitcmd.NewCommand("--no-pager", "log").
AddArguments("--pretty=tformat:%H").
AddOptionFormat("--max-count=%d", limit+1).
AddOptionFormat("--skip=%d", (opts.Page-1)*setting.Git.CommitsRangeSize)
gitCmd.AddDynamicArguments(opts.Revision)
if opts.Not != "" {
gitCmd.AddOptionValues("--not", opts.Not)
if opts.FollowRename {
gitCmd.AddArguments("--follow")
}
if opts.Since != "" {
gitCmd.AddOptionFormat("--since=%s", opts.Since)
@ -240,9 +243,12 @@ func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions)
if opts.Until != "" {
gitCmd.AddOptionFormat("--until=%s", opts.Until)
}
gitCmd.AddDynamicArguments(opts.Revision)
if opts.Not != "" {
gitCmd.AddOptionValues("--not", opts.Not)
}
gitCmd.AddDashesAndList(opts.File)
var commits []*Commit
stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe()
defer stdoutReaderClose()
err := gitCmd.WithDir(repo.Path).
@ -274,7 +280,12 @@ func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions)
}
}).
RunWithStderr(repo.Ctx)
return commits, err
hasMore = len(commits) > limit
if hasMore {
commits = commits[:limit]
}
return commits, hasMore, err
}
// FilesCountBetween return the number of files changed between two commits

View File

@ -6,8 +6,10 @@ package git
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
@ -140,11 +142,52 @@ func TestCommitsByFileAndRange(t *testing.T) {
defer bareRepo1.Close()
// "foo" has 3 commits in "master" branch
commits, err := bareRepo1.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "foo", Page: 1})
commits, hasMore, err := bareRepo1.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "foo", Page: 1})
require.NoError(t, err)
assert.True(t, hasMore)
assert.Len(t, commits, 2)
commits, err = bareRepo1.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "foo", Page: 2})
commits, hasMore, err = bareRepo1.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "foo", Page: 2})
require.NoError(t, err)
assert.Len(t, commits, 1)
assert.False(t, hasMore)
repoFollowRenameDir := filepath.Join(t.TempDir(), "repo.git")
require.NoError(t, gitcmd.NewCommand("init").AddDynamicArguments(repoFollowRenameDir).Run(t.Context()))
_, _, runErr := gitcmd.NewCommand("fast-import").WithDir(repoFollowRenameDir).WithStdinBytes([]byte(strings.TrimSpace(`
blob
mark :1
data 0
reset refs/heads/master
commit refs/heads/master
mark :2
author Chi-Iroh <user@example.com> 1778660718 +0200
committer Chi-Iroh <user@example.com> 1778660718 +0200
data 10
Add a.txt
M 100644 :1 a.txt
commit refs/heads/master
mark :3
author Chi-Iroh <user@example.com> 1778660741 +0200
committer Chi-Iroh <user@example.com> 1778660741 +0200
data 22
Rename a.txt to b.txt
from :2
D a.txt
M 100644 :1 b.txt
`))).RunStdString(t.Context())
require.NoError(t, runErr)
repoFollowRename, err := OpenRepository(t.Context(), repoFollowRenameDir)
require.NoError(t, err)
defer repoFollowRename.Close()
commits, _, err = repoFollowRename.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "b.txt", Page: 1})
require.NoError(t, err)
assert.Len(t, commits, 1)
commits, _, err = repoFollowRename.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "b.txt", Page: 1, FollowRename: true})
require.NoError(t, err)
assert.Len(t, commits, 2)
}

View File

@ -37,10 +37,11 @@ type Paginator struct {
total int // total rows count, -1 means unknown
totalPages int // total pages count, -1 means unknown
current int // current page number
curRows int // current page rows count
pagingNum int // how many rows in one page
numPages int // how many pages to show on the UI
hasNext *bool // used for total=-1 ("unlimited paging")
}
// New initialize a new pagination calculation and returns a Paginator as result.
@ -60,15 +61,13 @@ func New(total, pagingNum, current, numPages int) *Paginator {
}
}
func (p *Paginator) SetCurRows(rows int) {
func (p *Paginator) SetUnlimitedPaging(curRows int, hasNext bool) {
// For "unlimited paging", we need to know the rows of current page to determine if there is a next page.
// There is still an edge case: when curRows==pagingNum, then the "next page" will be an empty page.
// Ideally we should query one more row to determine if there is really a next page, but it's impossible in current framework.
p.curRows = rows
if p.total == -1 && p.current == 1 && !p.HasNext() {
p.hasNext = &hasNext
if p.total == -1 && p.current == 1 && !hasNext {
// if there is only one page for the "unlimited paging", set total rows/pages count
// then the tmpl could decide to hide the nav bar.
p.total = rows
p.total = curRows
p.totalPages = util.Iif(p.total == 0, 0, 1)
}
}
@ -92,8 +91,8 @@ func (p *Paginator) Previous() int {
// HasNext returns true if there is a next page relative to current page.
func (p *Paginator) HasNext() bool {
if p.total == -1 {
return p.curRows >= p.pagingNum
if p.hasNext != nil {
return *p.hasNext
}
return p.current*p.pagingNum < p.total
}

View File

@ -1321,6 +1321,7 @@
"repo.editor.fork_branch_exists": "Branch \"%s\" already exists in your fork. Please choose a new branch name.",
"repo.commits.desc": "Browse source code change history.",
"repo.commits.commits": "Commits",
"repo.commits.history_enable_follow_renames": "Include renames",
"repo.commits.no_commits": "No commits in common. \"%s\" and \"%s\" have entirely different histories.",
"repo.commits.nothing_to_compare": "There are no differences to show.",
"repo.commits.search.tooltip": "You can prefix keywords with \"author:\", \"committer:\", \"after:\", or \"before:\", e.g. \"revert author:Alice before:2019-01-13\".",

View File

@ -262,7 +262,7 @@ func GetAllCommits(ctx *context.APIContext) {
return
}
commits, err = ctx.Repo.GitRepo.CommitsByFileAndRange(
commits, _, err = ctx.Repo.GitRepo.CommitsByFileAndRange(
git.CommitsByFileAndRangeOptions{
Revision: sha,
File: path,

View File

@ -435,7 +435,7 @@ func ListPageRevisions(ctx *context.APIContext) {
page := max(ctx.FormInt("page"), 1)
// get Commit Count
commitsHistory, err := wikiRepo.CommitsByFileAndRange(
commitsHistory, _, err := wikiRepo.CommitsByFileAndRange(
git.CommitsByFileAndRangeOptions{
Revision: ctx.Repo.Repository.DefaultWikiBranch,
File: pageFilename,

View File

@ -20,7 +20,7 @@ func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string
if len(fileName) == 0 {
return
}
commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
commits, _, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
git.CommitsByFileAndRangeOptions{
Revision: ctx.Repo.RefFullName.ShortName(), // FIXME: legacy code used ShortName
File: fileName,

View File

@ -214,37 +214,52 @@ func FileHistory(ctx *context.Context) {
return
}
commitsCount, err := gitrepo.FileCommitsCount(ctx, ctx.Repo.Repository, ctx.Repo.RefFullName.ShortName(), ctx.Repo.TreePath)
if err != nil {
ctx.ServerError("FileCommitsCount", err)
return
} else if commitsCount == 0 {
ctx.NotFound(nil)
return
}
followRename := ctx.FormBool("follow-rename")
ctx.Data["ShowFollowRename"] = true
ctx.Data["FollowRenameChecked"] = followRename
page := max(ctx.FormInt("page"), 1)
commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
commits, hasMore, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
git.CommitsByFileAndRangeOptions{
Revision: ctx.Repo.RefFullName.ShortName(), // FIXME: legacy code used ShortName
File: ctx.Repo.TreePath,
Page: page,
Revision: ctx.Repo.RefFullName.ShortName(), // FIXME: legacy code used ShortName
File: ctx.Repo.TreePath,
Page: page,
FollowRename: followRename,
})
if err != nil {
ctx.ServerError("CommitsByFileAndRange", err)
return
}
var commitsCount int64
if followRename {
// there is no quick method to know the total count when "follow rename"
commitsCount = -1
} else {
commitsCount, err = gitrepo.FileCommitsCount(ctx, ctx.Repo.Repository, ctx.Repo.RefFullName.ShortName(), ctx.Repo.TreePath)
if err != nil {
ctx.ServerError("FileCommitsCount", err)
return
}
}
if len(commits) == 0 {
ctx.NotFound(nil)
return
}
ctx.Data["FileTreePath"] = ctx.Repo.TreePath
ctx.Data["CommitCount"] = commitsCount
ctx.Data["Commits"], err = processGitCommits(ctx, commits)
if err != nil {
ctx.ServerError("processGitCommits", err)
return
}
ctx.Data["FileTreePath"] = ctx.Repo.TreePath
ctx.Data["CommitCount"] = commitsCount
pager := context.NewPagination(commitsCount, setting.Git.CommitsRangeSize, page, 5)
if commitsCount == -1 {
pager.WithUnlimitedPaging(len(commits), hasMore)
}
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplCommits)

View File

@ -351,7 +351,7 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry)
page := max(ctx.FormInt("page"), 1)
// get Commit Count
commitsHistory, err := wikiGitRepo.CommitsByFileAndRange(
commitsHistory, _, err := wikiGitRepo.CommitsByFileAndRange(
git.CommitsByFileAndRangeOptions{
Revision: ctx.Repo.Repository.DefaultWikiBranch,
File: pageFilename,

View File

@ -111,6 +111,7 @@ func Dashboard(ctx *context.Context) {
prepareHeatmapURL(ctx)
pageSize := setting.UI.User.RepoPagingNum
feeds, count, err := feed_service.GetFeedsForDashboard(ctx, activities_model.GetFeedsOptions{
RequestedUser: ctxUser,
RequestedTeam: ctx.Org.Team,
@ -119,17 +120,17 @@ func Dashboard(ctx *context.Context) {
OnlyPerformedBy: false,
IncludeDeleted: false,
Date: ctx.FormString("date"),
ListOptions: db.ListOptions{
Page: page,
PageSize: setting.UI.FeedPagingNum,
},
ListOptions: db.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
ctx.ServerError("GetFeeds", err)
return
}
pager := context.NewPagination(count, setting.UI.FeedPagingNum, page, 5).WithCurRows(len(feeds))
// FIXME: UNLIMITE-PAGING-ONE-MORE-ROW: here is still an edge case: when curRows==pagingNum, then the "next page" will be an empty page.
// Ideally we should query one more row to determine if there is really a next page, but it's impossible in current framework.
pager := context.NewPagination(count, pageSize, page, 5).WithUnlimitedPaging(len(feeds), len(feeds) == pageSize)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.Data["Feeds"] = feeds

View File

@ -312,7 +312,8 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
pager := context.NewPagination(total, pagingNum, page, 5)
if tab == "activity" {
pager.WithCurRows(curRows)
// FIXME: UNLIMITE-PAGING-ONE-MORE-ROW: see another comment
pager.WithUnlimitedPaging(curRows, curRows == pagingNum)
}
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager

View File

@ -33,8 +33,8 @@ func NewPagination(total int64, pagingNum, current, numPages int) *Pagination {
return p
}
func (p *Pagination) WithCurRows(n int) *Pagination {
p.Paginater.SetCurRows(n)
func (p *Pagination) WithUnlimitedPaging(curRows int, hasNext bool) *Pagination {
p.Paginater.SetUnlimitedPaging(curRows, hasNext)
return p
}

View File

@ -32,4 +32,24 @@ func TestPagination(t *testing.T) {
params.Del("foo")
v, _ = url.ParseQuery(string(p.GetParams()))
assert.Equal(t, params, v)
p = NewPagination(-1, 1, 1, 1)
p.WithUnlimitedPaging(0, false)
assert.Zero(t, p.Paginater.TotalPages())
assert.False(t, p.Paginater.HasNext())
p = NewPagination(-1, 1, 1, 1)
p.WithUnlimitedPaging(10, false)
assert.Equal(t, 1, p.Paginater.TotalPages()) // first page, no next, so it should know that the total page number is 1
assert.False(t, p.Paginater.HasNext())
p = NewPagination(-1, 1, 2, 1)
p.WithUnlimitedPaging(10, false)
assert.Equal(t, -1, p.Paginater.TotalPages())
assert.False(t, p.Paginater.HasNext())
p = NewPagination(-1, 1, 1, 1)
p.WithUnlimitedPaging(10, true)
assert.Equal(t, -1, p.Paginater.TotalPages())
assert.True(t, p.Paginater.HasNext())
}

View File

@ -1,21 +1,30 @@
<h4 class="ui top attached header commits-table flex-left-right">
<div class="commits-table-left flex-text-block">
{{if or .PageIsCommits (gt .CommitCount 0)}}
{{.CommitCount}} {{ctx.Locale.Tr "repo.commits.commits"}}
<div class="ui top attached header flex-left-right">
<div class="flex-text-block">
{{if .Commits}}
{{if gt .CommitCount 0}}{{.CommitCount}}{{end}}{{/* CommitCount is -1 when "follow rename" */}}
{{ctx.Locale.Tr "repo.commits.commits"}}
{{else if .IsNothingToCompare}}
{{ctx.Locale.Tr "repo.commits.nothing_to_compare"}}
{{else}}
{{ctx.Locale.Tr "repo.commits.no_commits" $.BaseBranch $.HeadBranch}}
{{end}}
</div>
{{if .IsDiffCompare}}
<div class="commits-table-right tw-whitespace-nowrap">
<div class="flex-text-block">
{{if .ShowFollowRename}}
<div class="ui checkbox">
<input type="checkbox" {{if .FollowRenameChecked}}checked{{end}} data-global-init="initCommitHistoryFollowRename">
<label>{{ctx.Locale.Tr "repo.commits.history_enable_follow_renames"}}</label>
</div>
{{end}}
{{if .IsDiffCompare}}
<div class="flex-text-inline">
<a href="{{$.CommitRepoLink}}/commit/{{.BeforeCommitID | PathEscape}}" class="ui green sha label tw-mx-0">{{template "repo/commits_ref_name" .CompareInfo.BaseRef}}</a>
{{$.CompareInfo.CompareSeparator}}
<a href="{{$.CommitRepoLink}}/commit/{{.AfterCommitID | PathEscape}}" class="ui green sha label tw-mx-0">{{template "repo/commits_ref_name" .CompareInfo.HeadRef}}</a>
</div>
{{end}}
</h4>
{{end}}
</div>
</div>
{{if .PageIsCommits}}
<div class="ui attached segment">
@ -29,7 +38,7 @@
</div>
{{end}}
{{if and .Commits (gt .CommitCount 0)}}
{{if .Commits}}
{{template "repo/commits_list" .}}
{{end}}

View File

@ -1816,14 +1816,6 @@ tbody.commit-list {
box-shadow: 0 0.5rem 1rem var(--color-shadow) !important;
}
.commits-table .commits-table-right form {
display: flex;
align-items: center;
gap: 0.75em;
justify-content: center;
flex-wrap: wrap;
}
@media (max-width: 767.98px) {
.repository.view.issue .comment-list,
.repository.view.issue .comment-list .timeline-item {
@ -1848,22 +1840,6 @@ tbody.commit-list {
flex-basis: auto !important;
margin-bottom: 0.5rem !important;
}
.commits-table {
flex-direction: column;
}
.commits-table .commits-table-left {
align-items: initial !important;
margin-bottom: 6px;
}
.commits-table .commits-table-right form > div:nth-child(1) {
order: 1; /* the "commit search" input */
}
.commits-table .commits-table-right form > div:nth-child(2) {
order: 3; /* the "search all" checkbox */
}
.commits-table .commits-table-right form > button:nth-child(3) {
order: 2; /* the "search" button */
}
.commit-table {
overflow-x: auto;
}

View File

@ -24,3 +24,13 @@ export function initCommitStatuses() {
});
});
}
export function initCommitFileHistoryFollowRename() {
registerGlobalInitFunc('initCommitHistoryFollowRename', (el: HTMLInputElement) => {
el.addEventListener('change', () => {
const url = new URL(window.location.toString());
url.searchParams.set('follow-rename', `${el.checked}`);
window.location.assign(url.toString());
});
});
}

View File

@ -21,7 +21,7 @@ import {initMarkupContent} from './markup/content.ts';
import {initRepoFileView} from './features/file-view.ts';
import {initUserExternalLogins, initUserCheckAppUrl} from './features/user-auth.ts';
import {initRepoPullRequestReview, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
import {initRepoEllipsisButton, initCommitStatuses, initCommitFileHistoryFollowRename} from './features/repo-commit.ts';
import {initRepoTopicBar} from './features/repo-home.ts';
import {initAdminCommon} from './features/admin/common.ts';
import {initRepoCodeView} from './features/repo-code.ts';
@ -123,6 +123,7 @@ const initPerformanceTracer = callInitFunctions([
initRepoCodeView,
initBranchSelectorTabs,
initRepoEllipsisButton,
initCommitFileHistoryFollowRename,
initRepoDiffCommitBranchesAndTags,
initRepoEditor,
initRepoGraphGit,