diff --git a/models/git/commit_comment.go b/models/git/commit_comment.go new file mode 100644 index 0000000000..ae63682e1c --- /dev/null +++ b/models/git/commit_comment.go @@ -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") +} diff --git a/models/git/commit_comment_reaction.go b/models/git/commit_comment_reaction.go new file mode 100644 index 0000000000..4b239e6014 --- /dev/null +++ b/models/git/commit_comment_reaction.go @@ -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 +} diff --git a/models/git/commit_comment_test.go b/models/git/commit_comment_test.go new file mode 100644 index 0000000000..2ca1f70e3b --- /dev/null +++ b/models/git/commit_comment_test.go @@ -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{}) +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 27f5651ecb..d97010e312 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -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 diff --git a/routers/web/repo/commit_comment.go b/routers/web/repo/commit_comment.go new file mode 100644 index 0000000000..3ac73ccb30 --- /dev/null +++ b/routers/web/repo/commit_comment.go @@ -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, + }) +} diff --git a/routers/web/web.go b/routers/web/web.go index 64137876e0..819c5f7132 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go index f00c90d737..86ad06c89c 100644 --- a/services/gitdiff/gitdiff.go +++ b/services/gitdiff/gitdiff.go @@ -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. diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 2a3330d890..b82fa4720b 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -184,7 +184,13 @@ {{end}} {{else}} - + {{ $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 }} +
{{if $.IsSplitStyle}} {{template "repo/diff/section_split" dict "file" . "root" $}} {{else}} diff --git a/templates/repo/diff/comment_form.tmpl b/templates/repo/diff/comment_form.tmpl index 23de737292..94adf46a47 100644 --- a/templates/repo/diff/comment_form.tmpl +++ b/templates/repo/diff/comment_form.tmpl @@ -1,6 +1,6 @@ {{if and $.root.SignedUserID (not $.Repository.IsArchived)}} - - + + @@ -32,7 +32,7 @@ {{else}} - {{if $.root.CurrentReview}} + {{if or $.root.CurrentReview $.root.CommitID}} {{else}} diff --git a/templates/repo/diff/comments.tmpl b/templates/repo/diff/comments.tmpl index 22829fbf8a..eafdc68bad 100644 --- a/templates/repo/diff/comments.tmpl +++ b/templates/repo/diff/comments.tmpl @@ -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))}} @@ -65,14 +69,18 @@ {{end}}
{{.Content}}
-
+
{{if .Attachments}} {{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}} {{end}} {{$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}} diff --git a/templates/repo/diff/section_split.tmpl b/templates/repo/diff/section_split.tmpl index ab23b1b934..bb1c999bb5 100644 --- a/templates/repo/diff/section_split.tmpl +++ b/templates/repo/diff/section_split.tmpl @@ -28,7 +28,7 @@ {{else}}
{{if $line.LeftIdx}}{{if $leftDiff.EscapeStatus.Escaped}}{{end}}{{end}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} + {{- if and $.root.SignedUserID (or (not $.root.CommitID) $.root.CanWriteCode) -}} @@ -43,7 +43,7 @@ {{if $match.RightIdx}}{{if $rightDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $match.RightIdx}}{{end}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} + {{- if and $.root.SignedUserID (or (not $.root.CommitID) $.root.CanWriteCode) -}} @@ -60,7 +60,7 @@ {{if $line.LeftIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $line.LeftIdx}}{{end}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 2)) -}} + {{- if and $.root.SignedUserID (or (not $.root.CommitID) $.root.CanWriteCode) (not (eq .GetType 2)) -}} @@ -75,7 +75,7 @@ {{if $line.RightIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $line.RightIdx}}{{end}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 3)) -}} + {{- if and $.root.SignedUserID (or (not $.root.CommitID) $.root.CanWriteCode) (not (eq .GetType 3)) -}} diff --git a/templates/repo/diff/section_unified.tmpl b/templates/repo/diff/section_unified.tmpl index 6776198b75..db865e2180 100644 --- a/templates/repo/diff/section_unified.tmpl +++ b/templates/repo/diff/section_unified.tmpl @@ -33,7 +33,7 @@ {{template "repo/diff/section_code" dict "diff" $inlineDiff}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} + {{- if and $.root.SignedUserID (or (not $.root.CommitID) $.root.CanWriteCode) -}} diff --git a/templates/repo/issue/view_content/context_menu.tmpl b/templates/repo/issue/view_content/context_menu.tmpl index 749a2fa0dd..a42d658f54 100644 --- a/templates/repo/issue/view_content/context_menu.tmpl +++ b/templates/repo/issue/view_content/context_menu.tmpl @@ -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}}
{{ctx.Locale.Tr "repo.issues.context.quote_reply"}}
- {{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}} + {{if and ctx.RootData.PageIsPullFiles (not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled)}}
{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}
{{end}} {{if or ctx.RootData.Permission.IsAdmin .IsCommentPoster ctx.RootData.HasIssuesOrPullsWritePermission}}
-
{{ctx.Locale.Tr "repo.issues.context.edit"}}
+
{{ctx.Locale.Tr "repo.issues.context.edit"}}
{{if .delete}} + {{if ctx.RootData.CommitID}} +
{{ctx.Locale.Tr "repo.issues.context.delete"}}
+ {{else}}
{{ctx.Locale.Tr "repo.issues.context.delete"}}
{{end}} + {{end}} {{end}} {{end}} {{$canUserBlock := call ctx.RootData.CanBlockUser ctx.RootData.SignedUser .item.Poster}} diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts index 20f27f31bc..71efb827fb 100644 --- a/web_src/js/features/repo-diff.ts +++ b/web_src/js/features/repo-diff.ts @@ -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'); diff --git a/web_src/js/features/repo-issue-edit.ts b/web_src/js/features/repo-issue-edit.ts index 3838c4f041..5eccc7494d 100644 --- a/web_src/js/features/repo-issue-edit.ts +++ b/web_src/js/features/repo-issue-edit.ts @@ -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--raw"). Prefer using that when present. + let commentContent: HTMLElement; + const dataTarget = (clickTarget as HTMLElement).getAttribute('data-target'); + if (dataTarget) { + const raw = document.querySelector(`#${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(`#${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(`#${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('.raw-content'); + } + if (!targetRawToQuote) return; + const targetMarkupToQuote = targetRawToQuote.parentElement!.querySelector('.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('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('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); }); } diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts index 7145936ed1..2b02adc8c8 100644 --- a/web_src/js/features/repo-issue.ts +++ b/web_src/js/features/repo-issue.ts @@ -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("input[name='line']")!.value = idx; td.querySelector("input[name='side']")!.value = (side === 'left' ? 'previous' : 'proposed'); td.querySelector("input[name='path']")!.value = String(path); diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts index fc92242b40..51a0f8a843 100644 --- a/web_src/js/features/repo-legacy.ts +++ b/web_src/js/features/repo-legacy.ts @@ -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(); }