Add commits dropdown in PR files view and allow commit by commit review (#25528)

This PR adds a new dropdown to select a commit or a commit range
(shift-click like github) of a Pull Request.
After selection of a commit only the changes of this commit will be shown.
When selecting a range of commits the diff of this range is shown.

This allows to review a PR commit by commit or by viewing only commit ranges.
The "Show changes since your last review" mechanism github uses is implemented, too.
When reviewing a single commit or a commit range the "Viewed" functionality is disabled.

## Screenshots

### The commit dropdown

![image](https://github.com/go-gitea/gitea/assets/51889757/0db3ae62-1272-436c-be64-4730c5d611e3)

### Selecting a commit range

![image](https://github.com/go-gitea/gitea/assets/51889757/ad81eedb-8437-42b0-8073-2d940c25fe8f)

### Show changes of a single commit only

![image](https://github.com/go-gitea/gitea/assets/51889757/6b1a113b-73ef-4ecc-adf6-bc2340bb8f97)

### Show changes of a commit range

![image](https://github.com/go-gitea/gitea/assets/51889757/6401b358-cd66-4c09-8baa-6cf6177f23a7)


Fixes https://github.com/go-gitea/gitea/issues/20989
Fixes https://github.com/go-gitea/gitea/issues/19263

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: KN4CK3R <admin@oldschoolhack.me>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: delvh <dev.lh@web.de>
pull/26208/head^2
sebastian-sauer 1 year ago committed by GitHub
parent 4971a10543
commit 55532061c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 17
      models/fixtures/issue.yml
  2. 13
      models/fixtures/pull_request.yml
  3. 30
      models/fixtures/repo_unit.yml
  4. 31
      models/fixtures/repository.yml
  5. 2
      models/fixtures/user.yml
  6. 2
      models/issues/issue_test.go
  7. 2
      models/issues/review_list.go
  8. 6
      models/repo/repo_list_test.go
  9. 7
      options/locale/locale_en-US.ini
  10. 116
      routers/web/repo/pull.go
  11. 2
      routers/web/user/home_test.go
  12. 10
      routers/web/web.go
  13. 71
      services/pull/pull.go
  14. 24
      templates/repo/diff/box.tmpl
  15. 4
      templates/repo/diff/new_review.tmpl
  16. 1
      tests/gitea-repositories-meta/user2/commitsonpr.git/HEAD
  17. 4
      tests/gitea-repositories-meta/user2/commitsonpr.git/config
  18. 1
      tests/gitea-repositories-meta/user2/commitsonpr.git/description
  19. 6
      tests/gitea-repositories-meta/user2/commitsonpr.git/info/exclude
  20. 3
      tests/gitea-repositories-meta/user2/commitsonpr.git/info/refs
  21. 1
      tests/gitea-repositories-meta/user2/commitsonpr.git/logs/HEAD
  22. 1
      tests/gitea-repositories-meta/user2/commitsonpr.git/logs/refs/heads/branch1
  23. 1
      tests/gitea-repositories-meta/user2/commitsonpr.git/logs/refs/heads/main
  24. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/0a/6dda431c72a6a4aac05b98e319972a1a55e01c
  25. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/0c/396a509b64fd4e2e55649d100b86e8b96cc0e5
  26. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/10/0ef49565829e7bd83057d2dab88f58b00db831
  27. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/10/9ab1c0b84e088d7edcf018379518b49361f285
  28. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/19/78192d98bb1b65e11c2cf37da854fbf94bffd6
  29. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/1e/67d753ac1f9097eff26f9d33eb80182344b72c
  30. 2
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/23/576dd018294e476c06e569b6b0f170d0558705
  31. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/28/16bffda09c0f23775ea4be279de004d28a3803
  32. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/35/f03b5e176ee6d24c86b5cca7009a5b0ba2a026
  33. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/38/cdad2e40c989aabab3f2d0a27faf0f7be617d5
  34. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/3e/64625bd6eb5bcba69ac97de6c8f507402df861
  35. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/4b/860706d3eec5858324d4ba00db0423ca4cbf50
  36. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/4c/a8bcaf27e28504df7bf996819665986b01c847
  37. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/52/84ca7f5757816e67c098224a8367aa2544222d
  38. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/53/9a24812705f77484568e6ad7db84764c1903c8
  39. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/70/8605e8984e7fb9be58818e0e6d9f21bcefd63e
  40. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/74/7ddb3506a4fa04a7747808eb56ae16f9e933dc
  41. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/7e/d1d42eda9110676d5c3a7721965d6ed1afe83c
  42. 2
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/81/1d46c7e518f4f180afb862c0db5cb8c80529ce
  43. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/83/2d33e438d2b4a86fba81cb67b32d1d61a828cb
  44. 3
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/83/7d5c8125633d7d258f93b998e867eab0145520
  45. 2
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/87/cdc1333f5f117a92f3cef78ebe0301114b3610
  46. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/92/70b08497106eaa65fce8aa91f37c4780f76909
  47. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/96/cef4a7b72b3c208340ae6f0cf55a93e9077c93
  48. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/97/0c5deb117526983f554eaaa1b59102d3e3e0f7
  49. 2
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/c5/626fc9eff57eb1bb7b796b01d4d0f2f3f792a2
  50. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/c7/04db5794097441aa2d9dd834d5b7e2f8f08108
  51. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/cb/ff181af4c9c7fee3cf6c106699e07d9a3f54e6
  52. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d1/8e427f4011e74e96a31823c938be26eebab53b
  53. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d2/5795e38fbc1b4839697e834b957d61c83d994f
  54. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d6/6f456f0813a5841fbc03e5f1c47304dc675695
  55. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/e1/7e0fa20f3d2125916f2fb2f51f19240678cb83
  56. BIN
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/ec/d9fdda5c814055ee619513e1c388ba1bbcb280
  57. 1
      tests/gitea-repositories-meta/user2/commitsonpr.git/objects/info/packs
  58. 1
      tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/branch1
  59. 1
      tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/main
  60. 1
      tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/master
  61. 1
      tests/gitea-repositories-meta/user2/commitsonpr.git/refs/pull/1/head
  62. 14
      tests/integration/api_issue_test.go
  63. 2
      tests/integration/api_nodeinfo_test.go
  64. 6
      tests/integration/api_repo_test.go
  65. 14
      tests/integration/issue_test.go
  66. 58
      tests/integration/pull_diff_test.go
  67. 5
      web_src/css/base.css
  68. 299
      web_src/js/components/DiffCommitSelector.vue
  69. 10
      web_src/js/features/repo-diff-commitselect.js
  70. 2
      web_src/js/features/repo-diff.js
  71. 2
      web_src/js/svg.js

@ -304,3 +304,20 @@
created_unix: 946684830
updated_unix: 978307200
is_locked: false
-
id: 19
repo_id: 58
index: 1
poster_id: 2
original_author_id: 0
name: issue for pr
content: content
milestone_id: 0
priority: 0
is_closed: false
is_pull: true
num_comments: 0
created_unix: 946684830
updated_unix: 978307200
is_locked: false

@ -76,3 +76,16 @@
base_branch: master
merge_base: 2a47ca4b614a9f5a
has_merged: false
-
id: 7
type: 0 # gitea pull request
status: 2 # mergable
issue_id: 19
index: 1
head_repo_id: 58
base_repo_id: 58
head_branch: branch1
base_branch: main
merge_base: cbff181af4c9c7fee3cf6c106699e07d9a3f54e6
has_merged: false

@ -607,3 +607,33 @@
repo_id: 52
type: 1
created_unix: 946684810
-
id: 91
repo_id: 58
type: 1
created_unix: 946684810
-
id: 92
repo_id: 58
type: 2
created_unix: 946684810
-
id: 93
repo_id: 58
type: 3
created_unix: 946684810
-
id: 94
repo_id: 58
type: 4
created_unix: 946684810
-
id: 95
repo_id: 58
type: 5
created_unix: 946684810

@ -1662,3 +1662,34 @@
is_private: false
status: 0
num_issues: 0
-
id: 58 # org public repo
owner_id: 2
owner_name: user2
lower_name: commitsonpr
name: commitsonpr
default_branch: main
num_watches: 0
num_stars: 0
num_forks: 0
num_issues: 0
num_closed_issues: 0
num_pulls: 1
num_closed_pulls: 0
num_milestones: 0
num_closed_milestones: 0
num_projects: 0
num_closed_projects: 0
is_private: false
is_empty: false
is_archived: false
is_mirror: false
status: 0
is_fork: false
fork_id: 0
is_template: false
template_id: 0
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false

@ -66,7 +66,7 @@
num_followers: 2
num_following: 1
num_stars: 2
num_repos: 13
num_repos: 14
num_teams: 0
num_members: 0
visibility: 0

@ -538,7 +538,7 @@ func TestCountIssues(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
count, err := issues_model.CountIssues(db.DefaultContext, &issues_model.IssuesOptions{})
assert.NoError(t, err)
assert.EqualValues(t, 18, count)
assert.EqualValues(t, 19, count)
}
func TestIssueLoadAttributes(t *testing.T) {

@ -114,7 +114,7 @@ func FindLatestReviews(ctx context.Context, opts FindReviewOptions) (ReviewList,
}
sess.In("id", builder.
Select("max ( id ) ").
Select("max(id)").
From("review").
Where(cond).
GroupBy("reviewer_id"))

@ -235,12 +235,12 @@ func TestSearchRepository(t *testing.T) {
{
name: "AllPublic/PublicRepositoriesOfUserIncludingCollaborative",
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, AllPublic: true, Template: util.OptionalBoolFalse},
count: 30,
count: 31,
},
{
name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborative",
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, AllPublic: true, AllLimited: true, Template: util.OptionalBoolFalse},
count: 35,
count: 36,
},
{
name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborativeByName",
@ -255,7 +255,7 @@ func TestSearchRepository(t *testing.T) {
{
name: "AllPublic/PublicRepositoriesOfOrganization",
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 17, AllPublic: true, Collaborate: util.OptionalBoolFalse, Template: util.OptionalBoolFalse},
count: 30,
count: 31,
},
{
name: "AllTemplates",

@ -1662,6 +1662,13 @@ pulls.switch_comparison_type = Switch comparison type
pulls.switch_head_and_base = Switch head and base
pulls.filter_branch = Filter branch
pulls.no_results = No results found.
pulls.show_all_commits = Show all commits
pulls.show_changes_since_your_last_review = Show changes since your last review
pulls.showing_only_single_commit = Showing only changes of commit %[1]s
pulls.showing_specified_commit_range = Showing only changes between %[1]s..%[2]s
pulls.select_commit_hold_shift_for_range = Select commit. Hold shift + click to select a range
pulls.review_only_possible_for_full_diff = Review is only possible when viewing the full diff
pulls.filter_changes_by_commit = Filter by commit
pulls.nothing_to_compare = These branches are equal. There is no need to create a pull request.
pulls.nothing_to_compare_and_allow_empty_pr = These branches are equal. This PR will be empty.
pulls.has_pull_request = `A pull request between these branches already exists: <a href="%[1]s">%[2]s#%[3]d</a>`

@ -694,6 +694,42 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C
return compareInfo
}
type pullCommitList struct {
Commits []pull_service.CommitInfo `json:"commits"`
LastReviewCommitSha string `json:"last_review_commit_sha"`
Locale map[string]string `json:"locale"`
}
// GetPullCommits get all commits for given pull request
func GetPullCommits(ctx *context.Context) {
issue := checkPullInfo(ctx)
if ctx.Written() {
return
}
resp := &pullCommitList{}
commits, lastReviewCommitSha, err := pull_service.GetPullCommits(ctx, issue)
if err != nil {
ctx.JSON(http.StatusInternalServerError, err)
return
}
// Get the needed locale
resp.Locale = map[string]string{
"lang": ctx.Locale.Language(),
"filter_changes_by_commit": ctx.Tr("repo.pulls.filter_changes_by_commit"),
"show_all_commits": ctx.Tr("repo.pulls.show_all_commits"),
"stats_num_commits": ctx.TrN(len(commits), "repo.activity.git_stats_commit_1", "repo.activity.git_stats_commit_n", len(commits)),
"show_changes_since_your_last_review": ctx.Tr("repo.pulls.show_changes_since_your_last_review"),
"select_commit_hold_shift_for_range": ctx.Tr("repo.pulls.select_commit_hold_shift_for_range"),
}
resp.Commits = commits
resp.LastReviewCommitSha = lastReviewCommitSha
ctx.JSON(http.StatusOK, resp)
}
// ViewPullCommits show commits for a pull request
func ViewPullCommits(ctx *context.Context) {
ctx.Data["PageIsPullList"] = true
@ -739,7 +775,7 @@ func ViewPullCommits(ctx *context.Context) {
}
// ViewPullFiles render pull request changed files list page
func ViewPullFiles(ctx *context.Context) {
func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommit string, willShowSpecifiedCommitRange, willShowSpecifiedCommit bool) {
ctx.Data["PageIsPullList"] = true
ctx.Data["PageIsPullFiles"] = true
@ -762,6 +798,33 @@ func ViewPullFiles(ctx *context.Context) {
prInfo = PrepareViewPullInfo(ctx, issue)
}
// Validate the given commit sha to show (if any passed)
if willShowSpecifiedCommit || willShowSpecifiedCommitRange {
foundStartCommit := len(specifiedStartCommit) == 0
foundEndCommit := len(specifiedEndCommit) == 0
if !(foundStartCommit && foundEndCommit) {
for _, commit := range prInfo.Commits {
if commit.ID.String() == specifiedStartCommit {
foundStartCommit = true
}
if commit.ID.String() == specifiedEndCommit {
foundEndCommit = true
}
if foundStartCommit && foundEndCommit {
break
}
}
}
if !(foundStartCommit && foundEndCommit) {
ctx.NotFound("Given SHA1 not found for this PR", nil)
return
}
}
if ctx.Written() {
return
} else if prInfo == nil {
@ -775,12 +838,30 @@ func ViewPullFiles(ctx *context.Context) {
return
}
startCommitID = prInfo.MergeBase
endCommitID = headCommitID
ctx.Data["IsShowingOnlySingleCommit"] = willShowSpecifiedCommit
if willShowSpecifiedCommit || willShowSpecifiedCommitRange {
if len(specifiedEndCommit) > 0 {
endCommitID = specifiedEndCommit
} else {
endCommitID = headCommitID
}
if len(specifiedStartCommit) > 0 {
startCommitID = specifiedStartCommit
} else {
startCommitID = prInfo.MergeBase
}
ctx.Data["IsShowingAllCommits"] = false
} else {
endCommitID = headCommitID
startCommitID = prInfo.MergeBase
ctx.Data["IsShowingAllCommits"] = true
}
ctx.Data["Username"] = ctx.Repo.Owner.Name
ctx.Data["Reponame"] = ctx.Repo.Repository.Name
ctx.Data["AfterCommitID"] = endCommitID
ctx.Data["BeforeCommitID"] = startCommitID
fileOnly := ctx.FormBool("file-only")
@ -789,8 +870,8 @@ func ViewPullFiles(ctx *context.Context) {
if fileOnly && (len(files) == 2 || len(files) == 1) {
maxLines, maxFiles = -1, -1
}
diffOptions := &gitdiff.DiffOptions{
BeforeCommitID: startCommitID,
AfterCommitID: endCommitID,
SkipTo: ctx.FormString("skip-to"),
MaxLines: maxLines,
@ -799,9 +880,18 @@ func ViewPullFiles(ctx *context.Context) {
WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)),
}
if !willShowSpecifiedCommit {
diffOptions.BeforeCommitID = startCommitID
}
var methodWithError string
var diff *gitdiff.Diff
if !ctx.IsSigned {
// if we're not logged in or only a single commit (or commit range) is shown we
// have to load only the diff and not get the viewed information
// as the viewed information is designed to be loaded only on latest PR
// diff and if you're signed in.
if !ctx.IsSigned || willShowSpecifiedCommit || willShowSpecifiedCommitRange {
diff, err = gitdiff.GetDiff(gitRepo, diffOptions, files...)
methodWithError = "GetDiff"
} else {
@ -908,6 +998,22 @@ func ViewPullFiles(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplPullFiles)
}
func ViewPullFilesForSingleCommit(ctx *context.Context) {
viewPullFiles(ctx, "", ctx.Params("sha"), true, true)
}
func ViewPullFilesForRange(ctx *context.Context) {
viewPullFiles(ctx, ctx.Params("shaFrom"), ctx.Params("shaTo"), true, false)
}
func ViewPullFilesStartingFromCommit(ctx *context.Context) {
viewPullFiles(ctx, "", ctx.Params("sha"), true, false)
}
func ViewPullFilesForAllCommitsOfPr(ctx *context.Context) {
viewPullFiles(ctx, "", "", false, false)
}
// UpdatePullRequest merge PR's baseBranch into headBranch
func UpdatePullRequest(ctx *context.Context) {
issue := checkPullInfo(ctx)

@ -75,7 +75,7 @@ func TestPulls(t *testing.T) {
Pulls(ctx)
assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
assert.Len(t, ctx.Data["Issues"], 4)
assert.Len(t, ctx.Data["Issues"], 5)
}
func TestMilestones(t *testing.T) {

@ -1279,14 +1279,20 @@ func registerRoutes(m *web.Route) {
m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue)
m.Get(".diff", repo.DownloadPullDiff)
m.Get(".patch", repo.DownloadPullPatch)
m.Get("/commits", context.RepoRef(), repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewPullCommits)
m.Group("/commits", func() {
m.Get("", context.RepoRef(), repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewPullCommits)
m.Get("/list", context.RepoRef(), repo.GetPullCommits)
m.Get("/{sha:[a-f0-9]{7,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForSingleCommit)
})
m.Post("/merge", context.RepoMustNotBeArchived(), web.Bind(forms.MergePullRequestForm{}), repo.MergePullRequest)
m.Post("/cancel_auto_merge", context.RepoMustNotBeArchived(), repo.CancelAutoMergePullRequest)
m.Post("/update", repo.UpdatePullRequest)
m.Post("/set_allow_maintainer_edit", web.Bind(forms.UpdateAllowEditsForm{}), repo.SetAllowEdits)
m.Post("/cleanup", context.RepoMustNotBeArchived(), context.RepoRef(), repo.CleanUpPullRequest)
m.Group("/files", func() {
m.Get("", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFiles)
m.Get("", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForAllCommitsOfPr)
m.Get("/{sha:[a-f0-9]{7,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesStartingFromCommit)
m.Get("/{shaFrom:[a-f0-9]{7,40}}..{shaTo:[a-f0-9]{7,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForRange)
m.Group("/reviews", func() {
m.Get("/new_comment", repo.RenderNewCodeCommentForm)
m.Post("/comments", web.Bind(forms.CodeCommentForm{}), repo.SetShowOutdatedComments, repo.CreateCodeComment)

@ -10,6 +10,7 @@ import (
"os"
"regexp"
"strings"
"time"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
@ -17,7 +18,9 @@ import (
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/container"
gitea_context "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/json"
@ -856,3 +859,71 @@ func IsHeadEqualWithBranch(ctx context.Context, pr *issues_model.PullRequest, br
}
return baseCommit.HasPreviousCommit(headCommit.ID)
}
type CommitInfo struct {
Summary string `json:"summary"`
CommitterOrAuthorName string `json:"committer_or_author_name"`
ID string `json:"id"`
ShortSha string `json:"short_sha"`
Time string `json:"time"`
}
// GetPullCommits returns all commits on given pull request and the last review commit sha
func GetPullCommits(ctx *gitea_context.Context, issue *issues_model.Issue) ([]CommitInfo, string, error) {
pull := issue.PullRequest
baseGitRepo := ctx.Repo.GitRepo
if err := pull.LoadBaseRepo(ctx); err != nil {
return nil, "", err
}
baseBranch := pull.BaseBranch
if pull.HasMerged {
baseBranch = pull.MergeBase
}
prInfo, err := baseGitRepo.GetCompareInfo(pull.BaseRepo.RepoPath(), baseBranch, pull.GetGitRefName(), true, false)
if err != nil {
return nil, "", err
}
commits := make([]CommitInfo, 0, len(prInfo.Commits))
for _, commit := range prInfo.Commits {
var committerOrAuthorName string
var commitTime time.Time
if commit.Committer != nil {
committerOrAuthorName = commit.Committer.Name
commitTime = commit.Committer.When
} else {
committerOrAuthorName = commit.Author.Name
commitTime = commit.Author.When
}
commits = append(commits, CommitInfo{
Summary: commit.Summary(),
CommitterOrAuthorName: committerOrAuthorName,
ID: commit.ID.String(),
ShortSha: base.ShortSha(commit.ID.String()),
Time: commitTime.Format(time.RFC3339),
})
}
var lastReviewCommitID string
if ctx.IsSigned {
// get last review of current user and store information in context (if available)
lastreview, err := issues_model.FindLatestReviews(ctx, issues_model.FindReviewOptions{
IssueID: issue.ID,
ReviewerID: ctx.Doer.ID,
Type: issues_model.ReviewTypeUnknown,
})
if err != nil && !issues_model.IsErrReviewNotExist(err) {
return nil, "", err
}
if len(lastreview) > 0 {
lastReviewCommitID = lastreview[0].CommitID
}
}
return commits, lastReviewCommitID, nil
}

@ -31,12 +31,32 @@
{{end}}
{{template "repo/diff/whitespace_dropdown" .}}
{{template "repo/diff/options_dropdown" .}}
{{if .PageIsPullFiles}}
<div id="diff-commit-select" data-issuelink="{{$.Issue.Link}}" data-queryparams="?style={{if $.IsSplitStyle}}split{{else}}unified{{end}}&whitespace={{$.WhitespaceBehavior}}&show-outdated={{$.ShowOutdatedComments}}">
{{/*
the following will be replaced by vue component
but this avoids any loading artifacts till the vue component is initialized
*/}}
<div class="ui jump dropdown basic button custom">
{{svg "octicon-git-commit"}}
</div>
</div>
{{end}}
{{if and .PageIsPullFiles $.SignedUserID (not .IsArchived)}}
{{template "repo/diff/new_review" .}}
{{end}}
</div>
</div>
{{if not .DiffNotAvailable}}
{{if and .IsShowingOnlySingleCommit .PageIsPullFiles}}
<div class="ui info message">
<div>{{.locale.Tr "repo.pulls.showing_only_single_commit" (ShortSha .BeforeCommitID)}} - <a href="{{$.Issue.Link}}/files?style={{if $.IsSplitStyle}}split{{else}}unified{{end}}&whitespace={{$.WhitespaceBehavior}}&show-outdated={{$.ShowOutdatedComments}}">{{.locale.Tr "repo.pulls.show_all_commits"}}</a></div>
</div>
{{else if and (not .IsShowingAllCommits) .PageIsPullFiles}}
<div class="ui info message">
<div>{{.locale.Tr "repo.pulls.showing_specified_commit_range" (ShortSha .BeforeCommitID) (ShortSha .AfterCommitID)}} - <a href="{{$.Issue.Link}}/files?style={{if $.IsSplitStyle}}split{{else}}unified{{end}}&whitespace={{$.WhitespaceBehavior}}&show-outdated={{$.ShowOutdatedComments}}">{{.locale.Tr "repo.pulls.show_all_commits"}}</a></div>
</div>
{{end}}
<script id="diff-data-script" type="module">
const diffDataFiles = [{{range $i, $file := .Diff.Files}}{Name:"{{$file.Name}}",NameHash:"{{$file.NameHash}}",Type:{{$file.Type}},IsBin:{{$file.IsBin}},Addition:{{$file.Addition}},Deletion:{{$file.Deletion}},IsViewed:{{$file.IsViewed}}},{{end}}];
const diffData = {
@ -81,7 +101,7 @@
{{$isCsv := (call $.IsCsvFile $file)}}
{{$showFileViewToggle := or $isImage (and (not $file.IsIncomplete) $isCsv)}}
{{$isExpandable := or (gt $file.Addition 0) (gt $file.Deletion 0) $file.IsBin}}
{{$isReviewFile := and $.IsSigned $.PageIsPullFiles (not $.IsArchived)}}
{{$isReviewFile := and $.IsSigned $.PageIsPullFiles (not $.IsArchived) $.IsShowingAllCommits}}
<div class="diff-file-box diff-box file-content {{TabSizeClass $.Editorconfig $file.Name}} gt-mt-0" id="diff-{{$file.NameHash}}" data-old-filename="{{$file.OldName}}" data-new-filename="{{$file.Name}}" {{if or ($file.ShouldBeHidden) (not $isExpandable)}}data-folded="true"{{end}}>
<h4 class="diff-file-header sticky-2nd-row ui top attached normal header gt-df gt-ac gt-sb gt-fw">
<div class="diff-file-name gt-df gt-ac gt-gap-2 gt-fw">
@ -146,7 +166,7 @@
{{end}}
</div>
</h4>
<div class="diff-file-body ui attached unstackable table segment" {{if $file.IsViewed}}data-folded="true"{{end}}>
<div class="diff-file-body ui attached unstackable table segment" {{if and $file.IsViewed $.IsShowingAllCommits}}data-folded="true"{{end}}>
<div id="diff-source-{{$file.NameHash}}" class="file-body file-code unicode-escaped code-diff{{if $.IsSplitStyle}} code-diff-split{{else}} code-diff-unified{{end}}{{if $showFileViewToggle}} gt-hidden{{end}}">
{{if or $file.IsIncomplete $file.IsBin}}
<div class="diff-file-body binary" style="padding: 5px 10px;">

@ -1,9 +1,10 @@
<div id="review-box">
<button class="ui tiny green button gt-pr-2 gt-df js-btn-review">
<button class="ui tiny green button gt-pr-2 gt-df js-btn-review {{if not $.IsShowingAllCommits}}disabled{{end}}" {{if not $.IsShowingAllCommits}}data-tooltip-content="{{$.locale.Tr "repo.pulls.review_only_possible_for_full_diff"}}"{{end}}>
{{.locale.Tr "repo.diff.review"}}
<span class="ui small label review-comments-counter" data-pending-comment-number="{{.PendingCodeCommentNumber}}">{{.PendingCodeCommentNumber}}</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
</button>
{{if $.IsShowingAllCommits}}
<div class="review-box-panel tippy-target">
<div class="ui segment">
<form class="ui form form-fetch-action" action="{{.Link}}/reviews/submit" method="post">
@ -48,4 +49,5 @@
</form>
</div>
</div>
{{end}}
</div>

@ -0,0 +1,4 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

@ -0,0 +1,3 @@
1978192d98bb1b65e11c2cf37da854fbf94bffd6 refs/heads/branch1
cbff181af4c9c7fee3cf6c106699e07d9a3f54e6 refs/heads/main
1978192d98bb1b65e11c2cf37da854fbf94bffd6 refs/pull/1/head

@ -0,0 +1 @@
0000000000000000000000000000000000000000 cbff181af4c9c7fee3cf6c106699e07d9a3f54e6 Gitea <gitea@fake.local> 1688672318 +0200

@ -0,0 +1 @@
0000000000000000000000000000000000000000 1978192d98bb1b65e11c2cf37da854fbf94bffd6 Gitea <gitea@fake.local> 1688672383 +0200 push

@ -0,0 +1 @@
0000000000000000000000000000000000000000 cbff181af4c9c7fee3cf6c106699e07d9a3f54e6 root <sauer.sebastian@gmail.com> 1688672317 +0200 push

@ -0,0 +1,2 @@
x¥ŽA
Â0E]ç³$™´™D¼ƒ'˜If´`­´éý­ OàêÁƒ÷ùež¦±váÐUˆC©\Q;_ò<EFBFBD>™…%VÏHÆæ<EFBFBD>DS Ú»7/újPú„ÉJV³žT‚ å$>Ô®zC‹Foí1/pSáµ<EFBFBD>ü‚oºÀyýâ´þôõ>ñø<•yº@HÃ<EFBFBD>#E8zôÞív?Ûöì¯×tmйJÝNê

@ -0,0 +1,2 @@
x¥<EFBFBD>M
ֲ0F]ח³ֺה§<EFBFBD> ˆx‡<EFBFBD>`ׂL´`­´י<EFBFBD> Oאךƒ<EFBFBD>דMכ²ּ\°§÷©‚c;₪R²`<EFBFBD>°8Oװ«„₪<EFBFBD>bVִ<EFBFBD>‹gפז-›¾*LװXך)<EFBFBD>q9זּ>ה>‘÷ֲ<EFBFBD>"9ךc<EFBFBD>`װ${<EFBFBD>ו£÷ֱe<EFBFBD><EFBFBD>נם¾ָ<EFBFBD>ל¦u¹‚˜j ־טM£-¶¶<EFBFBD>_Su¯@ז¼DLג

@ -0,0 +1,3 @@
x¥ŽA
Â0E]ç³ÊL'MRñ=Á$™hÁZiÓûžÀՇǟŸÖe™+ô–NuS…àSNÄÌe(D^ƾpÒâƒFEF"²‘¡y˦¯
Þúœ#èÄA+¾‘€­>8QreÔ‘9'#G}¬Le¯³¼`’C7¸ìßèö¾Ý™Ÿ]Z—+<EFBFBD> Áùž=Ã{DÓh;[›ö׌©ºWÍȵM

@ -0,0 +1,2 @@
x+)JMU067`040031QrutńuŐËMa¸–ďšĎ!Ľ´E~óÓŹGŠYM…**I-.1Ô+©(axsóď­<EFBFBD>F‡Đw‰S…îŘ%˝gS’"#°"ˬ€Ů)ýôBSć
p·˝Ř™sŕ)’"c°˘KáS÷ď‘ö°Ě¬köžZ›xÂv¦?’"°˘é<±ŻKŐf؇ú­Z¸u"ÓĺÇľ#)2+2ý`'ž÷ě’OŰÖ3ËfEs/Z †¤Č ¬¨Y×ř‰ĹĄ-+ňw5žN߬+›¸Bă4’"s°˘ŕY*Ťę¬ßKZÂú˛®ßn)ó‘d><EFBFBD>¤Č¬<EFBFBD>+÷–łLѲ%«DĎx,9]K*ô<EFBFBD> ’"K°"<EFBFBD>Yěđăč­»óAÂ|ŞÄ–ɉźZvŰ“G

@ -0,0 +1,2 @@
xĄŽA
Â0E]çłĘ4I3ń=Á$L´`¬4éý­ OŕęÁ<EFBFBD>÷ůy­ué`ýxę›*°%L<EFBFBD>AEÂT˛F‹Łě)bˇŔČć-›ľ:pČZĽP"›\¶ťGŃP0—ivĘH”ŮŮűcÝ`Ö$­/ň‚YvÝŕŇľÚOßîU–ç<EFBFBD>×z…1ÄČ:rpF‹h{śíGö׌éÚ:8ó•ELÇ

@ -219,7 +219,7 @@ func TestAPISearchIssues(t *testing.T) {
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadIssue)
// as this API was used in the frontend, it uses UI page size
expectedIssueCount := 16 // from the fixtures
expectedIssueCount := 17 // from the fixtures
if expectedIssueCount > setting.UI.IssuePagingNum {
expectedIssueCount = setting.UI.IssuePagingNum
}
@ -243,7 +243,7 @@ func TestAPISearchIssues(t *testing.T) {
req = NewRequest(t, "GET", link.String())
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 9)
assert.Len(t, apiIssues, 10)
query.Del("since")
query.Del("before")
@ -259,15 +259,15 @@ func TestAPISearchIssues(t *testing.T) {
req = NewRequest(t, "GET", link.String())
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count"))
assert.Len(t, apiIssues, 18)
assert.EqualValues(t, "19", resp.Header().Get("X-Total-Count"))
assert.Len(t, apiIssues, 19)
query.Add("limit", "10")
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count"))
assert.EqualValues(t, "19", resp.Header().Get("X-Total-Count"))
assert.Len(t, apiIssues, 10)
query = url.Values{"assigned": {"true"}, "state": {"all"}, "token": {token}}
@ -296,7 +296,7 @@ func TestAPISearchIssues(t *testing.T) {
req = NewRequest(t, "GET", link.String())
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 7)
assert.Len(t, apiIssues, 8)
query = url.Values{"owner": {"user3"}, "token": {token}} // organization
link.RawQuery = query.Encode()
@ -317,7 +317,7 @@ func TestAPISearchIssuesWithLabels(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// as this API was used in the frontend, it uses UI page size
expectedIssueCount := 16 // from the fixtures
expectedIssueCount := 17 // from the fixtures
if expectedIssueCount > setting.UI.IssuePagingNum {
expectedIssueCount = setting.UI.IssuePagingNum
}

@ -33,7 +33,7 @@ func TestNodeinfo(t *testing.T) {
assert.True(t, nodeinfo.OpenRegistrations)
assert.Equal(t, "gitea", nodeinfo.Software.Name)
assert.Equal(t, 25, nodeinfo.Usage.Users.Total)
assert.Equal(t, 18, nodeinfo.Usage.LocalPosts)
assert.Equal(t, 19, nodeinfo.Usage.LocalPosts)
assert.Equal(t, 2, nodeinfo.Usage.LocalComments)
})
}

@ -93,9 +93,9 @@ func TestAPISearchRepo(t *testing.T) {
}{
{
name: "RepositoriesMax50", requestURL: "/api/v1/repos/search?limit=50&private=false", expectedResults: expectedResults{
nil: {count: 32},
user: {count: 32},
user2: {count: 32},
nil: {count: 33},
user: {count: 33},
user2: {count: 33},
},
},
{

@ -356,7 +356,7 @@ func TestSearchIssues(t *testing.T) {
session := loginUser(t, "user2")
expectedIssueCount := 16 // from the fixtures
expectedIssueCount := 17 // from the fixtures
if expectedIssueCount > setting.UI.IssuePagingNum {
expectedIssueCount = setting.UI.IssuePagingNum
}
@ -377,7 +377,7 @@ func TestSearchIssues(t *testing.T) {
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 9)
assert.Len(t, apiIssues, 10)
query.Del("since")
query.Del("before")
@ -393,15 +393,15 @@ func TestSearchIssues(t *testing.T) {
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count"))
assert.Len(t, apiIssues, 18)
assert.EqualValues(t, "19", resp.Header().Get("X-Total-Count"))
assert.Len(t, apiIssues, 19)
query.Add("limit", "5")
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count"))
assert.EqualValues(t, "19", resp.Header().Get("X-Total-Count"))
assert.Len(t, apiIssues, 5)
query = url.Values{"assigned": {"true"}, "state": {"all"}}
@ -430,7 +430,7 @@ func TestSearchIssues(t *testing.T) {
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 7)
assert.Len(t, apiIssues, 8)
query = url.Values{"owner": {"user3"}} // organization
link.RawQuery = query.Encode()
@ -450,7 +450,7 @@ func TestSearchIssues(t *testing.T) {
func TestSearchIssuesWithLabels(t *testing.T) {
defer tests.PrepareTestEnv(t)()
expectedIssueCount := 16 // from the fixtures
expectedIssueCount := 17 // from the fixtures
if expectedIssueCount > setting.UI.IssuePagingNum {
expectedIssueCount = setting.UI.IssuePagingNum
}

@ -0,0 +1,58 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
"code.gitea.io/gitea/tests"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
)
func TestPullDiff_CompletePRDiff(t *testing.T) {
doTestPRDiff(t, "/user2/commitsonpr/pulls/1/files", false, []string{"test1.txt", "test10.txt", "test2.txt", "test3.txt", "test4.txt", "test5.txt", "test6.txt", "test7.txt", "test8.txt", "test9.txt"})
}
func TestPullDiff_SingleCommitPRDiff(t *testing.T) {
doTestPRDiff(t, "/user2/commitsonpr/pulls/1/commits/c5626fc9eff57eb1bb7b796b01d4d0f2f3f792a2", true, []string{"test3.txt"})
}
func TestPullDiff_CommitRangePRDiff(t *testing.T) {
doTestPRDiff(t, "/user2/commitsonpr/pulls/1/files/4ca8bcaf27e28504df7bf996819665986b01c847..23576dd018294e476c06e569b6b0f170d0558705", true, []string{"test2.txt", "test3.txt", "test4.txt"})
}
func TestPullDiff_StartingFromBaseToCommitPRDiff(t *testing.T) {
doTestPRDiff(t, "/user2/commitsonpr/pulls/1/files/c5626fc9eff57eb1bb7b796b01d4d0f2f3f792a2", true, []string{"test1.txt", "test2.txt", "test3.txt"})
}
func doTestPRDiff(t *testing.T, prDiffURL string, reviewBtnDisabled bool, expectedFilenames []string) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user2/commitsonpr/pulls")
session.MakeRequest(t, req, http.StatusOK)
// Get the given PR diff url
req = NewRequest(t, "GET", prDiffURL)
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
// Assert all files are visible.
fileContents := doc.doc.Find(".file-content")
numberOfFiles := fileContents.Length()
assert.Equal(t, len(expectedFilenames), numberOfFiles)
fileContents.Each(func(i int, s *goquery.Selection) {
filename, _ := s.Attr("data-old-filename")
assert.Equal(t, expectedFilenames[i], filename)
})
// Ensure the review button is enabled for full PR reviews
assert.Equal(t, reviewBtnDisabled, doc.doc.Find(".js-btn-review").HasClass("disabled"))
}

@ -633,6 +633,11 @@ a.label,
color: var(--color-text-light-2);
}
.ui.dropdown > .text > .description,
.ui.dropdown .menu > .item > .description {
color: var(--color-text-light-2);
}
.ui.list .list > .item .header,
.ui.list > .item .header {
color: var(--color-text-dark);

@ -0,0 +1,299 @@
<template>
<div class="ui scrolling dropdown custom">
<button
class="ui basic button"
id="diff-commit-list-expand"
@click.stop="toggleMenu()"
:data-tooltip-content="locale.filter_changes_by_commit"
aria-haspopup="true"
tabindex="0"
aria-controls="diff-commit-selector-menu"
:aria-label="locale.filter_changes_by_commit"
aria-activedescendant="diff-commit-list-show-all"
>
<svg-icon name="octicon-git-commit"/>
</button>
<div class="menu left transition" id="diff-commit-selector-menu" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak :aria-expanded="menuVisible ? 'true': 'false'">
<div class="loading-indicator is-loading" v-if="isLoading"/>
<div v-if="!isLoading" class="vertical item gt-df gt-fc gt-gap-2" id="diff-commit-list-show-all" role="menuitem" tabindex="-1" @keydown.enter="showAllChanges()" @click="showAllChanges()">
<div class="gt-ellipsis">
{{ locale.show_all_commits }}
</div>
<div class="gt-ellipsis text light-2 gt-mb-0">
{{ locale.stats_num_commits }}
</div>
</div>
<!-- only show the show changes since last review if there is a review AND we are commits ahead of the last review -->
<div
v-if="lastReviewCommitSha != null" role="menuitem" tabindex="-1"
class="vertical item gt-df gt-fc gt-gap-2 gt-border-secondary-top"
:class="{disabled: commitsSinceLastReview === 0}"
@keydown.enter="changesSinceLastReviewClick()"
@click="changesSinceLastReviewClick()"
>
<div class="gt-ellipsis">
{{ locale.show_changes_since_your_last_review }}
</div>
<div class="gt-ellipsis text light-2">
{{ commitsSinceLastReview }} commits
</div>
</div>
<span v-if="!isLoading" class="info gt-border-secondary-top text light-2">{{ locale.select_commit_hold_shift_for_range }}</span>
<template v-for="commit in commits" :key="commit.id">
<div
class="vertical item gt-df gt-gap-2 gt-border-secondary-top" role="menuitem" tabindex="-1"
:class="{selection: commit.selected, hovered: commit.hovered}"
@keydown.enter.exact="commitClicked(commit.id)"
@keydown.enter.shift.exact="commitClickedShift(commit)"
@mouseover.shift="highlight(commit)"
@click.exact="commitClicked(commit.id)"
@click.ctrl.exact="commitClicked(commit.id, true)"
@click.meta.exact="commitClicked(commit.id, true)"
@click.shift.exact.stop.prevent="commitClickedShift(commit)"
>
<div class="gt-f1 gt-df gt-fc gt-gap-2">
<div class="gt-ellipsis commit-list-summary">
{{ commit.summary }}
</div>
<div class="gt-ellipsis text light-2">
{{ commit.committer_or_author_name }}
<span class="text right">
<relative-time class="time-since" prefix="" :datetime="commit.time" data-tooltip-content data-tooltip-interactive="true">{{ commit.time }}</relative-time>
</span>
</div>
</div>
<div class="gt-mono">
{{ commit.short_sha }}
</div>
</div>
</template>
</div>
</div>
</template>
<script>
import {SvgIcon} from '../svg.js';
export default {
components: {SvgIcon},
data: () => {
return {
menuVisible: false,
isLoading: false,
locale: {},
commits: [],
hoverActivated: false,
lastReviewCommitSha: null
};
},
computed: {
commitsSinceLastReview() {
if (this.lastReviewCommitSha) {
return this.commits.length - this.commits.findIndex((x) => x.id === this.lastReviewCommitSha) - 1;
}
return 0;
},
queryParams() {
return this.$el.parentNode.getAttribute('data-queryparams');
},
issueLink() {
return this.$el.parentNode.getAttribute('data-issuelink');
}
},
mounted() {
document.body.addEventListener('click', this.onBodyClick);
this.$el.addEventListener('keydown', this.onKeyDown);
this.$el.addEventListener('keyup', this.onKeyUp);
},
unmounted() {
document.body.removeEventListener('click', this.onBodyClick);
this.$el.removeEventListener('keydown', this.onKeyDown);
this.$el.removeEventListener('keyup', this.onKeyUp);
},
methods: {
onBodyClick(event) {
// close this menu on click outside of this element when the dropdown is currently visible opened
if (this.$el.contains(event.target)) return;
if (this.menuVisible) {
this.toggleMenu();
}
},
onKeyDown(event) {
if (!this.menuVisible) return;
const item = document.activeElement;
if (!this.$el.contains(item)) return;
switch (event.key) {
case 'ArrowDown': // select next element
event.preventDefault();
this.focusElem(item.nextElementSibling, item);
break;
case 'ArrowUp': // select previous element
event.preventDefault();
this.focusElem(item.previousElementSibling, item);
break;
case 'Escape': // close menu
event.preventDefault();
item.tabIndex = -1;
this.toggleMenu();
break;
}
},
onKeyUp(event) {
if (!this.menuVisible) return;
const item = document.activeElement;
if (!this.$el.contains(item)) return;
if (event.key === 'Shift' && this.hoverActivated) {
// shift is not pressed anymore -> deactivate hovering and reset hovered and selected
this.hoverActivated = false;
for (const commit of this.commits) {
commit.hovered = false;
commit.selected = false;
}
}
},
highlight(commit) {
if (!this.hoverActivated) return;
const indexSelected = this.commits.findIndex((x) => x.selected);
const indexCurrentElem = this.commits.findIndex((x) => x.id === commit.id);
for (const [idx, commit] of this.commits.entries()) {
commit.hovered = Math.min(indexSelected, indexCurrentElem) <= idx && idx <= Math.max(indexSelected, indexCurrentElem);
}
},
/** Focus given element */
focusElem(elem, prevElem) {
if (elem) {
elem.tabIndex = 0;
prevElem.tabIndex = -1;
elem.focus();
}
},
/** Opens our menu, loads commits before opening */
async toggleMenu() {
this.menuVisible = !this.menuVisible;
// load our commits when the menu is not yet visible (it'll be toggled after loading)
// and we got no commits
if (this.commits.length === 0 && this.menuVisible && !this.isLoading) {
this.isLoading = true;
try {
await this.fetchCommits();
} finally {
this.isLoading = false;
}
}
// set correct tabindex to allow easier navigation
this.$nextTick(() => {
const expandBtn = this.$el.querySelector('#diff-commit-list-expand');
const showAllChanges = this.$el.querySelector('#diff-commit-list-show-all');
if (this.menuVisible) {
this.focusElem(showAllChanges, expandBtn);
} else {
this.focusElem(expandBtn, showAllChanges);
}
});
},
/** Load the commits to show in this dropdown */
async fetchCommits() {
const resp = await fetch(`${this.issueLink}/commits/list`);
const results = await resp.json();
this.commits.push(...results.commits.map((x) => {
x.hovered = false;
return x;
}));
this.commits.reverse();
this.lastReviewCommitSha = results.last_review_commit_sha || null;
if (this.lastReviewCommitSha && this.commits.findIndex((x) => x.id === this.lastReviewCommitSha) === -1) {
// the lastReviewCommit is not available (probably due to a force push)
// reset the last review commit sha
this.lastReviewCommitSha = null;
}
Object.assign(this.locale, results.locale);
},
showAllChanges() {
window.location = `${this.issueLink}/files${this.queryParams}`;
},
/** Called when user clicks on since last review */
changesSinceLastReviewClick() {
window.location = `${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1).id}${this.queryParams}`;
},
/** Clicking on a single commit opens this specific commit */
commitClicked(commitId, newWindow = false) {
const url = `${this.issueLink}/commits/${commitId}${this.queryParams}`;
if (newWindow) {
window.open(url);
} else {
window.location = url;
}
},
/**
* When a commit is clicked with shift this enables the range
* selection. Second click (with shift) defines the end of the
* range. This opens the diff of this range
* Exception: first commit is the first commit of this PR. Then
* the diff from beginning of PR up to the second clicked commit is
* opened
*/
commitClickedShift(commit) {
this.hoverActivated = !this.hoverActivated;
commit.selected = true;
// Second click -> determine our range and open links accordingly
if (!this.hoverActivated) {
// find all selected commits and generate a link
if (this.commits[0].selected) {
// first commit is selected - generate a short url with only target sha
const lastCommitIdx = this.commits.findLastIndex((x) => x.selected);
if (lastCommitIdx === this.commits.length - 1) {
// user selected all commits - just show the normal diff page
window.location = `${this.issueLink}/files${this.queryParams}`;
} else {
window.location = `${this.issueLink}/files/${this.commits[lastCommitIdx].id}${this.queryParams}`;
}
} else {
const start = this.commits[this.commits.findIndex((x) => x.selected) - 1].id;
const end = this.commits.findLast((x) => x.selected).id;
window.location = `${this.issueLink}/files/${start}..${end}${this.queryParams}`;
}
}
},
}
};
</script>
<style scoped>
.hovered:not(.selection) {
background-color: var(--color-small-accent) !important;
}
.selection {
background-color: var(--color-accent) !important;
}
.info {
display: inline-block;
padding: 7px 14px !important;
line-height: 1.4;
width: 100%;
}
#diff-commit-selector-menu {
overflow-x: hidden;
max-height: 450px;
}
#diff-commit-selector-menu .loading-indicator {
height: 200px;
width: 350px;
}
#diff-commit-selector-menu .item {
flex-direction: row;
line-height: 1.4;
padding: 7px 14px !important;
}
#diff-commit-selector-menu .item:focus {
color: var(--color-text);
background: var(--color-hover);
}
#diff-commit-selector-menu .commit-list-summary {
max-width: min(380px, 96vw);
}
</style>

@ -0,0 +1,10 @@
import {createApp} from 'vue';
import DiffCommitSelector from '../components/DiffCommitSelector.vue';
export function initDiffCommitSelect() {
const el = document.getElementById('diff-commit-select');
if (!el) return;
const commitSelect = createApp(DiffCommitSelector);
commitSelect.mount(el);
}

@ -2,6 +2,7 @@ import $ from 'jquery';
import {initCompReactionSelector} from './comp/ReactionSelector.js';
import {initRepoIssueContentHistory} from './repo-issue-content.js';
import {initDiffFileTree} from './repo-diff-filetree.js';
import {initDiffCommitSelect} from './repo-diff-commitselect.js';
import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.js';
import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles, initExpandAndCollapseFilesButton} from './pull-view-file.js';
import {initImageDiff} from './imagediff.js';
@ -188,6 +189,7 @@ export function initRepoDiffView() {
const diffFileList = $('#diff-file-list');
if (diffFileList.length === 0) return;
initDiffFileTree();
initDiffCommitSelect();
initRepoDiffShowMore();
initRepoDiffReviewButton();
initRepoDiffFileViewToggle();

@ -29,6 +29,7 @@ import octiconFileDirectoryFill from '../../public/assets/img/svg/octicon-file-d
import octiconFilter from '../../public/assets/img/svg/octicon-filter.svg';
import octiconGear from '../../public/assets/img/svg/octicon-gear.svg';
import octiconGitBranch from '../../public/assets/img/svg/octicon-git-branch.svg';
import octiconGitCommit from '../../public/assets/img/svg/octicon-git-commit.svg';
import octiconGitMerge from '../../public/assets/img/svg/octicon-git-merge.svg';
import octiconGitPullRequest from '../../public/assets/img/svg/octicon-git-pull-request.svg';
import octiconHeading from '../../public/assets/img/svg/octicon-heading.svg';
@ -99,6 +100,7 @@ const svgs = {
'octicon-filter': octiconFilter,
'octicon-gear': octiconGear,
'octicon-git-branch': octiconGitBranch,
'octicon-git-commit': octiconGitCommit,
'octicon-git-merge': octiconGitMerge,
'octicon-git-pull-request': octiconGitPullRequest,
'octicon-heading': octiconHeading,

Loading…
Cancel
Save