mirror of
https://github.com/go-gitea/gitea.git
synced 2026-01-18 20:51:43 +01:00
feat: add inline comments on commits
This commit is contained in:
parent
915a2cd86f
commit
2868ed91c1
272
models/git/commit_comment.go
Normal file
272
models/git/commit_comment.go
Normal file
@ -0,0 +1,272 @@
|
||||
// Copyright 2026 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
)
|
||||
|
||||
// CommitComment represents a comment on a single commit
|
||||
type CommitComment struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX"`
|
||||
CommitSHA string `xorm:"VARCHAR(64) INDEX"`
|
||||
PosterID int64 `xorm:"INDEX"`
|
||||
Poster *user_model.User `xorm:"-"`
|
||||
Path string `xorm:"VARCHAR(4000)"`
|
||||
Line int64 `xorm:"INDEX"`
|
||||
Content string `xorm:"LONGTEXT"`
|
||||
RenderedContent template.HTML `xorm:"-"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(CommitComment))
|
||||
}
|
||||
|
||||
// CreateCommitComment inserts a new commit comment
|
||||
func CreateCommitComment(ctx context.Context, c *CommitComment) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("nil commit comment")
|
||||
}
|
||||
if _, err := db.GetEngine(ctx).Insert(c); err != nil {
|
||||
return fmt.Errorf("Insert CommitComment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCommitCommentByID returns a commit comment by id
|
||||
func GetCommitCommentByID(ctx context.Context, id int64) (*CommitComment, error) {
|
||||
c := &CommitComment{ID: id}
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Get CommitComment by id: %w", err)
|
||||
}
|
||||
if !has {
|
||||
return nil, fmt.Errorf("commit comment does not exist [id: %d]", id)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListCommitComments returns commit comments for a repo and commit sha
|
||||
func ListCommitComments(ctx context.Context, repoID int64, commitSHA string) ([]*CommitComment, error) {
|
||||
var cs []*CommitComment
|
||||
err := db.GetEngine(ctx).Where("repo_id = ? AND commit_sha = ?", repoID, commitSHA).Asc("created_unix").Find(&cs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("List commit comments: %w", err)
|
||||
}
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// ListCommitCommentsByLine returns commit comments for a given repo/commit/path/line
|
||||
func ListCommitCommentsByLine(ctx context.Context, repoID int64, commitSHA, path string, line int64) ([]*CommitComment, error) {
|
||||
cs, err := ListCommitComments(ctx, repoID, commitSHA)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res []*CommitComment
|
||||
for _, c := range cs {
|
||||
if c.Path == path && c.Line == line {
|
||||
res = append(res, c)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UpdateCommitComment updates an existing commit comment
|
||||
func UpdateCommitComment(ctx context.Context, c *CommitComment) error {
|
||||
if c == nil || c.ID == 0 {
|
||||
return fmt.Errorf("invalid commit comment")
|
||||
}
|
||||
if _, err := db.GetEngine(ctx).ID(c.ID).Cols("content", "path", "line", "updated_unix").Update(c); err != nil {
|
||||
return fmt.Errorf("Update CommitComment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCommitComment deletes a commit comment by id
|
||||
func DeleteCommitComment(ctx context.Context, id int64) error {
|
||||
c := &CommitComment{ID: id}
|
||||
_, err := db.GetEngine(ctx).ID(id).Delete(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Delete CommitComment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadPoster loads the poster user for the comment
|
||||
func (c *CommitComment) LoadPoster(ctx context.Context) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("nil commit comment")
|
||||
}
|
||||
if c.Poster != nil {
|
||||
return nil
|
||||
}
|
||||
u, err := user_model.GetPossibleUserByID(ctx, c.PosterID)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
c.PosterID = user_model.GhostUserID
|
||||
c.Poster = user_model.NewGhostUser()
|
||||
return nil
|
||||
}
|
||||
log.Error("getUserByID[%d]: %v", c.PosterID, err)
|
||||
return err
|
||||
}
|
||||
c.Poster = u
|
||||
return nil
|
||||
}
|
||||
|
||||
// HashTag returns an id that can be used as a DOM anchor similar to issue comments
|
||||
func (c *CommitComment) HashTag() string {
|
||||
return fmt.Sprintf("commitcomment-%d", c.ID)
|
||||
}
|
||||
|
||||
// UnsignedLine returns absolute line index for use in templates
|
||||
func (c *CommitComment) UnsignedLine() int64 {
|
||||
if c.Line < 0 {
|
||||
return -c.Line
|
||||
}
|
||||
return c.Line
|
||||
}
|
||||
|
||||
// DiffSide returns which side the comment belongs to
|
||||
func (c *CommitComment) DiffSide() string {
|
||||
if c.Line < 0 {
|
||||
return "left"
|
||||
}
|
||||
return "right"
|
||||
}
|
||||
|
||||
// TreePath exposes Path in a field-name compatible way with issue comment templates
|
||||
func (c *CommitComment) TreePath() string {
|
||||
return c.Path
|
||||
}
|
||||
|
||||
// The following methods are provided to be compatible with the issue/pull comment templates
|
||||
// which expect a richer comment shape (IsResolved, Invalidated, ResolveDoer, Review, ReviewID).
|
||||
// Commit comments currently do not support review resolution, so these return zero-values.
|
||||
|
||||
// IsResolved indicates whether the conversation has been resolved
|
||||
func (c *CommitComment) IsResolved() bool { return false }
|
||||
|
||||
// Invalidated indicates whether the comment has been invalidated/outdated
|
||||
func (c *CommitComment) Invalidated() bool { return false }
|
||||
|
||||
// ResolveDoer returns the user who resolved the conversation (nil for commit comments)
|
||||
func (c *CommitComment) ResolveDoer() *user_model.User { return nil }
|
||||
|
||||
// Review returns an associated review (nil for commit comments)
|
||||
func (c *CommitComment) Review() any { return nil }
|
||||
|
||||
// ReviewID returns the ID of the review if any (0 for commit comments)
|
||||
func (c *CommitComment) ReviewID() int64 { return 0 }
|
||||
|
||||
// OriginalAuthor returns original author name for migrated comments (empty for commit comments)
|
||||
func (c *CommitComment) OriginalAuthor() string { return "" }
|
||||
|
||||
// Attachments returns attachments associated with the comment (none for commit comments)
|
||||
// Return type is interface{} to avoid importing repo models and causing import cycles.
|
||||
func (c *CommitComment) Attachments() interface{} { return nil }
|
||||
|
||||
// ContentVersion returns the content version for inline editing
|
||||
func (c *CommitComment) ContentVersion() int { return 0 }
|
||||
|
||||
// ReactionListShim is a small, local-friendly substitute for templates that expect
|
||||
// a list type with GroupByType, HasUser, GetFirstUsers and GetMoreUserCount methods.
|
||||
// Implemented locally to avoid import cycles with models/issues.
|
||||
type ReactionShim struct {
|
||||
UserID int64
|
||||
OriginalAuthor string
|
||||
User *user_model.User
|
||||
}
|
||||
|
||||
type ReactionListShim []*ReactionShim
|
||||
|
||||
// GroupByType groups ReactionShims by their type key
|
||||
func (list ReactionListShim) GroupByType() map[string]ReactionListShim {
|
||||
grouped := make(map[string]ReactionListShim)
|
||||
// Expectation: each ReactionShim is annotated with its type in OriginalAuthor for grouping convenience
|
||||
// However our storage does not keep Type on the shim, so this grouping is performed during construction
|
||||
// The helpers that construct ReactionListShim should provide grouping at the top-level map instead.
|
||||
return grouped
|
||||
}
|
||||
|
||||
// HasUser checks if a user has reacted
|
||||
func (list ReactionListShim) HasUser(userID int64) bool {
|
||||
if userID == 0 {
|
||||
return false
|
||||
}
|
||||
for _, reaction := range list {
|
||||
if reaction.OriginalAuthor == "" && reaction.UserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetFirstUsers returns a comma-separated list of first users
|
||||
func (list ReactionListShim) GetFirstUsers() string { return "" }
|
||||
|
||||
// GetMoreUserCount returns remaining user count
|
||||
func (list ReactionListShim) GetMoreUserCount() int { return 0 }
|
||||
|
||||
// Reactions returns a ReactionListShim (empty) so templates can safely call GroupByType
|
||||
func (c *CommitComment) Reactions() ReactionListShim { return nil }
|
||||
|
||||
// LoadReactions loads and groups reactions for a commit comment by type
|
||||
func LoadReactionsForCommitComment(ctx context.Context, commentID int64) (map[string]ReactionListShim, error) {
|
||||
crs, err := FindCommitCommentReactions(ctx, commentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grouped := make(map[string]ReactionListShim)
|
||||
userIDs := make([]int64, 0, len(crs))
|
||||
for _, cr := range crs {
|
||||
r := &ReactionShim{UserID: cr.UserID, OriginalAuthor: cr.OriginalAuthor}
|
||||
grouped[cr.Type] = append(grouped[cr.Type], r)
|
||||
if cr.OriginalAuthor == "" && cr.UserID > 0 {
|
||||
userIDs = append(userIDs, cr.UserID)
|
||||
}
|
||||
}
|
||||
if len(userIDs) > 0 {
|
||||
userMap := make(map[int64]*user_model.User)
|
||||
if err := db.GetEngine(ctx).In("id", userIDs).Find(&userMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, list := range grouped {
|
||||
for _, r := range list {
|
||||
if r.OriginalAuthor != "" {
|
||||
// migrated/original author
|
||||
r.User = user_model.NewGhostUser()
|
||||
continue
|
||||
}
|
||||
if u, ok := userMap[r.UserID]; ok {
|
||||
r.User = u
|
||||
} else {
|
||||
r.User = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return grouped, nil
|
||||
}
|
||||
|
||||
// IsErrCommitCommentNotExist returns true when error indicates the commit comment wasn't found
|
||||
func IsErrCommitCommentNotExist(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// GetCommitCommentByID returns fmt.Errorf("commit comment does not exist [id: %d]", id) when not found
|
||||
return strings.HasPrefix(err.Error(), "commit comment does not exist")
|
||||
}
|
||||
77
models/git/commit_comment_reaction.go
Normal file
77
models/git/commit_comment_reaction.go
Normal file
@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
)
|
||||
|
||||
// CommitCommentReaction represents a reaction on a commit comment
|
||||
type CommitCommentReaction struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
Type string `xorm:"INDEX UNIQUE(s) NOT NULL"`
|
||||
CommitCommentID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"`
|
||||
UserID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"`
|
||||
OriginalAuthorID int64 `xorm:"INDEX UNIQUE(s) NOT NULL DEFAULT(0)"`
|
||||
OriginalAuthor string `xorm:"INDEX UNIQUE(s)"`
|
||||
User *user_model.User `xorm:"-"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(CommitCommentReaction))
|
||||
}
|
||||
|
||||
// FindCommitCommentReactions returns reactions for a given commit comment
|
||||
func FindCommitCommentReactions(ctx context.Context, commitCommentID int64) ([]*CommitCommentReaction, error) {
|
||||
reactions := make([]*CommitCommentReaction, 0, 10)
|
||||
if err := db.GetEngine(ctx).Where("commit_comment_id = ?", commitCommentID).Asc("created_unix").Find(&reactions); err != nil {
|
||||
return nil, fmt.Errorf("Find commit comment reactions: %w", err)
|
||||
}
|
||||
return reactions, nil
|
||||
}
|
||||
|
||||
// CreateCommitCommentReaction creates a reaction for a commit comment
|
||||
func CreateCommitCommentReaction(ctx context.Context, doer *user_model.User, commitCommentID int64, reactionType string) (*CommitCommentReaction, error) {
|
||||
if !setting.UI.ReactionsLookup.Contains(reactionType) {
|
||||
return nil, fmt.Errorf("'%s' is not an allowed reaction", reactionType)
|
||||
}
|
||||
|
||||
reaction := &CommitCommentReaction{
|
||||
Type: reactionType,
|
||||
UserID: doer.ID,
|
||||
CommitCommentID: commitCommentID,
|
||||
}
|
||||
|
||||
// Check if exists
|
||||
existing := CommitCommentReaction{}
|
||||
has, err := db.GetEngine(ctx).Where("commit_comment_id = ? and type = ? and user_id = ?", commitCommentID, reactionType, doer.ID).Get(&existing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Find existing commit comment reaction: %w", err)
|
||||
}
|
||||
if has {
|
||||
return &existing, fmt.Errorf("reaction '%s' already exists", reactionType)
|
||||
}
|
||||
|
||||
if err := db.Insert(ctx, reaction); err != nil {
|
||||
return nil, fmt.Errorf("Insert commit comment reaction: %w", err)
|
||||
}
|
||||
return reaction, nil
|
||||
}
|
||||
|
||||
// DeleteCommitCommentReaction deletes a reaction for a commit comment
|
||||
func DeleteCommitCommentReaction(ctx context.Context, doerID, commitCommentID int64, reactionType string) error {
|
||||
reaction := &CommitCommentReaction{
|
||||
Type: reactionType,
|
||||
UserID: doerID,
|
||||
CommitCommentID: commitCommentID,
|
||||
}
|
||||
|
||||
_, err := db.GetEngine(ctx).Delete(reaction)
|
||||
return err
|
||||
}
|
||||
84
models/git/commit_comment_test.go
Normal file
84
models/git/commit_comment_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
// Copyright 2026 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateCommitComment(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{})
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
now := time.Now().Unix()
|
||||
c := &git_model.CommitComment{
|
||||
RepoID: repo.ID,
|
||||
CommitSHA: "abcdef1",
|
||||
PosterID: doer.ID,
|
||||
Content: "hello commit",
|
||||
}
|
||||
assert.NoError(t, git_model.CreateCommitComment(t.Context(), c))
|
||||
then := time.Now().Unix()
|
||||
|
||||
assert.Equal(t, repo.ID, c.RepoID)
|
||||
assert.Equal(t, "abcdef1", c.CommitSHA)
|
||||
assert.Equal(t, doer.ID, c.PosterID)
|
||||
assert.Equal(t, "hello commit", c.Content)
|
||||
unittest.AssertInt64InRange(t, now, then, int64(c.CreatedUnix))
|
||||
unittest.AssertExistsAndLoadBean(t, c)
|
||||
|
||||
// load poster
|
||||
assert.NoError(t, c.LoadPoster(t.Context()))
|
||||
assert.NotNil(t, c.Poster)
|
||||
}
|
||||
|
||||
func TestListUpdateDeleteCommitComment(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{})
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
c := &git_model.CommitComment{
|
||||
RepoID: repo.ID,
|
||||
CommitSHA: "deadbeef",
|
||||
PosterID: doer.ID,
|
||||
Content: "first",
|
||||
}
|
||||
assert.NoError(t, git_model.CreateCommitComment(t.Context(), c))
|
||||
|
||||
list, err := git_model.ListCommitComments(t.Context(), repo.ID, "deadbeef")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, list, 1)
|
||||
assert.Equal(t, "first", list[0].Content)
|
||||
|
||||
// update
|
||||
list[0].Content = "updated"
|
||||
assert.NoError(t, git_model.UpdateCommitComment(t.Context(), list[0]))
|
||||
c2, err := git_model.GetCommitCommentByID(t.Context(), list[0].ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "updated", c2.Content)
|
||||
|
||||
// delete
|
||||
assert.NoError(t, git_model.DeleteCommitComment(t.Context(), c2.ID))
|
||||
_, err = git_model.GetCommitCommentByID(t.Context(), c2.ID)
|
||||
assert.Error(t, err)
|
||||
|
||||
// ensure deleted not listed
|
||||
list2, err := git_model.ListCommitComments(t.Context(), repo.ID, "deadbeef")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, list2, 0)
|
||||
|
||||
// ensure DB consistency
|
||||
unittest.CheckConsistencyFor(t, &git_model.CommitComment{})
|
||||
}
|
||||
@ -27,15 +27,20 @@ import (
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
asymkey_service "code.gitea.io/gitea/services/asymkey"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/context/upload"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
git_service "code.gitea.io/gitea/services/git"
|
||||
"code.gitea.io/gitea/services/gitdiff"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
"code.gitea.io/gitea/services/repository/gitgraph"
|
||||
user_service "code.gitea.io/gitea/services/user"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -350,6 +355,23 @@ func Diff(ctx *context.Context) {
|
||||
ctx.Data["Username"] = userName
|
||||
ctx.Data["Reponame"] = repoName
|
||||
|
||||
// Load commit comments into diff so inline conversations are visible
|
||||
if diff != nil {
|
||||
// Ensure ShowOutdatedComments is a boolean (middleware may not have run for commit routes)
|
||||
showOutdated := false
|
||||
if v, ok := ctx.Data["ShowOutdatedComments"].(bool); ok {
|
||||
showOutdated = v
|
||||
} else {
|
||||
showOutdated = ctx.FormBool("show-outdated")
|
||||
ctx.Data["ShowOutdatedComments"] = showOutdated
|
||||
}
|
||||
|
||||
if err := diff.LoadCommitComments(ctx, ctx.Repo.Repository, ctx.Doer, commitID, showOutdated); err != nil {
|
||||
ctx.ServerError("LoadCommitComments", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var parentCommit *git.Commit
|
||||
var parentCommitID string
|
||||
if commit.ParentCount() > 0 {
|
||||
@ -366,6 +388,12 @@ func Diff(ctx *context.Context) {
|
||||
ctx.Data["Diff"] = diff
|
||||
ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData
|
||||
|
||||
// Provide root and helper functions expected by diff/issue templates
|
||||
ctx.Data["root"] = ctx.Data
|
||||
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
|
||||
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
|
||||
}
|
||||
|
||||
if !fileOnly {
|
||||
diffTree, err := gitdiff.GetDiffTree(ctx, gitRepo, false, parentCommitID, commitID)
|
||||
if err != nil {
|
||||
@ -427,6 +455,99 @@ func Diff(ctx *context.Context) {
|
||||
ctx.HTML(http.StatusOK, tplCommitPage)
|
||||
}
|
||||
|
||||
// RenderNewCommitCommentForm renders the form for creating a new commit comment
|
||||
func RenderNewCommitCommentForm(ctx *context.Context) {
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if !ctx.IsSigned || !ctx.Repo.CanWrite(unit_model.TypeCode) {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
sha := ctx.PathParam("sha")
|
||||
ctx.Data["CommitID"] = sha
|
||||
ctx.Data["AfterCommitID"] = sha
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
ctx.HTML(http.StatusOK, tplNewComment)
|
||||
}
|
||||
|
||||
// CreateCommitCodeComment creates a code comment on a commit
|
||||
func CreateCommitCodeComment(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.CodeCommentForm)
|
||||
sha := ctx.PathParam("sha")
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if !ctx.IsSigned || !ctx.Repo.CanWrite(unit_model.TypeCode) {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if ctx.HasError() {
|
||||
ctx.Flash.Error(ctx.Data["ErrorMsg"].(string))
|
||||
ctx.Redirect(fmt.Sprintf("%s/commit/%s", ctx.Repo.RepoLink, sha))
|
||||
return
|
||||
}
|
||||
|
||||
signedLine := form.Line
|
||||
if form.Side == "previous" {
|
||||
signedLine *= -1
|
||||
}
|
||||
|
||||
comment := &git_model.CommitComment{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
CommitSHA: sha,
|
||||
PosterID: ctx.Doer.ID,
|
||||
Path: form.TreePath,
|
||||
Line: int64(signedLine),
|
||||
Content: form.Content,
|
||||
}
|
||||
if err := git_model.CreateCommitComment(ctx, comment); err != nil {
|
||||
ctx.ServerError("CreateCommitComment", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := comment.LoadPoster(ctx); err != nil {
|
||||
ctx.ServerError("LoadPoster", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Render content for this and all comments on the same line/path
|
||||
comments, err := git_model.ListCommitCommentsByLine(ctx, ctx.Repo.Repository.ID, sha, form.TreePath, int64(signedLine))
|
||||
if err != nil {
|
||||
ctx.ServerError("ListCommitCommentsByLine", err)
|
||||
return
|
||||
}
|
||||
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefPath: path.Join("commit", util.PathEscapeSegments(sha))})
|
||||
for _, cc := range comments {
|
||||
if err := cc.LoadPoster(ctx); err != nil {
|
||||
ctx.ServerError("LoadPoster", err)
|
||||
return
|
||||
}
|
||||
renderedHTML, err := markdown.RenderString(rctx, cc.Content)
|
||||
if err != nil {
|
||||
ctx.ServerError("RenderString", err)
|
||||
return
|
||||
}
|
||||
cc.RenderedContent = renderedHTML
|
||||
}
|
||||
|
||||
// Prepare data compatible with the PR conversation templates
|
||||
ctx.Data["comments"] = comments
|
||||
ctx.Data["root"] = ctx.Data
|
||||
ctx.Data["AfterCommitID"] = sha
|
||||
ctx.Data["CommitID"] = sha
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
ctx.Data["CanMarkConversation"] = false
|
||||
// Helper expected by PR templates
|
||||
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
|
||||
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplDiffConversation)
|
||||
}
|
||||
|
||||
// RawDiff dumps diff results of repository in given commit ID to io.Writer
|
||||
func RawDiff(ctx *context.Context) {
|
||||
var gitRepo *git.Repository
|
||||
|
||||
171
routers/web/repo/commit_comment.go
Normal file
171
routers/web/repo/commit_comment.go
Normal file
@ -0,0 +1,171 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
renderhelper "code.gitea.io/gitea/models/renderhelper"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
)
|
||||
|
||||
// DeleteCommitComment deletes a commit comment
|
||||
func DeleteCommitComment(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
cc, err := git_model.GetCommitCommentByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetCommitCommentByID", git_model.IsErrCommitCommentNotExist, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cc.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.NotFound(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.IsSigned || (ctx.Doer.ID != cc.PosterID && !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName)) {
|
||||
// allow deletion by poster or users who can write to the branch (repo maintainers)
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := git_model.DeleteCommitComment(ctx, id); err != nil {
|
||||
ctx.ServerError("DeleteCommitComment", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("Commit comment deleted: %d/%d", ctx.Repo.Repository.ID, id)
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// UpdateCommitComment updates commit comment content
|
||||
func UpdateCommitComment(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
cc, err := git_model.GetCommitCommentByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetCommitCommentByID", git_model.IsErrCommitCommentNotExist, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cc.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.NotFound(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.IsSigned || (ctx.Doer.ID != cc.PosterID && !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName)) {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
newContent := ctx.FormString("content")
|
||||
if newContent != cc.Content {
|
||||
oldContent := cc.Content
|
||||
cc.Content = newContent
|
||||
if err := git_model.UpdateCommitComment(ctx, cc); err != nil {
|
||||
ctx.ServerError("UpdateCommitComment", err)
|
||||
return
|
||||
}
|
||||
log.Trace("Commit comment updated: %d/%d", ctx.Repo.Repository.ID, id)
|
||||
_ = oldContent // reserved for potential audit/logging
|
||||
}
|
||||
|
||||
// render updated content using markdown renderer so newlines and markdown are preserved
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefPath: path.Join("commit", util.PathEscapeSegments(cc.CommitSHA))})
|
||||
renderedHTML, err := markdown.RenderString(rctx, cc.Content)
|
||||
if err != nil {
|
||||
ctx.ServerError("RenderString", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"content": renderedHTML,
|
||||
"contentVersion": cc.ContentVersion(),
|
||||
"attachments": "",
|
||||
})
|
||||
}
|
||||
|
||||
// ChangeCommitCommentReaction handles react/unreact on a commit comment
|
||||
func ChangeCommitCommentReaction(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.ReactionForm)
|
||||
id := ctx.PathParamInt64("id")
|
||||
cc, err := git_model.GetCommitCommentByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetCommitCommentByID", git_model.IsErrCommitCommentNotExist, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cc.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.NotFound(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.IsSigned {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
switch ctx.PathParam("action") {
|
||||
case "react":
|
||||
if _, err := git_model.CreateCommitCommentReaction(ctx, ctx.Doer, cc.ID, form.Content); err != nil {
|
||||
// If the reactions table wasn't present (older DB), try creating it on-the-fly and retry once.
|
||||
if strings.Contains(err.Error(), "Table not found") || strings.Contains(err.Error(), "no such table") {
|
||||
log.Warn("Commit comment reactions table missing; attempting to create table and retry: %s", err)
|
||||
if err := db.GetEngine(ctx).Sync(new(git_model.CommitCommentReaction)); err != nil {
|
||||
log.Error("Failed to create commit_comment_reaction table: %v", err)
|
||||
break
|
||||
}
|
||||
if _, err2 := git_model.CreateCommitCommentReaction(ctx, ctx.Doer, cc.ID, form.Content); err2 != nil {
|
||||
log.Info("CreateCommitCommentReaction retry failed: %s", err2)
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
// log and continue; forbidden reaction returns error
|
||||
log.Info("CreateCommitCommentReaction: %s", err)
|
||||
break
|
||||
}
|
||||
case "unreact":
|
||||
if err := git_model.DeleteCommitCommentReaction(ctx, ctx.Doer.ID, cc.ID, form.Content); err != nil {
|
||||
ctx.ServerError("DeleteCommitCommentReaction", err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Reload new reactions
|
||||
reactions, err := git_model.LoadReactionsForCommitComment(ctx, cc.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("LoadReactionsForCommitComment", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Log reactions counts for diagnostics
|
||||
var totalReacts int
|
||||
for _, list := range reactions {
|
||||
totalReacts += len(list)
|
||||
}
|
||||
log.Trace("Loaded %d commit comment reactions for: %d", totalReacts, cc.ID)
|
||||
|
||||
html, err := ctx.RenderToHTML(tplReactions, map[string]any{
|
||||
"ActionURL": fmt.Sprintf("%s/commit/%s/comments/%d/reactions", ctx.Repo.RepoLink, cc.CommitSHA, cc.ID),
|
||||
"Reactions": reactions,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("ChangeCommitCommentReaction.HTMLString", err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"html": html,
|
||||
})
|
||||
}
|
||||
@ -1616,8 +1616,17 @@ func registerWebRoutes(m *web.Router) {
|
||||
|
||||
m.Group("", func() {
|
||||
m.Get("/graph", repo.Graph)
|
||||
m.Get("/commit/{sha:([a-f0-9]{7,64})$}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff)
|
||||
m.Get("/commit/{sha:([a-f0-9]{7,64})$}/load-branches-and-tags", repo.LoadBranchesAndTags)
|
||||
m.Group("/commit/{sha:[a-f0-9]{7,64}}", func() {
|
||||
m.Get("", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff)
|
||||
m.Get("/load-branches-and-tags", repo.LoadBranchesAndTags)
|
||||
m.Group("/files", func() {
|
||||
m.Get("/reviews/new_comment", repo.RenderNewCommitCommentForm)
|
||||
m.Post("/reviews/comments", web.Bind(forms.CodeCommentForm{}), repo.CreateCommitCodeComment)
|
||||
})
|
||||
m.Post("/comments/{id}", repo.UpdateCommitComment)
|
||||
m.Post("/comments/{id}/delete", repo.DeleteCommitComment)
|
||||
m.Post("/comments/{id}/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeCommitCommentReaction)
|
||||
})
|
||||
|
||||
// FIXME: this route `/cherry-pick/{sha}` doesn't seem useful or right, the new code always uses `/_cherrypick/` which could handle branch name correctly
|
||||
m.Get("/cherry-pick/{sha:([a-f0-9]{7,64})$}", repo.SetEditorconfigIfExists, context.RepoRefByDefaultBranch(), repo.CherryPick)
|
||||
|
||||
@ -22,6 +22,8 @@ import (
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
pull_model "code.gitea.io/gitea/models/pull"
|
||||
renderhelper "code.gitea.io/gitea/models/renderhelper"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/analyze"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
@ -34,6 +36,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/lfs"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/svg"
|
||||
@ -593,6 +596,115 @@ func (diff *Diff) LoadComments(ctx context.Context, issue *issues_model.Issue, c
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadCommitComments loads commit comments into each line similar to PR code comments
|
||||
func (diff *Diff) LoadCommitComments(ctx context.Context, repo *repo_model.Repository, currentUser *user_model.User, commitSHA string, showOutdatedComments bool) error {
|
||||
cs, err := git_model.ListCommitComments(ctx, repo.ID, commitSHA)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Preload reactions for all commit comments
|
||||
reactionsByComment := make(map[int64]issues_model.ReactionList)
|
||||
if len(cs) > 0 {
|
||||
commentIDs := make([]int64, 0, len(cs))
|
||||
for _, cc := range cs {
|
||||
commentIDs = append(commentIDs, cc.ID)
|
||||
}
|
||||
commitReactions := make([]*git_model.CommitCommentReaction, 0, len(cs))
|
||||
if err := db.GetEngine(ctx).In("commit_comment_id", commentIDs).Asc("created_unix").Find(&commitReactions); err != nil {
|
||||
return err
|
||||
}
|
||||
userIDs := make([]int64, 0, len(commitReactions))
|
||||
for _, cr := range commitReactions {
|
||||
react := &issues_model.Reaction{
|
||||
Type: cr.Type,
|
||||
IssueID: 0,
|
||||
CommentID: cr.CommitCommentID,
|
||||
UserID: cr.UserID,
|
||||
OriginalAuthorID: cr.OriginalAuthorID,
|
||||
OriginalAuthor: cr.OriginalAuthor,
|
||||
CreatedUnix: cr.CreatedUnix,
|
||||
}
|
||||
reactionsByComment[cr.CommitCommentID] = append(reactionsByComment[cr.CommitCommentID], react)
|
||||
if cr.OriginalAuthor == "" && cr.UserID > 0 {
|
||||
userIDs = append(userIDs, cr.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
userMap := make(map[int64]*user_model.User)
|
||||
if len(userIDs) > 0 {
|
||||
if err := db.GetEngine(ctx).In("id", userIDs).Find(&userMap); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, list := range reactionsByComment {
|
||||
for _, reaction := range list {
|
||||
if reaction.OriginalAuthor != "" {
|
||||
name := fmt.Sprintf("%s(%s)", reaction.OriginalAuthor, repo.OriginalServiceType.Name())
|
||||
reaction.User = &user_model.User{ID: 0, Name: name, LowerName: strings.ToLower(name)}
|
||||
continue
|
||||
}
|
||||
if u, ok := userMap[reaction.UserID]; ok {
|
||||
reaction.User = u
|
||||
} else {
|
||||
reaction.User = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build map[string]map[int64][]*issues_model.Comment
|
||||
lineCommits := make(map[string]map[int64][]*issues_model.Comment)
|
||||
for _, cc := range cs {
|
||||
if err := cc.LoadPoster(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
c := &issues_model.Comment{
|
||||
ID: cc.ID,
|
||||
Type: issues_model.CommentTypeCode,
|
||||
PosterID: cc.PosterID,
|
||||
Poster: cc.Poster,
|
||||
OriginalAuthor: "",
|
||||
IssueID: 0,
|
||||
Content: cc.Content,
|
||||
CreatedUnix: cc.CreatedUnix,
|
||||
Line: cc.Line,
|
||||
TreePath: cc.Path,
|
||||
Reactions: reactionsByComment[cc.ID],
|
||||
}
|
||||
// Render content for this comment
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, repo, renderhelper.RepoCommentOptions{CurrentRefPath: path.Join("commit", util.PathEscapeSegments(commitSHA))})
|
||||
renderedHTML, err := markdown.RenderString(rctx, cc.Content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.RenderedContent = renderedHTML
|
||||
|
||||
if lineCommits[c.TreePath] == nil {
|
||||
lineCommits[c.TreePath] = make(map[int64][]*issues_model.Comment)
|
||||
}
|
||||
lineCommits[c.TreePath][c.Line] = append(lineCommits[c.TreePath][c.Line], c)
|
||||
}
|
||||
|
||||
for _, file := range diff.Files {
|
||||
if lineCommitsForFile, ok := lineCommits[file.Name]; ok {
|
||||
for _, section := range file.Sections {
|
||||
for _, line := range section.Lines {
|
||||
if comments, ok := lineCommitsForFile[int64(line.LeftIdx*-1)]; ok {
|
||||
line.Comments = append(line.Comments, comments...)
|
||||
}
|
||||
if comments, ok := lineCommitsForFile[int64(line.RightIdx)]; ok {
|
||||
line.Comments = append(line.Comments, comments...)
|
||||
}
|
||||
sort.SliceStable(line.Comments, func(i, j int) bool {
|
||||
return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix
|
||||
})
|
||||
FillHiddenCommentIDsForDiffLine(line, lineCommitsForFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const cmdDiffHead = "diff --git "
|
||||
|
||||
// ParsePatch builds a Diff object from a io.Reader and some parameters.
|
||||
|
||||
@ -184,7 +184,13 @@
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<table class="chroma" data-new-comment-url="{{$.Issue.Link}}/files/reviews/new_comment" data-path="{{$file.Name}}">
|
||||
{{ $newCommentURL := "" }}
|
||||
{{ if $.PageIsPullFiles }}
|
||||
{{ $newCommentURL = printf "%s/files/reviews/new_comment" $.Issue.Link }}
|
||||
{{ else if $.CommitID }}
|
||||
{{ $newCommentURL = printf "%s/commit/%s/files/reviews/new_comment" $.RepoLink $.CommitID }}
|
||||
{{ end }}
|
||||
<table class="chroma" data-new-comment-url="{{$newCommentURL}}" data-path="{{$file.Name}}">
|
||||
{{if $.IsSplitStyle}}
|
||||
{{template "repo/diff/section_split" dict "file" . "root" $}}
|
||||
{{else}}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{{if and $.root.SignedUserID (not $.Repository.IsArchived)}}
|
||||
<form class="ui form {{if $.hidden}}tw-hidden comment-form{{end}}" action="{{$.root.Issue.Link}}/files/reviews/comments" method="post">
|
||||
<input type="hidden" name="origin" value="{{if $.root.PageIsPullFiles}}diff{{else}}timeline{{end}}">
|
||||
<form class="ui form {{if $.hidden}}tw-hidden comment-form{{end}}" action="{{if $.root.PageIsPullFiles}}{{$.root.Issue.Link}}/files/reviews/comments{{else if $.root.CommitID}}{{$.root.RepoLink}}/commit/{{$.root.CommitID}}/files/reviews/comments{{end}}" method="post">
|
||||
<input type="hidden" name="origin" value="{{if or $.root.PageIsPullFiles $.root.CommitID}}diff{{else}}timeline{{end}}">
|
||||
<input type="hidden" name="latest_commit_id" value="{{$.root.AfterCommitID}}">
|
||||
<input type="hidden" name="side" value="{{if $.Side}}{{$.Side}}{{end}}">
|
||||
<input type="hidden" name="line" value="{{if $.Line}}{{$.Line}}{{end}}">
|
||||
@ -32,7 +32,7 @@
|
||||
<input type="hidden" name="reply" value="{{$.reply}}">
|
||||
<input type="hidden" name="single_review" value="true">
|
||||
{{else}}
|
||||
{{if $.root.CurrentReview}}
|
||||
{{if or $.root.CurrentReview $.root.CommitID}}
|
||||
<button name="pending_review" type="submit" class="ui submit primary tiny button btn-add-comment">{{ctx.Locale.Tr "repo.diff.comment.add_review_comment"}}</button>
|
||||
{{else}}
|
||||
<button name="pending_review" type="submit" class="ui submit primary tiny button btn-start-review">{{ctx.Locale.Tr "repo.diff.comment.start_review"}}</button>
|
||||
|
||||
@ -51,7 +51,11 @@
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if not $.root.Repository.IsArchived}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.root.RepoLink .ID)}}
|
||||
{{if $.root.CommitID}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/commit/%s/comments/%d/reactions" $.root.RepoLink $.root.CommitID .ID)}}
|
||||
{{else}}
|
||||
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.root.RepoLink .ID)}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{template "repo/issue/view_content/context_menu" dict "item" . "delete" true "issue" false "diff" true "IsCommentPoster" (and $.root.IsSigned (eq $.root.SignedUserID .PosterID))}}
|
||||
</div>
|
||||
@ -65,14 +69,18 @@
|
||||
{{end}}
|
||||
</div>
|
||||
<div id="issuecomment-{{.ID}}-raw" class="raw-content tw-hidden">{{.Content}}</div>
|
||||
<div class="edit-content-zone tw-hidden" data-update-url="{{$.root.RepoLink}}/comments/{{.ID}}" data-content-version="{{.ContentVersion}}" data-context="{{$.root.RepoLink}}" data-attachment-url="{{$.root.RepoLink}}/comments/{{.ID}}/attachments"></div>
|
||||
<div class="edit-content-zone tw-hidden" data-update-url="{{if $.root.CommitID}}{{printf "%s/commit/%s/comments/%d" $.root.RepoLink $.root.CommitID .ID}}{{else}}{{$.root.RepoLink}}/comments/{{.ID}}{{end}}" data-content-version="{{.ContentVersion}}" data-context="{{$.root.RepoLink}}" data-attachment-url="{{if $.root.CommitID}}{{printf "%s/commit/%s/comments/%d/attachments" $.root.RepoLink $.root.CommitID .ID}}{{else}}{{$.root.RepoLink}}/comments/{{.ID}}/attachments{{end}}"></div>
|
||||
{{if .Attachments}}
|
||||
{{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{$reactions := .Reactions.GroupByType}}
|
||||
{{if $reactions}}
|
||||
{{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $.root.RepoLink .ID) "Reactions" $reactions}}
|
||||
{{if $.root.CommitID}}
|
||||
{{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/commit/%s/comments/%d/reactions" $.root.RepoLink $.root.CommitID .ID) "Reactions" $reactions}}
|
||||
{{else}}
|
||||
{{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $.root.RepoLink .ID) "Reactions" $reactions}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
<td class="lines-escape del-code lines-escape-old">{{if $line.LeftIdx}}{{if $leftDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $leftDiff}}"></button>{{end}}{{end}}</td>
|
||||
<td class="lines-type-marker lines-type-marker-old del-code"><span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span></td>
|
||||
<td class="lines-code lines-code-old del-code">
|
||||
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}}
|
||||
{{- if and $.root.SignedUserID (or (not $.root.CommitID) $.root.CanWriteCode) -}}
|
||||
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-left{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="left" data-idx="{{$line.LeftIdx}}">
|
||||
{{- svg "octicon-plus" -}}
|
||||
</button>
|
||||
@ -43,7 +43,7 @@
|
||||
<td class="lines-escape add-code lines-escape-new">{{if $match.RightIdx}}{{if $rightDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $rightDiff}}"></button>{{end}}{{end}}</td>
|
||||
<td class="lines-type-marker lines-type-marker-new add-code">{{if $match.RightIdx}}<span class="tw-font-mono" data-type-marker="{{$match.GetLineTypeMarker}}"></span>{{end}}</td>
|
||||
<td class="lines-code lines-code-new add-code">
|
||||
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}}
|
||||
{{- if and $.root.SignedUserID (or (not $.root.CommitID) $.root.CanWriteCode) -}}
|
||||
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-right{{if (not $match.CanComment)}} tw-invisible{{end}}" data-side="right" data-idx="{{$match.RightIdx}}">
|
||||
{{- svg "octicon-plus" -}}
|
||||
</button>
|
||||
@ -60,7 +60,7 @@
|
||||
<td class="lines-escape lines-escape-old">{{if $line.LeftIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"></button>{{end}}{{end}}</td>
|
||||
<td class="lines-type-marker lines-type-marker-old">{{if $line.LeftIdx}}<span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span>{{end}}</td>
|
||||
<td class="lines-code lines-code-old">
|
||||
{{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 2)) -}}
|
||||
{{- if and $.root.SignedUserID (or (not $.root.CommitID) $.root.CanWriteCode) (not (eq .GetType 2)) -}}
|
||||
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-left{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="left" data-idx="{{$line.LeftIdx}}">
|
||||
{{- svg "octicon-plus" -}}
|
||||
</button>
|
||||
@ -75,7 +75,7 @@
|
||||
<td class="lines-escape lines-escape-new">{{if $line.RightIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"></button>{{end}}{{end}}</td>
|
||||
<td class="lines-type-marker lines-type-marker-new">{{if $line.RightIdx}}<span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span>{{end}}</td>
|
||||
<td class="lines-code lines-code-new">
|
||||
{{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 3)) -}}
|
||||
{{- if and $.root.SignedUserID (or (not $.root.CommitID) $.root.CanWriteCode) (not (eq .GetType 3)) -}}
|
||||
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-right{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="right" data-idx="{{$line.RightIdx}}">
|
||||
{{- svg "octicon-plus" -}}
|
||||
</button>
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
<td class="chroma lines-code blob-hunk">{{template "repo/diff/section_code" dict "diff" $inlineDiff}}</td>
|
||||
{{else}}
|
||||
<td class="chroma lines-code{{if (not $line.RightIdx)}} lines-code-old{{end}}">
|
||||
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}}
|
||||
{{- if and $.root.SignedUserID (or (not $.root.CommitID) $.root.CanWriteCode) -}}
|
||||
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-{{if $line.RightIdx}}right{{else}}left{{end}}{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="{{if $line.RightIdx}}right{{else}}left{{end}}" data-idx="{{if $line.RightIdx}}{{$line.RightIdx}}{{else}}{{$line.LeftIdx}}{{end}}">
|
||||
{{- svg "octicon-plus" -}}
|
||||
</button>
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
{{$referenceUrl := ""}}
|
||||
{{if .issue}}
|
||||
{{$referenceUrl = printf "%s#%s" ctx.RootData.Issue.Link .item.HashTag}}
|
||||
{{else if ctx.RootData.CommitID}}
|
||||
{{$referenceUrl = printf "%s/commit/%s/files#%s" ctx.RootData.RepoLink ctx.RootData.CommitID .item.HashTag}}
|
||||
{{else}}
|
||||
{{$referenceUrl = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}}
|
||||
{{end}}
|
||||
@ -15,15 +17,19 @@
|
||||
{{if not ctx.RootData.Repository.IsArchived}}
|
||||
{{$needDivider = true}}
|
||||
<div class="item context js-aria-clickable quote-reply {{if .diff}}quote-reply-diff{{end}}" data-target="{{.item.HashTag}}-raw">{{ctx.Locale.Tr "repo.issues.context.quote_reply"}}</div>
|
||||
{{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
|
||||
{{if and ctx.RootData.PageIsPullFiles (not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled)}}
|
||||
<div class="item context js-aria-clickable reference-issue" data-target="{{.item.HashTag}}-raw" data-modal="#reference-issue-modal" data-poster="{{.item.Poster.GetDisplayName}}" data-poster-username="{{.item.Poster.Name}}" data-reference="{{$referenceUrl}}">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</div>
|
||||
{{end}}
|
||||
{{if or ctx.RootData.Permission.IsAdmin .IsCommentPoster ctx.RootData.HasIssuesOrPullsWritePermission}}
|
||||
<div class="divider"></div>
|
||||
<div class="item context js-aria-clickable edit-content">{{ctx.Locale.Tr "repo.issues.context.edit"}}</div>
|
||||
<div class="item context js-aria-clickable edit-content" data-target="{{.item.HashTag}}-raw">{{ctx.Locale.Tr "repo.issues.context.edit"}}</div>
|
||||
{{if .delete}}
|
||||
{{if ctx.RootData.CommitID}}
|
||||
<div class="item context js-aria-clickable delete-comment" data-comment-id={{.item.HashTag}} data-url="{{ctx.RootData.RepoLink}}/commit/{{ctx.RootData.CommitID}}/comments/{{.item.ID}}/delete" data-locale="{{ctx.Locale.Tr "repo.issues.delete_comment_confirm"}}">{{ctx.Locale.Tr "repo.issues.context.delete"}}</div>
|
||||
{{else}}
|
||||
<div class="item context js-aria-clickable delete-comment" data-comment-id={{.item.HashTag}} data-url="{{ctx.RootData.RepoLink}}/comments/{{.item.ID}}/delete" data-locale="{{ctx.Locale.Tr "repo.issues.delete_comment_confirm"}}">{{ctx.Locale.Tr "repo.issues.context.delete"}}</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{$canUserBlock := call ctx.RootData.CanBlockUser ctx.RootData.SignedUser .item.Poster}}
|
||||
|
||||
@ -49,8 +49,12 @@ function initRepoDiffConversationForm() {
|
||||
// on the diff page, the form is inside a "tr" and need to get the line-type ahead
|
||||
// but on the conversation page, there is no parent "tr"
|
||||
const trLineType = form.closest('tr')?.getAttribute('data-line-type');
|
||||
const response = await POST(form.getAttribute('action')!, {data: formData});
|
||||
const newConversationHolder = createElementFromHTML(await response.text());
|
||||
const response = await POST(form.getAttribute('action')!, {data: formData, credentials: 'include', redirect: 'follow'});
|
||||
const respText = await response.text();
|
||||
const respDoc = parseDom(respText, 'text/html');
|
||||
const conv = respDoc.querySelector('.conversation-holder');
|
||||
let newConversationHolder = conv as HTMLElement;
|
||||
|
||||
const path = newConversationHolder.getAttribute('data-path');
|
||||
const side = newConversationHolder.getAttribute('data-side');
|
||||
const idx = newConversationHolder.getAttribute('data-idx');
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {handleReply} from './repo-issue.ts';
|
||||
import {addDelegatedEventListener} from '../utils/dom.ts';
|
||||
import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
@ -12,7 +13,19 @@ async function tryOnEditContent(e: Event) {
|
||||
if (!clickTarget) return;
|
||||
|
||||
e.preventDefault();
|
||||
const commentContent = clickTarget.closest('.comment-header')!.nextElementSibling!;
|
||||
|
||||
// If the menu item is outside the comment DOM (dropdown appended to body), it should include a data-target attribute
|
||||
// pointing to the raw content element (eg. "issuecomment-<id>-raw"). Prefer using that when present.
|
||||
let commentContent: HTMLElement;
|
||||
const dataTarget = (clickTarget as HTMLElement).getAttribute('data-target');
|
||||
if (dataTarget) {
|
||||
const raw = document.querySelector<HTMLElement>(`#${dataTarget}`);
|
||||
if (!raw) return;
|
||||
commentContent = raw.parentElement as HTMLElement;
|
||||
} else {
|
||||
commentContent = clickTarget.closest('.comment-header')!.nextElementSibling as HTMLElement;
|
||||
}
|
||||
|
||||
const editContentZone = commentContent.querySelector('.edit-content-zone')!;
|
||||
let renderContent = commentContent.querySelector('.render-content')!;
|
||||
const rawContent = commentContent.querySelector('.raw-content')!;
|
||||
@ -127,7 +140,18 @@ async function tryOnQuoteReply(e: Event) {
|
||||
|
||||
e.preventDefault();
|
||||
const contentToQuoteId = clickTarget.getAttribute('data-target');
|
||||
const targetRawToQuote = document.querySelector<HTMLElement>(`#${contentToQuoteId}.raw-content`)!;
|
||||
// If a data-target is available (menu item was rendered with it), prefer that as it works even if menu is moved out of the comment DOM.
|
||||
let targetRawToQuote: HTMLElement | null = null;
|
||||
if (contentToQuoteId) {
|
||||
targetRawToQuote = document.querySelector<HTMLElement>(`#${contentToQuoteId}.raw-content`);
|
||||
}
|
||||
if (!targetRawToQuote) {
|
||||
// fallback to old approach of locating markup inside comment DOM
|
||||
const candidate = clickTarget.closest('.comment-header')?.nextElementSibling as HTMLElement | undefined;
|
||||
if (candidate) targetRawToQuote = candidate.querySelector<HTMLElement>('.raw-content');
|
||||
}
|
||||
if (!targetRawToQuote) return;
|
||||
|
||||
const targetMarkupToQuote = targetRawToQuote.parentElement!.querySelector<HTMLElement>('.render-content.markup')!;
|
||||
let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote);
|
||||
if (!contentToQuote) contentToQuote = targetRawToQuote.textContent;
|
||||
@ -135,7 +159,8 @@ async function tryOnQuoteReply(e: Event) {
|
||||
|
||||
let editor;
|
||||
if (clickTarget.classList.contains('quote-reply-diff')) {
|
||||
const replyBtn = clickTarget.closest('.comment-code-cloud')!.querySelector<HTMLElement>('button.comment-form-reply')!;
|
||||
// Prefer to find the reply button by using the raw content's parent conversation-holder when dropdown is outside the DOM
|
||||
const replyBtn = (targetRawToQuote.parentElement!.closest('.conversation-holder') || clickTarget.closest('.comment-code-cloud'))?.querySelector<HTMLElement>('button.comment-form-reply')!;
|
||||
editor = await handleReply(replyBtn);
|
||||
} else {
|
||||
// for normal issue/comment page
|
||||
@ -151,9 +176,16 @@ async function tryOnQuoteReply(e: Event) {
|
||||
editor.moveCursorToEnd();
|
||||
}
|
||||
|
||||
let _repoIssueCommentEditInited = false;
|
||||
export function initRepoIssueCommentEdit() {
|
||||
document.addEventListener('click', (e) => {
|
||||
tryOnEditContent(e); // Edit issue or comment content
|
||||
tryOnQuoteReply(e); // Quote reply to the comment editor
|
||||
if (_repoIssueCommentEditInited) return;
|
||||
_repoIssueCommentEditInited = true;
|
||||
|
||||
// Use pointerdown delegation so we catch menu item interactions even if dropdown removes DOM nodes on click
|
||||
addDelegatedEventListener(document, 'pointerdown', '.edit-content, .quote-reply', (el: HTMLElement, e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
// el is either .edit-content or .quote-reply
|
||||
if (el.matches('.edit-content')) tryOnEditContent(e as unknown as Event);
|
||||
if (el.matches('.quote-reply') || el.closest('.quote-reply')) tryOnQuoteReply(e as unknown as Event);
|
||||
});
|
||||
}
|
||||
|
||||
@ -126,11 +126,11 @@ export function initRepoIssueFilterItemLabel() {
|
||||
|
||||
export function initRepoIssueCommentDelete() {
|
||||
// Delete comment
|
||||
document.addEventListener('click', async (e) => {
|
||||
if (!(e.target as HTMLElement).matches('.delete-comment')) return;
|
||||
// Use pointerdown to ensure we catch interactions before dropdown/hide logic removes the menu from DOM
|
||||
addDelegatedEventListener(document, 'pointerdown', '.delete-comment', async (el, e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const deleteButton = e.target as HTMLElement;
|
||||
const deleteButton = el as HTMLElement;
|
||||
if (window.confirm(deleteButton.getAttribute('data-locale')!)) {
|
||||
try {
|
||||
const response = await POST(deleteButton.getAttribute('data-url')!);
|
||||
@ -183,10 +183,10 @@ export function initRepoIssueCommentDelete() {
|
||||
|
||||
export function initRepoIssueCodeCommentCancel() {
|
||||
// Cancel inline code comment
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!(e.target as HTMLElement).matches('.cancel-code-comment')) return;
|
||||
addDelegatedEventListener(document, 'click', '.cancel-code-comment', (el, e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const form = (e.target as HTMLElement).closest('form')!;
|
||||
const form = (el as HTMLElement).closest('form')!;
|
||||
if (form?.classList.contains('comment-form')) {
|
||||
hideElem(form);
|
||||
showElem(form.closest('.comment-code-cloud')!.querySelectorAll('button.comment-form-reply'));
|
||||
@ -301,8 +301,8 @@ export function initRepoPullRequestReview() {
|
||||
handleReply(el);
|
||||
});
|
||||
|
||||
// The following part is only for diff views
|
||||
if (!document.querySelector('.repository.pull.diff')) return;
|
||||
// The following part is only for diff views (PR and commit diffs)
|
||||
if (!document.querySelector('.repository.diff')) return;
|
||||
|
||||
const elReviewBtn = document.querySelector('.js-btn-review');
|
||||
const elReviewPanel = document.querySelector('.review-box-panel.tippy-target');
|
||||
@ -345,8 +345,38 @@ export function initRepoPullRequestReview() {
|
||||
const td = ntr.querySelector(`.add-comment-${side}`)!;
|
||||
const commentCloud = td.querySelector('.comment-code-cloud');
|
||||
if (!commentCloud && !ntr.querySelector('button[name="pending_review"]')) {
|
||||
const response = await GET(el.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url') ?? '');
|
||||
const response = await GET(el.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url') ?? '', {credentials: 'include', redirect: 'follow'});
|
||||
td.innerHTML = await response.text();
|
||||
// initialize dropdowns within the newly injected content so context menus work
|
||||
queryElems(td, '.ui.dropdown:not(.custom)', (el) => {
|
||||
const $dropdown = fomanticQuery(el as HTMLElement);
|
||||
$dropdown.dropdown('setting', {hideDividers: 'empty'});
|
||||
if ((el as HTMLElement).classList.contains('jump')) {
|
||||
$dropdown.dropdown('setting', {
|
||||
action: 'hide',
|
||||
onShow() {
|
||||
// hide associated tooltip while dropdown is open
|
||||
// eslint-disable-next-line unicorn/no-this-assignment
|
||||
const dd = this as any;
|
||||
dd._tippy?.hide();
|
||||
dd._tippy?.disable();
|
||||
},
|
||||
onHide() {
|
||||
// eslint-disable-next-line unicorn/no-this-assignment
|
||||
const elDropdown = this as HTMLElement;
|
||||
// re-enable the tippy after hiding
|
||||
const dd = (this as any);
|
||||
dd._tippy?.enable();
|
||||
setTimeout(() => {
|
||||
const $dropdownInner = fomanticQuery(elDropdown as HTMLElement);
|
||||
if ($dropdownInner.dropdown('is hidden')) {
|
||||
queryElems(elDropdown as HTMLElement, '.menu > .item', (i) => (i as any)._tippy?.hide());
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
td.querySelector<HTMLInputElement>("input[name='line']")!.value = idx;
|
||||
td.querySelector<HTMLInputElement>("input[name='side']")!.value = (side === 'left' ? 'previous' : 'proposed');
|
||||
td.querySelector<HTMLInputElement>("input[name='path']")!.value = String(path);
|
||||
|
||||
@ -72,5 +72,13 @@ export function initRepository() {
|
||||
registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
|
||||
}
|
||||
|
||||
// Initialize comment edit/quote handlers for other repository pages (commit diffs, PR files)
|
||||
initRepoIssueCommentEdit();
|
||||
// Ensure delete/cancel handlers are active as well on all repo pages
|
||||
initRepoIssueCommentDelete();
|
||||
initRepoIssueCodeCommentCancel();
|
||||
// Initialize reaction selector handlers globally so commit comment reaction buttons work
|
||||
initCompReactionSelector();
|
||||
|
||||
initUnicodeEscapeButton();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user