From b2748d7654db6ef7c423585b1bb1581d19d42848 Mon Sep 17 00:00:00 2001 From: Thomas Sayen <69324626+Chi-Iroh@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:40:38 +0200 Subject: [PATCH] feat(ui): add "follow rename" to file commit history list (#34994) Fix #28253 --------- Co-authored-by: wxiaoguang --- modules/git/repo_commit.go | 29 ++++++++++++------ modules/git/repo_commit_test.go | 47 +++++++++++++++++++++++++++-- modules/paginator/paginator.go | 17 +++++------ options/locale/locale_en-US.json | 1 + routers/api/v1/repo/commits.go | 2 +- routers/api/v1/repo/wiki.go | 2 +- routers/web/feed/file.go | 2 +- routers/web/repo/commit.go | 47 +++++++++++++++++++---------- routers/web/repo/wiki.go | 2 +- routers/web/user/home.go | 11 ++++--- routers/web/user/profile.go | 3 +- services/context/pagination.go | 4 +-- services/context/pagination_test.go | 20 ++++++++++++ templates/repo/commits_table.tmpl | 27 +++++++++++------ web_src/css/repo.css | 24 --------------- web_src/js/features/repo-commit.ts | 10 ++++++ web_src/js/index.ts | 3 +- 17 files changed, 169 insertions(+), 82 deletions(-) diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go index cbe5053346..acf2a13b0e 100644 --- a/modules/git/repo_commit.go +++ b/modules/git/repo_commit.go @@ -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 diff --git a/modules/git/repo_commit_test.go b/modules/git/repo_commit_test.go index 517decf0ca..aecba04250 100644 --- a/modules/git/repo_commit_test.go +++ b/modules/git/repo_commit_test.go @@ -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 1778660718 +0200 +committer Chi-Iroh 1778660718 +0200 +data 10 +Add a.txt +M 100644 :1 a.txt + +commit refs/heads/master +mark :3 +author Chi-Iroh 1778660741 +0200 +committer Chi-Iroh 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) } diff --git a/modules/paginator/paginator.go b/modules/paginator/paginator.go index 942bf7117b..ee06d70dca 100644 --- a/modules/paginator/paginator.go +++ b/modules/paginator/paginator.go @@ -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 } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index fdef581358..51a9797742 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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\".", diff --git a/routers/api/v1/repo/commits.go b/routers/api/v1/repo/commits.go index 0e66bae430..620ee03700 100644 --- a/routers/api/v1/repo/commits.go +++ b/routers/api/v1/repo/commits.go @@ -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, diff --git a/routers/api/v1/repo/wiki.go b/routers/api/v1/repo/wiki.go index e6fdd6afc6..1647104482 100644 --- a/routers/api/v1/repo/wiki.go +++ b/routers/api/v1/repo/wiki.go @@ -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, diff --git a/routers/web/feed/file.go b/routers/web/feed/file.go index 122d219b6c..f115521928 100644 --- a/routers/web/feed/file.go +++ b/routers/web/feed/file.go @@ -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, diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 0760cb4eed..7cb2f82a90 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -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) diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go index dcb0c25829..e0881dc9f1 100644 --- a/routers/web/repo/wiki.go +++ b/routers/web/repo/wiki.go @@ -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, diff --git a/routers/web/user/home.go b/routers/web/user/home.go index 3573a13241..6951be45ef 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -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 diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index dfe9a12bc2..654bd1a5f0 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -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 diff --git a/services/context/pagination.go b/services/context/pagination.go index a6e83df6a1..ef0f44ab37 100644 --- a/services/context/pagination.go +++ b/services/context/pagination.go @@ -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 } diff --git a/services/context/pagination_test.go b/services/context/pagination_test.go index 0ddef26ca3..1b15fa7b81 100644 --- a/services/context/pagination_test.go +++ b/services/context/pagination_test.go @@ -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()) } diff --git a/templates/repo/commits_table.tmpl b/templates/repo/commits_table.tmpl index 56a4867ff4..59e87d10c0 100644 --- a/templates/repo/commits_table.tmpl +++ b/templates/repo/commits_table.tmpl @@ -1,21 +1,30 @@ -

-
- {{if or .PageIsCommits (gt .CommitCount 0)}} - {{.CommitCount}} {{ctx.Locale.Tr "repo.commits.commits"}} +
+
+ {{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}}
- {{if .IsDiffCompare}} -
+
+ {{if .ShowFollowRename}} +
+ + +
+ {{end}} + {{if .IsDiffCompare}} + - {{end}} -

+ {{end}} + + {{if .PageIsCommits}}
@@ -29,7 +38,7 @@
{{end}} -{{if and .Commits (gt .CommitCount 0)}} +{{if .Commits}} {{template "repo/commits_list" .}} {{end}} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index b47e03b8ce..b7cb5e1dcd 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -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; } diff --git a/web_src/js/features/repo-commit.ts b/web_src/js/features/repo-commit.ts index 16f3899374..17130427a6 100644 --- a/web_src/js/features/repo-commit.ts +++ b/web_src/js/features/repo-commit.ts @@ -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()); + }); + }); +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 094caefb7e..a2994d6912 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -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,