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.tokarchuk/v1.17
parent
59b30f060a
commit
5ca224a789
@ -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