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:
parent
59b30f060a
commit
5ca224a789
16 changed files with 492 additions and 44 deletions
|
@ -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
25
models/migrations/v215.go
Normal 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
139
models/pull/review_state.go
Normal 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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -685,9 +685,7 @@ 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{
|
||||||
diff, err := gitdiff.GetDiff(gitRepo,
|
|
||||||
&gitdiff.DiffOptions{
|
|
||||||
BeforeCommitID: startCommitID,
|
BeforeCommitID: startCommitID,
|
||||||
AfterCommitID: endCommitID,
|
AfterCommitID: endCommitID,
|
||||||
SkipTo: ctx.FormString("skip-to"),
|
SkipTo: ctx.FormString("skip-to"),
|
||||||
|
@ -695,12 +693,27 @@ func ViewPullFiles(ctx *context.Context) {
|
||||||
MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters,
|
MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters,
|
||||||
MaxFiles: maxFiles,
|
MaxFiles: maxFiles,
|
||||||
WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)),
|
WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)),
|
||||||
}, ctx.FormStrings("files")...)
|
}
|
||||||
|
|
||||||
|
var methodWithError string
|
||||||
|
var diff *gitdiff.Diff
|
||||||
|
if !ctx.IsSigned {
|
||||||
|
diff, err = gitdiff.GetDiff(gitRepo, diffOptions, files...)
|
||||||
|
methodWithError = "GetDiff"
|
||||||
|
} else {
|
||||||
|
diff, err = gitdiff.SyncAndGetUserSpecificDiff(ctx, ctx.Doer.ID, pull, gitRepo, diffOptions, files...)
|
||||||
|
methodWithError = "SyncAndGetUserSpecificDiff"
|
||||||
|
}
|
||||||
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
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
|
@ -620,6 +621,8 @@ type DiffFile struct {
|
||||||
IsProtected bool
|
IsProtected bool
|
||||||
IsGenerated bool
|
IsGenerated bool
|
||||||
IsVendored bool
|
IsVendored bool
|
||||||
|
IsViewed bool // User specific
|
||||||
|
HasChangedSinceLastReview bool // User specific
|
||||||
Language string
|
Language string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -678,9 +693,11 @@ 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
|
||||||
|
TotalAddition, TotalDeletion int
|
||||||
Files []*DiffFile
|
Files []*DiffFile
|
||||||
IsIncomplete bool
|
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,
|
||||||
|
|
|
@ -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;">
|
||||||
|
|
18
web_src/js/features/file-fold.js
Normal file
18
web_src/js/features/file-fold.js
Normal 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');
|
||||||
|
}
|
||||||
|
|
71
web_src/js/features/pull-view-file.js
Normal file
71
web_src/js/features/pull-view-file.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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');
|
||||||
|
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
Reference in a new issue