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>
This commit is contained in:
sebastian-sauer 2023-07-28 21:18:12 +02:00 committed by GitHub
parent 4971a10543
commit 55532061c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
71 changed files with 748 additions and 35 deletions

View file

@ -304,3 +304,20 @@
created_unix: 946684830 created_unix: 946684830
updated_unix: 978307200 updated_unix: 978307200
is_locked: false 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

View file

@ -76,3 +76,16 @@
base_branch: master base_branch: master
merge_base: 2a47ca4b614a9f5a merge_base: 2a47ca4b614a9f5a
has_merged: false 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

View file

@ -607,3 +607,33 @@
repo_id: 52 repo_id: 52
type: 1 type: 1
created_unix: 946684810 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

View file

@ -1662,3 +1662,34 @@
is_private: false is_private: false
status: 0 status: 0
num_issues: 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

View file

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

View file

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

View file

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

View file

@ -235,12 +235,12 @@ func TestSearchRepository(t *testing.T) {
{ {
name: "AllPublic/PublicRepositoriesOfUserIncludingCollaborative", name: "AllPublic/PublicRepositoriesOfUserIncludingCollaborative",
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, AllPublic: true, Template: util.OptionalBoolFalse}, 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", 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}, 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", name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborativeByName",
@ -255,7 +255,7 @@ func TestSearchRepository(t *testing.T) {
{ {
name: "AllPublic/PublicRepositoriesOfOrganization", name: "AllPublic/PublicRepositoriesOfOrganization",
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 17, AllPublic: true, Collaborate: util.OptionalBoolFalse, Template: util.OptionalBoolFalse}, 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", name: "AllTemplates",

View file

@ -1662,6 +1662,13 @@ pulls.switch_comparison_type = Switch comparison type
pulls.switch_head_and_base = Switch head and base pulls.switch_head_and_base = Switch head and base
pulls.filter_branch = Filter branch pulls.filter_branch = Filter branch
pulls.no_results = No results found. 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 = 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.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>` pulls.has_pull_request = `A pull request between these branches already exists: <a href="%[1]s">%[2]s#%[3]d</a>`

View file

@ -694,6 +694,42 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C
return compareInfo 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 // ViewPullCommits show commits for a pull request
func ViewPullCommits(ctx *context.Context) { func ViewPullCommits(ctx *context.Context) {
ctx.Data["PageIsPullList"] = true ctx.Data["PageIsPullList"] = true
@ -739,7 +775,7 @@ func ViewPullCommits(ctx *context.Context) {
} }
// ViewPullFiles render pull request changed files list page // 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["PageIsPullList"] = true
ctx.Data["PageIsPullFiles"] = true ctx.Data["PageIsPullFiles"] = true
@ -762,6 +798,33 @@ func ViewPullFiles(ctx *context.Context) {
prInfo = PrepareViewPullInfo(ctx, issue) 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() { if ctx.Written() {
return return
} else if prInfo == nil { } else if prInfo == nil {
@ -775,12 +838,30 @@ func ViewPullFiles(ctx *context.Context) {
return return
} }
startCommitID = prInfo.MergeBase ctx.Data["IsShowingOnlySingleCommit"] = willShowSpecifiedCommit
if willShowSpecifiedCommit || willShowSpecifiedCommitRange {
if len(specifiedEndCommit) > 0 {
endCommitID = specifiedEndCommit
} else {
endCommitID = headCommitID 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["Username"] = ctx.Repo.Owner.Name
ctx.Data["Reponame"] = ctx.Repo.Repository.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name
ctx.Data["AfterCommitID"] = endCommitID ctx.Data["AfterCommitID"] = endCommitID
ctx.Data["BeforeCommitID"] = startCommitID
fileOnly := ctx.FormBool("file-only") fileOnly := ctx.FormBool("file-only")
@ -789,8 +870,8 @@ func ViewPullFiles(ctx *context.Context) {
if fileOnly && (len(files) == 2 || len(files) == 1) { if fileOnly && (len(files) == 2 || len(files) == 1) {
maxLines, maxFiles = -1, -1 maxLines, maxFiles = -1, -1
} }
diffOptions := &gitdiff.DiffOptions{ diffOptions := &gitdiff.DiffOptions{
BeforeCommitID: startCommitID,
AfterCommitID: endCommitID, AfterCommitID: endCommitID,
SkipTo: ctx.FormString("skip-to"), SkipTo: ctx.FormString("skip-to"),
MaxLines: maxLines, MaxLines: maxLines,
@ -799,9 +880,18 @@ func ViewPullFiles(ctx *context.Context) {
WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)), WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)),
} }
if !willShowSpecifiedCommit {
diffOptions.BeforeCommitID = startCommitID
}
var methodWithError string var methodWithError string
var diff *gitdiff.Diff 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...) diff, err = gitdiff.GetDiff(gitRepo, diffOptions, files...)
methodWithError = "GetDiff" methodWithError = "GetDiff"
} else { } else {
@ -908,6 +998,22 @@ func ViewPullFiles(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplPullFiles) 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 // UpdatePullRequest merge PR's baseBranch into headBranch
func UpdatePullRequest(ctx *context.Context) { func UpdatePullRequest(ctx *context.Context) {
issue := checkPullInfo(ctx) issue := checkPullInfo(ctx)

View file

@ -75,7 +75,7 @@ func TestPulls(t *testing.T) {
Pulls(ctx) Pulls(ctx)
assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) 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) { func TestMilestones(t *testing.T) {

View file

@ -1279,14 +1279,20 @@ func registerRoutes(m *web.Route) {
m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue) m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue)
m.Get(".diff", repo.DownloadPullDiff) m.Get(".diff", repo.DownloadPullDiff)
m.Get(".patch", repo.DownloadPullPatch) 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("/merge", context.RepoMustNotBeArchived(), web.Bind(forms.MergePullRequestForm{}), repo.MergePullRequest)
m.Post("/cancel_auto_merge", context.RepoMustNotBeArchived(), repo.CancelAutoMergePullRequest) m.Post("/cancel_auto_merge", context.RepoMustNotBeArchived(), repo.CancelAutoMergePullRequest)
m.Post("/update", repo.UpdatePullRequest) m.Post("/update", repo.UpdatePullRequest)
m.Post("/set_allow_maintainer_edit", web.Bind(forms.UpdateAllowEditsForm{}), repo.SetAllowEdits) m.Post("/set_allow_maintainer_edit", web.Bind(forms.UpdateAllowEditsForm{}), repo.SetAllowEdits)
m.Post("/cleanup", context.RepoMustNotBeArchived(), context.RepoRef(), repo.CleanUpPullRequest) m.Post("/cleanup", context.RepoMustNotBeArchived(), context.RepoRef(), repo.CleanUpPullRequest)
m.Group("/files", func() { 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.Group("/reviews", func() {
m.Get("/new_comment", repo.RenderNewCodeCommentForm) m.Get("/new_comment", repo.RenderNewCodeCommentForm)
m.Post("/comments", web.Bind(forms.CodeCommentForm{}), repo.SetShowOutdatedComments, repo.CreateCodeComment) m.Post("/comments", web.Bind(forms.CodeCommentForm{}), repo.SetShowOutdatedComments, repo.CreateCodeComment)

View file

@ -10,6 +10,7 @@ import (
"os" "os"
"regexp" "regexp"
"strings" "strings"
"time"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
@ -17,7 +18,9 @@ import (
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/container" "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/git"
"code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/json" "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) 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
}

View file

@ -31,12 +31,32 @@
{{end}} {{end}}
{{template "repo/diff/whitespace_dropdown" .}} {{template "repo/diff/whitespace_dropdown" .}}
{{template "repo/diff/options_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)}} {{if and .PageIsPullFiles $.SignedUserID (not .IsArchived)}}
{{template "repo/diff/new_review" .}} {{template "repo/diff/new_review" .}}
{{end}} {{end}}
</div> </div>
</div> </div>
{{if not .DiffNotAvailable}} {{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"> <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 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 = { const diffData = {
@ -81,7 +101,7 @@
{{$isCsv := (call $.IsCsvFile $file)}} {{$isCsv := (call $.IsCsvFile $file)}}
{{$showFileViewToggle := or $isImage (and (not $file.IsIncomplete) $isCsv)}} {{$showFileViewToggle := or $isImage (and (not $file.IsIncomplete) $isCsv)}}
{{$isExpandable := or (gt $file.Addition 0) (gt $file.Deletion 0) $file.IsBin}} {{$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}}> <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"> <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"> <div class="diff-file-name gt-df gt-ac gt-gap-2 gt-fw">
@ -146,7 +166,7 @@
{{end}} {{end}}
</div> </div>
</h4> </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}}"> <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}} {{if or $file.IsIncomplete $file.IsBin}}
<div class="diff-file-body binary" style="padding: 5px 10px;"> <div class="diff-file-body binary" style="padding: 5px 10px;">

View file

@ -1,9 +1,10 @@
<div id="review-box"> <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"}} {{.locale.Tr "repo.diff.review"}}
<span class="ui small label review-comments-counter" data-pending-comment-number="{{.PendingCodeCommentNumber}}">{{.PendingCodeCommentNumber}}</span> <span class="ui small label review-comments-counter" data-pending-comment-number="{{.PendingCodeCommentNumber}}">{{.PendingCodeCommentNumber}}</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}
</button> </button>
{{if $.IsShowingAllCommits}}
<div class="review-box-panel tippy-target"> <div class="review-box-panel tippy-target">
<div class="ui segment"> <div class="ui segment">
<form class="ui form form-fetch-action" action="{{.Link}}/reviews/submit" method="post"> <form class="ui form form-fetch-action" action="{{.Link}}/reviews/submit" method="post">
@ -48,4 +49,5 @@
</form> </form>
</div> </div>
</div> </div>
{{end}}
</div> </div>

View file

@ -0,0 +1 @@
ref: refs/heads/main

View file

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

View file

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

View file

@ -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]
# *~

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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—+<2B> Áùž=Ã{DÓh;[ö׌©ºWÍȵM

View file

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

View file

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

View file

@ -0,0 +1 @@
1978192d98bb1b65e11c2cf37da854fbf94bffd6

View file

@ -0,0 +1 @@
cbff181af4c9c7fee3cf6c106699e07d9a3f54e6

View file

@ -0,0 +1 @@
cbff181af4c9c7fee3cf6c106699e07d9a3f54e6

View file

@ -0,0 +1 @@
1978192d98bb1b65e11c2cf37da854fbf94bffd6

View file

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

View file

@ -33,7 +33,7 @@ func TestNodeinfo(t *testing.T) {
assert.True(t, nodeinfo.OpenRegistrations) assert.True(t, nodeinfo.OpenRegistrations)
assert.Equal(t, "gitea", nodeinfo.Software.Name) assert.Equal(t, "gitea", nodeinfo.Software.Name)
assert.Equal(t, 25, nodeinfo.Usage.Users.Total) 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) assert.Equal(t, 2, nodeinfo.Usage.LocalComments)
}) })
} }

View file

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

View file

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

View file

@ -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"))
}

View file

@ -633,6 +633,11 @@ a.label,
color: var(--color-text-light-2); 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 .list > .item .header,
.ui.list > .item .header { .ui.list > .item .header {
color: var(--color-text-dark); color: var(--color-text-dark);

View file

@ -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>

View file

@ -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);
}

View file

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

View file

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