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.master
parent
59b30f060a
commit
5ca224a789
16 changed files with 493 additions and 45 deletions
@ -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)) |
||||
} |
@ -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 |
||||
} |
@ -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'); |
||||
} |
||||
|
@ -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); |
||||
}); |
||||
} |
||||
} |
Loading…
Reference in new issue