Allow to mark files in a PR as viewed (#19007)

Users can now mark files in PRs as viewed, resulting in them not being shown again by default when they reopen the PR again.
This commit is contained in:
delvh 2022-05-07 20:28:10 +02:00 committed by GitHub
parent 59b30f060a
commit 5ca224a789
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 492 additions and 44 deletions

View file

@ -385,6 +385,8 @@ var migrations = []Migration{
NewMigration("Add allow edits from maintainers to PullRequest table", addAllowMaintainerEdit), NewMigration("Add allow edits from maintainers to PullRequest table", addAllowMaintainerEdit),
// v214 -> v215 // v214 -> v215
NewMigration("Add auto merge table", addAutoMergeTable), NewMigration("Add auto merge table", addAutoMergeTable),
// v215 -> v216
NewMigration("allow to view files in PRs", addReviewViewedFiles),
} }
// GetCurrentDBVersion returns the current db version // GetCurrentDBVersion returns the current db version

25
models/migrations/v215.go Normal file
View file

@ -0,0 +1,25 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package migrations
import (
"code.gitea.io/gitea/models/pull"
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/xorm"
)
func addReviewViewedFiles(x *xorm.Engine) error {
type ReviewState struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL UNIQUE(pull_commit_user)"`
PullID int64 `xorm:"NOT NULL UNIQUE(pull_commit_user) DEFAULT 0"`
CommitSHA string `xorm:"NOT NULL VARCHAR(40) UNIQUE(pull_commit_user)"`
UpdatedFiles map[string]pull.ViewedState `xorm:"NOT NULL LONGTEXT JSON"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
return x.Sync2(new(ReviewState))
}

139
models/pull/review_state.go Normal file
View file

@ -0,0 +1,139 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package pull
import (
"context"
"fmt"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/timeutil"
)
// ViewedState stores for a file in which state it is currently viewed
type ViewedState uint8
const (
Unviewed ViewedState = iota
HasChanged // cannot be set from the UI/ API, only internally
Viewed
)
func (viewedState ViewedState) String() string {
switch viewedState {
case Unviewed:
return "unviewed"
case HasChanged:
return "has-changed"
case Viewed:
return "viewed"
default:
return fmt.Sprintf("unknown(value=%d)", viewedState)
}
}
// ReviewState stores for a user-PR-commit combination which files the user has already viewed
type ReviewState struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL UNIQUE(pull_commit_user)"`
PullID int64 `xorm:"NOT NULL UNIQUE(pull_commit_user) DEFAULT 0"` // Which PR was the review on?
CommitSHA string `xorm:"NOT NULL VARCHAR(40) UNIQUE(pull_commit_user)"` // Which commit was the head commit for the review?
UpdatedFiles map[string]ViewedState `xorm:"NOT NULL LONGTEXT JSON"` // Stores for each of the changed files of a PR whether they have been viewed, changed since last viewed, or not viewed
UpdatedUnix timeutil.TimeStamp `xorm:"updated"` // Is an accurate indicator of the order of commits as we do not expect it to be possible to make reviews on previous commits
}
func init() {
db.RegisterModel(new(ReviewState))
}
// GetReviewState returns the ReviewState with all given values prefilled, whether or not it exists in the database.
// If the review didn't exist before in the database, it won't afterwards either.
// The returned boolean shows whether the review exists in the database
func GetReviewState(ctx context.Context, userID, pullID int64, commitSHA string) (*ReviewState, bool, error) {
review := &ReviewState{UserID: userID, PullID: pullID, CommitSHA: commitSHA}
has, err := db.GetEngine(ctx).Get(review)
return review, has, err
}
// UpdateReviewState updates the given review inside the database, regardless of whether it existed before or not
// The given map of files with their viewed state will be merged with the previous review, if present
func UpdateReviewState(ctx context.Context, userID, pullID int64, commitSHA string, updatedFiles map[string]ViewedState) error {
log.Trace("Updating review for user %d, repo %d, commit %s with the updated files %v.", userID, pullID, commitSHA, updatedFiles)
review, exists, err := GetReviewState(ctx, userID, pullID, commitSHA)
if err != nil {
return err
}
if exists {
review.UpdatedFiles = mergeFiles(review.UpdatedFiles, updatedFiles)
} else if previousReview, err := getNewestReviewStateApartFrom(ctx, userID, pullID, commitSHA); err != nil {
return err
// Overwrite the viewed files of the previous review if present
} else if previousReview != nil {
review.UpdatedFiles = mergeFiles(previousReview.UpdatedFiles, updatedFiles)
} else {
review.UpdatedFiles = updatedFiles
}
// Insert or Update review
engine := db.GetEngine(ctx)
if !exists {
log.Trace("Inserting new review for user %d, repo %d, commit %s with the updated files %v.", userID, pullID, commitSHA, review.UpdatedFiles)
_, err := engine.Insert(review)
return err
}
log.Trace("Updating already existing review with ID %d (user %d, repo %d, commit %s) with the updated files %v.", review.ID, userID, pullID, commitSHA, review.UpdatedFiles)
_, err = engine.ID(review.ID).Update(&ReviewState{UpdatedFiles: review.UpdatedFiles})
return err
}
// mergeFiles merges the given maps of files with their viewing state into one map.
// Values from oldFiles will be overridden with values from newFiles
func mergeFiles(oldFiles, newFiles map[string]ViewedState) map[string]ViewedState {
if oldFiles == nil {
return newFiles
} else if newFiles == nil {
return oldFiles
}
for file, viewed := range newFiles {
oldFiles[file] = viewed
}
return oldFiles
}
// GetNewestReviewState gets the newest review of the current user in the current PR.
// The returned PR Review will be nil if the user has not yet reviewed this PR.
func GetNewestReviewState(ctx context.Context, userID, pullID int64) (*ReviewState, error) {
var review ReviewState
has, err := db.GetEngine(ctx).Where("user_id = ?", userID).And("pull_id = ?", pullID).OrderBy("updated_unix DESC").Get(&review)
if err != nil || !has {
return nil, err
}
return &review, err
}
// getNewestReviewStateApartFrom is like GetNewestReview, except that the second newest review will be returned if the newest review points at the given commit.
// The returned PR Review will be nil if the user has not yet reviewed this PR.
func getNewestReviewStateApartFrom(ctx context.Context, userID, pullID int64, commitSHA string) (*ReviewState, error) {
var reviews []ReviewState
err := db.GetEngine(ctx).Where("user_id = ?", userID).And("pull_id = ?", pullID).OrderBy("updated_unix DESC").Limit(2).Find(&reviews)
// It would also be possible to use ".And("commit_sha != ?", commitSHA)" instead of the error handling below
// However, benchmarks show drastically improved performance by not doing that
// Error cases in which no review should be returned
if err != nil || len(reviews) == 0 || (len(reviews) == 1 && reviews[0].CommitSHA == commitSHA) {
return nil, err
// The first review points at the commit to exclude, hence skip to the second review
} else if len(reviews) >= 2 && reviews[0].CommitSHA == commitSHA {
return &reviews[1], nil
}
// As we have no error cases left, the result must be the first element in the list
return &reviews[0], nil
}

View file

@ -286,6 +286,15 @@ func (repo *Repository) GetPatch(base, head string, w io.Writer) error {
return err return err
} }
// GetFilesChangedBetween returns a list of all files that have been changed between the given commits
func (repo *Repository) GetFilesChangedBetween(base, head string) ([]string, error) {
stdout, _, err := NewCommand(repo.Ctx, "diff", "--name-only", base+".."+head).RunStdString(&RunOpts{Dir: repo.Path})
if err != nil {
return nil, err
}
return strings.Split(stdout, "\n"), err
}
// GetDiffFromMergeBase generates and return patch data from merge base to head // GetDiffFromMergeBase generates and return patch data from merge base to head
func (repo *Repository) GetDiffFromMergeBase(base, head string, w io.Writer) error { func (repo *Repository) GetDiffFromMergeBase(base, head string, w io.Writer) error {
stderr := new(bytes.Buffer) stderr := new(bytes.Buffer)

View file

@ -1493,6 +1493,9 @@ pulls.allow_edits_from_maintainers = Allow edits from maintainers
pulls.allow_edits_from_maintainers_desc = Users with write access to the base branch can also push to this branch pulls.allow_edits_from_maintainers_desc = Users with write access to the base branch can also push to this branch
pulls.allow_edits_from_maintainers_err = Updating failed pulls.allow_edits_from_maintainers_err = Updating failed
pulls.compare_changes_desc = Select the branch to merge into and the branch to pull from. pulls.compare_changes_desc = Select the branch to merge into and the branch to pull from.
pulls.has_viewed_file = Viewed
pulls.has_changed_since_last_review = Changed since your last review
pulls.viewed_files_label = %[1]d / %[2]d files viewed
pulls.compare_base = merge into pulls.compare_base = merge into
pulls.compare_compare = pull from pulls.compare_compare = pull from
pulls.switch_comparison_type = Switch comparison type pulls.switch_comparison_type = Switch comparison type

View file

@ -685,22 +685,35 @@ 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{
BeforeCommitID: startCommitID,
AfterCommitID: endCommitID,
SkipTo: ctx.FormString("skip-to"),
MaxLines: maxLines,
MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters,
MaxFiles: maxFiles,
WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)),
}
diff, err := gitdiff.GetDiff(gitRepo, var methodWithError string
&gitdiff.DiffOptions{ var diff *gitdiff.Diff
BeforeCommitID: startCommitID, if !ctx.IsSigned {
AfterCommitID: endCommitID, diff, err = gitdiff.GetDiff(gitRepo, diffOptions, files...)
SkipTo: ctx.FormString("skip-to"), methodWithError = "GetDiff"
MaxLines: maxLines, } else {
MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, diff, err = gitdiff.SyncAndGetUserSpecificDiff(ctx, ctx.Doer.ID, pull, gitRepo, diffOptions, files...)
MaxFiles: maxFiles, methodWithError = "SyncAndGetUserSpecificDiff"
WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)), }
}, ctx.FormStrings("files")...)
if err != nil { if err != nil {
ctx.ServerError("GetDiffRangeWithWhitespaceBehavior", err) ctx.ServerError(methodWithError, err)
return return
} }
ctx.PageData["prReview"] = map[string]interface{}{
"numberOfFiles": diff.NumFiles,
"numberOfViewedFiles": diff.NumViewedFiles,
}
if err = diff.LoadComments(ctx, issue, ctx.Doer); err != nil { if err = diff.LoadComments(ctx, issue, ctx.Doer); err != nil {
ctx.ServerError("LoadComments", err) ctx.ServerError("LoadComments", err)
return return

View file

@ -9,8 +9,10 @@ import (
"net/http" "net/http"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
pull_model "code.gitea.io/gitea/models/pull"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
@ -242,3 +244,47 @@ func DismissReview(ctx *context.Context) {
ctx.Redirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, comm.Issue.Index, comm.HashTag())) ctx.Redirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, comm.Issue.Index, comm.HashTag()))
} }
// viewedFilesUpdate Struct to parse the body of a request to update the reviewed files of a PR
// If you want to implement an API to update the review, simply move this struct into modules.
type viewedFilesUpdate struct {
Files map[string]bool `json:"files"`
HeadCommitSHA string `json:"headCommitSHA"`
}
func UpdateViewedFiles(ctx *context.Context) {
// Find corresponding PR
issue := checkPullInfo(ctx)
if ctx.Written() {
return
}
pull := issue.PullRequest
var data *viewedFilesUpdate
err := json.NewDecoder(ctx.Req.Body).Decode(&data)
if err != nil {
log.Warn("Attempted to update a review but could not parse request body: %v", err)
ctx.Resp.WriteHeader(http.StatusBadRequest)
return
}
// Expect the review to have been now if no head commit was supplied
if data.HeadCommitSHA == "" {
data.HeadCommitSHA = pull.HeadCommitID
}
updatedFiles := make(map[string]pull_model.ViewedState, len(data.Files))
for file, viewed := range data.Files {
// Only unviewed and viewed are possible, has-changed can not be set from the outside
state := pull_model.Unviewed
if viewed {
state = pull_model.Viewed
}
updatedFiles[file] = state
}
if err := pull_model.UpdateReviewState(ctx, ctx.Doer.ID, pull.ID, data.HeadCommitSHA, updatedFiles); err != nil {
ctx.ServerError("UpdateReview", err)
}
}

View file

@ -849,6 +849,7 @@ func RegisterRoutes(m *web.Route) {
m.Post("/deadline", bindIgnErr(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline) m.Post("/deadline", bindIgnErr(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline)
m.Post("/watch", repo.IssueWatch) m.Post("/watch", repo.IssueWatch)
m.Post("/ref", repo.UpdateIssueRef) m.Post("/ref", repo.UpdateIssueRef)
m.Post("/viewed-files", repo.UpdateViewedFiles)
m.Group("/dependency", func() { m.Group("/dependency", func() {
m.Post("/add", repo.AddDependency) m.Post("/add", repo.AddDependency)
m.Post("/delete", repo.RemoveDependency) m.Post("/delete", repo.RemoveDependency)

View file

@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
pull_model "code.gitea.io/gitea/models/pull"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/analyze" "code.gitea.io/gitea/modules/analyze"
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
@ -602,25 +603,27 @@ func (diffSection *DiffSection) GetComputedInlineDiffFor(diffLine *DiffLine) Dif
// DiffFile represents a file diff. // DiffFile represents a file diff.
type DiffFile struct { type DiffFile struct {
Name string Name string
OldName string OldName string
Index int Index int
Addition, Deletion int Addition, Deletion int
Type DiffFileType Type DiffFileType
IsCreated bool IsCreated bool
IsDeleted bool IsDeleted bool
IsBin bool IsBin bool
IsLFSFile bool IsLFSFile bool
IsRenamed bool IsRenamed bool
IsAmbiguous bool IsAmbiguous bool
IsSubmodule bool IsSubmodule bool
Sections []*DiffSection Sections []*DiffSection
IsIncomplete bool IsIncomplete bool
IsIncompleteLineTooLong bool IsIncompleteLineTooLong bool
IsProtected bool IsProtected bool
IsGenerated bool IsGenerated bool
IsVendored bool IsVendored bool
Language string IsViewed bool // User specific
HasChangedSinceLastReview bool // User specific
Language string
} }
// GetType returns type of diff file. // GetType returns type of diff file.
@ -663,6 +666,18 @@ func (diffFile *DiffFile) GetTailSection(gitRepo *git.Repository, leftCommitID,
return tailSection return tailSection
} }
// GetDiffFileName returns the name of the diff file, or its old name in case it was deleted
func (diffFile *DiffFile) GetDiffFileName() string {
if diffFile.Name == "" {
return diffFile.OldName
}
return diffFile.Name
}
func (diffFile *DiffFile) ShouldBeHidden() bool {
return diffFile.IsGenerated || diffFile.IsViewed
}
func getCommitFileLineCount(commit *git.Commit, filePath string) int { func getCommitFileLineCount(commit *git.Commit, filePath string) int {
blob, err := commit.GetBlobByPath(filePath) blob, err := commit.GetBlobByPath(filePath)
if err != nil { if err != nil {
@ -677,10 +692,12 @@ func getCommitFileLineCount(commit *git.Commit, filePath string) int {
// Diff represents a difference between two git trees. // Diff represents a difference between two git trees.
type Diff struct { type Diff struct {
Start, End string Start, End string
NumFiles, TotalAddition, TotalDeletion int NumFiles int
Files []*DiffFile TotalAddition, TotalDeletion int
IsIncomplete bool Files []*DiffFile
IsIncomplete bool
NumViewedFiles int // user-specific
} }
// LoadComments loads comments into each line // LoadComments loads comments into each line
@ -1497,6 +1514,70 @@ func GetDiff(gitRepo *git.Repository, opts *DiffOptions, files ...string) (*Diff
return diff, nil return diff, nil
} }
// SyncAndGetUserSpecificDiff is like GetDiff, except that user specific data such as which files the given user has already viewed on the given PR will also be set
// Additionally, the database asynchronously is updated if files have changed since the last review
func SyncAndGetUserSpecificDiff(ctx context.Context, userID int64, pull *models.PullRequest, gitRepo *git.Repository, opts *DiffOptions, files ...string) (*Diff, error) {
diff, err := GetDiff(gitRepo, opts, files...)
if err != nil {
return nil, err
}
review, err := pull_model.GetNewestReviewState(ctx, userID, pull.ID)
if err != nil || review == nil || review.UpdatedFiles == nil {
return diff, err
}
latestCommit := opts.AfterCommitID
if latestCommit == "" {
latestCommit = pull.HeadBranch // opts.AfterCommitID is preferred because it handles PRs from forks correctly and the branch name doesn't
}
changedFiles, err := gitRepo.GetFilesChangedBetween(review.CommitSHA, latestCommit)
if err != nil {
return diff, err
}
filesChangedSinceLastDiff := make(map[string]pull_model.ViewedState)
outer:
for _, diffFile := range diff.Files {
fileViewedState := review.UpdatedFiles[diffFile.GetDiffFileName()]
// Check whether it was previously detected that the file has changed since the last review
if fileViewedState == pull_model.HasChanged {
diffFile.HasChangedSinceLastReview = true
continue
}
filename := diffFile.GetDiffFileName()
// Check explicitly whether the file has changed since the last review
for _, changedFile := range changedFiles {
diffFile.HasChangedSinceLastReview = filename == changedFile
if diffFile.HasChangedSinceLastReview {
filesChangedSinceLastDiff[filename] = pull_model.HasChanged
continue outer // We don't want to check if the file is viewed here as that would fold the file, which is in this case unwanted
}
}
// Check whether the file has already been viewed
if fileViewedState == pull_model.Viewed {
diffFile.IsViewed = true
diff.NumViewedFiles++
}
}
// Explicitly store files that have changed in the database, if any is present at all.
// This has the benefit that the "Has Changed" attribute will be present as long as the user does not explicitly mark this file as viewed, so it will even survive a page reload after marking another file as viewed.
// On the other hand, this means that even if a commit reverting an unseen change is committed, the file will still be seen as changed.
if len(filesChangedSinceLastDiff) > 0 {
err := pull_model.UpdateReviewState(ctx, review.UserID, review.PullID, review.CommitSHA, filesChangedSinceLastDiff)
if err != nil {
log.Warn("Could not update review for user %d, pull %d, commit %s and the changed files %v: %v", review.UserID, review.PullID, review.CommitSHA, filesChangedSinceLastDiff, err)
return nil, err
}
}
return diff, err
}
// CommentAsDiff returns c.Patch as *Diff // CommentAsDiff returns c.Patch as *Diff
func CommentAsDiff(c *models.Comment) (*Diff, error) { func CommentAsDiff(c *models.Comment) (*Diff, error) {
diff, err := ParsePatch(setting.Git.MaxGitDiffLines, diff, err := ParsePatch(setting.Git.MaxGitDiffLines,

View file

@ -18,6 +18,12 @@
{{svg "octicon-diff" 16 "mr-2"}}{{.i18n.Tr "repo.diff.stats_desc" .Diff.NumFiles .Diff.TotalAddition .Diff.TotalDeletion | Str2html}} {{svg "octicon-diff" 16 "mr-2"}}{{.i18n.Tr "repo.diff.stats_desc" .Diff.NumFiles .Diff.TotalAddition .Diff.TotalDeletion | Str2html}}
</div> </div>
<div class="diff-detail-actions df ac"> <div class="diff-detail-actions df ac">
{{if and .PageIsPullFiles $.SignedUserID (not .IsArchived)}}
<meter id="viewed-files-summary" value="{{.Diff.NumViewedFiles}}" max="{{.Diff.NumFiles}}"></meter>
<label for="viewed-files-summary" id="viewed-files-summary-label" data-text-changed-template="{{.i18n.Tr "repo.pulls.viewed_files_label"}}">
{{.i18n.Tr "repo.pulls.viewed_files_label" .Diff.NumViewedFiles .Diff.NumFiles}}
</label>
{{end}}
{{template "repo/diff/whitespace_dropdown" .}} {{template "repo/diff/whitespace_dropdown" .}}
{{template "repo/diff/options_dropdown" .}} {{template "repo/diff/options_dropdown" .}}
{{if and .PageIsPullFiles $.SignedUserID (not .IsArchived)}} {{if and .PageIsPullFiles $.SignedUserID (not .IsArchived)}}
@ -58,11 +64,11 @@
{{$isCsv := (call $.IsCsvFile $file)}} {{$isCsv := (call $.IsCsvFile $file)}}
{{$showFileViewToggle := or $isImage (and (not $file.IsIncomplete) $isCsv)}} {{$showFileViewToggle := or $isImage (and (not $file.IsIncomplete) $isCsv)}}
{{$nameHash := Sha1 $file.Name}} {{$nameHash := Sha1 $file.Name}}
<div class="diff-file-box diff-box file-content {{TabSizeClass $.Editorconfig $file.Name}} mt-3" id="diff-{{$nameHash}}" data-old-filename="{{$file.OldName}}" data-new-filename="{{$file.Name}}" {{if $file.IsGenerated}}data-folded="true"{{end}}> <div class="diff-file-box diff-box file-content {{TabSizeClass $.Editorconfig $file.Name}} mt-3" id="diff-{{$nameHash}}" data-old-filename="{{$file.OldName}}" data-new-filename="{{$file.Name}}" {{if $file.ShouldBeHidden}}data-folded="true"{{end}}>
<h4 class="diff-file-header sticky-2nd-row ui top attached normal header df ac sb"> <h4 class="diff-file-header sticky-2nd-row ui top attached normal header df ac sb">
<div class="df ac"> <div class="df ac">
<a role="button" class="fold-file muted mr-2"> <a role="button" class="fold-file muted mr-2">
{{if $file.IsGenerated}} {{if $file.ShouldBeHidden}}
{{svg "octicon-chevron-right" 18}} {{svg "octicon-chevron-right" 18}}
{{else}} {{else}}
{{svg "octicon-chevron-down" 18}} {{svg "octicon-chevron-down" 18}}
@ -106,9 +112,18 @@
<a class="ui basic tiny button" rel="nofollow" href="{{$.SourcePath}}/{{PathEscapeSegments .Name}}">{{$.i18n.Tr "repo.diff.view_file"}}</a> <a class="ui basic tiny button" rel="nofollow" href="{{$.SourcePath}}/{{PathEscapeSegments .Name}}">{{$.i18n.Tr "repo.diff.view_file"}}</a>
{{end}} {{end}}
{{end}} {{end}}
{{if and $.IsSigned $.PageIsPullFiles (not $.IsArchived)}}
{{if $file.HasChangedSinceLastReview}}
<span class="changed-since-last-review unselectable">{{$.i18n.Tr "repo.pulls.has_changed_since_last_review"}}</span>
{{end}}
<div data-link="{{$.Issue.Link}}/viewed-files" data-headcommit="{{$.PullHeadCommitID}}" class="viewed-file-form unselectable{{if $file.IsViewed}} viewed-file-checked-form{{end}}">
<input type="checkbox" name="{{$file.GetDiffFileName}}" id="viewed-file-checkbox-{{$i}}" autocomplete="off" {{if $file.IsViewed}}checked{{end}}></input>
<label for="viewed-file-checkbox-{{$i}}">{{$.i18n.Tr "repo.pulls.has_viewed_file"}}</label>
</div>
{{end}}
</div> </div>
</h4> </h4>
<div class="diff-file-body ui attached unstackable table segment"> <div class="diff-file-body ui attached unstackable table segment" {{if $file.IsViewed}}data-folded="true"{{end}}>
<div id="diff-source-{{$i}}" class="file-body file-code unicode-escaped code-diff{{if $.IsSplitStyle}} code-diff-split{{else}} code-diff-unified{{end}}{{if $showFileViewToggle}} hide{{end}}"> <div id="diff-source-{{$i}}" class="file-body file-code unicode-escaped code-diff{{if $.IsSplitStyle}} code-diff-split{{else}} code-diff-unified{{end}}{{if $showFileViewToggle}} hide{{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

@ -0,0 +1,18 @@
import {svg} from '../svg.js';
// Hides the file if newFold is true, and shows it otherwise. The actual hiding is performed using CSS.
//
// The fold arrow is the icon displayed on the upper left of the file box, especially intended for components having the 'fold-file' class.
// The file content box is the box that should be hidden or shown, especially intended for components having the 'file-content' class.
//
export function setFileFolding(fileContentBox, foldArrow, newFold) {
foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18);
fileContentBox.setAttribute('data-folded', newFold);
}
// Like `setFileFolding`, except that it automatically inverts the current file folding state.
export function invertFileFolding(fileContentBox, foldArrow) {
setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true');
}

View file

@ -0,0 +1,71 @@
import {setFileFolding} from './file-fold.js';
const {csrfToken, pageData} = window.config;
const prReview = pageData.prReview || {};
const viewedStyleClass = 'viewed-file-checked-form';
const viewedCheckboxSelector = '.viewed-file-form'; // Selector under which all "Viewed" checkbox forms can be found
// Refreshes the summary of viewed files if present
// The data used will be window.config.pageData.prReview.numberOf{Viewed}Files
function refreshViewedFilesSummary() {
const viewedFilesMeter = document.getElementById('viewed-files-summary');
viewedFilesMeter?.setAttribute('value', prReview.numberOfViewedFiles);
const summaryLabel = document.getElementById('viewed-files-summary-label');
if (summaryLabel) summaryLabel.innerHTML = summaryLabel.getAttribute('data-text-changed-template')
.replace('%[1]d', prReview.numberOfViewedFiles)
.replace('%[2]d', prReview.numberOfFiles);
}
// Explicitly recounts how many files the user has currently reviewed by counting the number of checked "viewed" checkboxes
// Additionally, the viewed files summary will be updated if it exists
export function countAndUpdateViewedFiles() {
// The number of files is constant, but the number of viewed files can change because files can be loaded dynamically
prReview.numberOfViewedFiles = document.querySelectorAll(`${viewedCheckboxSelector} > input[type=checkbox][checked]`).length;
refreshViewedFilesSummary();
}
// Initializes a listener for all children of the given html element
// (for example 'document' in the most basic case)
// to watch for changes of viewed-file checkboxes
export function initViewedCheckboxListenerFor() {
for (const form of document.querySelectorAll(`${viewedCheckboxSelector}:not([data-has-viewed-checkbox-listener="true"])`)) {
// To prevent double addition of listeners
form.setAttribute('data-has-viewed-checkbox-listener', true);
// The checkbox consists of a div containing the real checkbox with its label and the CSRF token,
// hence the actual checkbox first has to be found
const checkbox = form.querySelector('input[type=checkbox]');
checkbox.addEventListener('change', function() {
// Mark the file as viewed visually - will especially change the background
if (this.checked) {
form.classList.add(viewedStyleClass);
prReview.numberOfViewedFiles++;
} else {
form.classList.remove(viewedStyleClass);
prReview.numberOfViewedFiles--;
}
// Update viewed-files summary and remove "has changed" label if present
refreshViewedFilesSummary();
const hasChangedLabel = form.parentNode.querySelector('.changed-since-last-review');
hasChangedLabel?.parentNode.removeChild(hasChangedLabel);
// Unfortunately, actual forms cause too many problems, hence another approach is needed
const files = {};
files[checkbox.getAttribute('name')] = this.checked;
const data = {files};
const headCommitSHA = form.getAttribute('data-headcommit');
if (headCommitSHA) data.headCommitSHA = headCommitSHA;
fetch(form.getAttribute('data-link'), {
method: 'POST',
headers: {'X-Csrf-Token': csrfToken},
body: JSON.stringify(data),
});
// Fold the file accordingly
const parentBox = form.closest('.diff-file-header');
setFileFolding(parentBox.closest('.file-content'), parentBox.querySelector('.fold-file'), this.checked);
});
}
}

View file

@ -1,5 +1,6 @@
import $ from 'jquery'; import $ from 'jquery';
import {svg} from '../svg.js'; import {svg} from '../svg.js';
import {invertFileFolding} from './file-fold.js';
function changeHash(hash) { function changeHash(hash) {
if (window.history.pushState) { if (window.history.pushState) {
@ -148,10 +149,7 @@ export function initRepoCodeView() {
}).trigger('hashchange'); }).trigger('hashchange');
} }
$(document).on('click', '.fold-file', ({currentTarget}) => { $(document).on('click', '.fold-file', ({currentTarget}) => {
const box = currentTarget.closest('.file-content'); invertFileFolding(currentTarget.closest('.file-content'), currentTarget);
const folded = box.getAttribute('data-folded') !== 'true';
currentTarget.innerHTML = svg(`octicon-chevron-${folded ? 'right' : 'down'}`, 18);
box.setAttribute('data-folded', String(folded));
}); });
$(document).on('click', '.blob-excerpt', async ({currentTarget}) => { $(document).on('click', '.blob-excerpt', async ({currentTarget}) => {
const url = currentTarget.getAttribute('data-url'); const url = currentTarget.getAttribute('data-url');

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 {validateTextareaNonEmpty} from './comp/EasyMDE.js'; import {validateTextareaNonEmpty} from './comp/EasyMDE.js';
import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles} from './pull-view-file.js';
const {csrfToken} = window.config; const {csrfToken} = window.config;
@ -104,6 +105,13 @@ export function initRepoDiffConversationNav() {
}); });
} }
// Will be called when the show more (files) button has been pressed
function onShowMoreFiles() {
initRepoIssueContentHistory();
initViewedCheckboxListenerFor();
countAndUpdateViewedFiles();
}
export function initRepoDiffShowMore() { export function initRepoDiffShowMore() {
$('#diff-files, #diff-file-boxes').on('click', '#diff-show-more-files, #diff-show-more-files-stats', (e) => { $('#diff-files, #diff-file-boxes').on('click', '#diff-show-more-files, #diff-show-more-files-stats', (e) => {
e.preventDefault(); e.preventDefault();
@ -125,7 +133,7 @@ export function initRepoDiffShowMore() {
$('#diff-too-many-files-stats').remove(); $('#diff-too-many-files-stats').remove();
$('#diff-files').append($(resp).find('#diff-files li')); $('#diff-files').append($(resp).find('#diff-files li'));
$('#diff-incomplete').replaceWith($(resp).find('#diff-file-boxes').children()); $('#diff-incomplete').replaceWith($(resp).find('#diff-file-boxes').children());
initRepoIssueContentHistory(); onShowMoreFiles();
}).fail(() => { }).fail(() => {
$('#diff-show-more-files, #diff-show-more-files-stats').removeClass('disabled'); $('#diff-show-more-files, #diff-show-more-files-stats').removeClass('disabled');
}); });
@ -151,7 +159,7 @@ export function initRepoDiffShowMore() {
} }
$target.parent().replaceWith($(resp).find('#diff-file-boxes .diff-file-body .file-body').children()); $target.parent().replaceWith($(resp).find('#diff-file-boxes .diff-file-body .file-body').children());
initRepoIssueContentHistory(); onShowMoreFiles();
}).fail(() => { }).fail(() => {
$target.removeClass('disabled'); $target.removeClass('disabled');
}); });

View file

@ -70,6 +70,7 @@ import {
initRepoSettingsCollaboration, initRepoSettingsCollaboration,
initRepoSettingSearchTeamBox, initRepoSettingSearchTeamBox,
} from './features/repo-settings.js'; } from './features/repo-settings.js';
import {initViewedCheckboxListenerFor} from './features/pull-view-file.js';
import {initOrgTeamSearchRepoBox, initOrgTeamSettings} from './features/org-team.js'; import {initOrgTeamSearchRepoBox, initOrgTeamSettings} from './features/org-team.js';
import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.js'; import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.js';
import {initRepoRelease, initRepoReleaseEditor} from './features/repo-release.js'; import {initRepoRelease, initRepoReleaseEditor} from './features/repo-release.js';
@ -178,6 +179,6 @@ $(document).ready(() => {
initUserAuthWebAuthn(); initUserAuthWebAuthn();
initUserAuthWebAuthnRegister(); initUserAuthWebAuthnRegister();
initUserSettings(); initUserSettings();
initViewedCheckboxListenerFor();
checkAppUrl(); checkAppUrl();
}); });

View file

@ -262,3 +262,21 @@ a.blob-excerpt:hover {
scroll-margin-top: 130px; scroll-margin-top: 130px;
} }
} }
.changed-since-last-review {
margin: 0 5px;
padding: 0 3px;
border: 2px var(--color-primary-light-3) solid;
background-color: var(--color-primary-alpha-30);
border-radius: 7px;
}
.viewed-file-form {
margin: 0 3px;
padding: 0 3px;
border-radius: 3px;
}
.viewed-file-checked-form {
background-color: var(--color-primary-light-4);
}