diff --git a/models/git/commit_comment.go b/models/git/commit_comment.go new file mode 100644 index 0000000000000..5a73879403c7c --- /dev/null +++ b/models/git/commit_comment.go @@ -0,0 +1,546 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package git + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "io" + "slices" + "strconv" + + "code.gitea.io/gitea/models/db" + "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/json" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/google/uuid" +) + +type ( + CommitCommentList []*CommitComment + ReactionMap map[string][]string + AttachmentMap map[string]*AttachmentOptions +) + +type CommitComment struct { + ID int64 `xorm:"pk autoincr"` + PosterID int64 `xorm:"INDEX"` + Poster *user_model.User `xorm:"-"` + Line int64 + FileName string + CommitSHA string `xorm:"VARCHAR(64)"` + Attachments string `xorm:"JSON TEXT"` + Reactions string `xorm:"JSON TEXT"` + Comment string `xorm:"LONGTEXT"` + RenderedComment template.HTML `xorm:"-"` + ContentVersion int `xorm:"NOT NULL DEFAULT 0"` + RefRepoID int64 `xorm:"index"` + Repo *repo_model.Repository `xorm:"-"` + ReplyToCommentID int64 `xorm:"index"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` +} + +type CreateCommitCommentOptions struct { + Doer *user_model.User + Repo *repo_model.Repository + + CommitID int64 + CommitSHA string + LineNum int64 + Reactions string + Comment string + FileName string + Attachments AttachmentMap + RefRepoID int64 + ReplyToCommentID int64 +} + +type AttachmentOptions struct { + FileName string + Size int64 + UploaderID int64 +} + +type FindCommitCommentOptions struct { + db.ListOptions + RepoID int64 + CommitSHA string + Since int64 + Before int64 + Line int64 + FileName string +} + +func init() { + db.RegisterModel(new(CommitComment)) +} + +// HashTag returns unique hash tag for CommitComment. +func (commitComment *CommitComment) HashTag() string { + return fmt.Sprintf("commitComment-%d", commitComment.ID) +} + +func (commitComment *CommitComment) LoadRepo(ctx context.Context) (err error) { + if commitComment.Repo == nil && commitComment.RefRepoID != 0 { + commitComment.Repo, err = repo_model.GetRepositoryByID(ctx, commitComment.RefRepoID) + if err != nil { + return fmt.Errorf("getRepositoryByID [%d]: %w", commitComment.RefRepoID, err) + } + } + return nil +} + +// LoadPoster loads poster +func (commitComment *CommitComment) LoadPoster(ctx context.Context) (err error) { + if commitComment.Poster == nil && commitComment.PosterID != 0 { + commitComment.Poster, err = user_model.GetPossibleUserByID(ctx, commitComment.PosterID) + if err != nil { + commitComment.PosterID = user_model.GhostUserID + commitComment.Poster = user_model.NewGhostUser() + if !user_model.IsErrUserNotExist(err) { + return fmt.Errorf("getUserByID.(poster) [%d]: %w", commitComment.PosterID, err) + } + return nil + } + } + return err +} + +func (commitComment *CommitComment) UnsignedLine() uint64 { + if commitComment.Line < 0 { + return uint64(commitComment.Line * -1) + } + return uint64(commitComment.Line) +} + +func (commitComment *CommitComment) TreePath() string { + return commitComment.FileName +} + +// DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes. +func (commitComment *CommitComment) DiffSide() string { + if commitComment.Line < 0 { + return "previous" + } + return "proposed" +} + +func (commitComment *CommitComment) GroupReactionsByType() (ReactionMap, error) { + reactions := make(ReactionMap) + + err := json.Unmarshal([]byte(commitComment.Reactions), &reactions) + if err != nil { + return nil, errors.New("GroupReactionsByType") + } + return reactions, nil +} + +func (commitComment *CommitComment) GroupAttachmentsByUUID() (AttachmentMap, error) { + attachmentMap := make(AttachmentMap) + err := json.Unmarshal([]byte(commitComment.Attachments), &attachmentMap) + if err != nil { + return nil, err + } + return attachmentMap, nil +} + +// HasUser check if user has reacted +func (commitComment *CommitComment) HasUser(reaction string, userID int64) bool { + if userID == 0 { + return false + } + reactions, err := commitComment.GroupReactionsByType() + if err != nil { + return false + } + list := reactions[reaction] + hasUser := false + for _, userid := range list { + id, _ := strconv.ParseInt(userid, 10, 64) + if id == userID { + hasUser = true + return hasUser + } + } + return hasUser +} + +// GetFirstUsers returns first reacted user display names separated by comma +func (commitComment *CommitComment) GetFirstUsers(ctx context.Context, reaction string) string { + var buffer bytes.Buffer + rem := setting.UI.ReactionMaxUserNum + reactions, err := commitComment.GroupReactionsByType() + if err != nil { + return "" + } + list := reactions[reaction] + for _, userid := range list { + if buffer.Len() > 0 { + buffer.WriteString(", ") + } + id, _ := strconv.ParseInt(userid, 10, 64) + user, _ := user_model.GetUserByID(ctx, id) + + buffer.WriteString(user.Name) + if rem--; rem == 0 { + break + } + } + return buffer.String() +} + +// GetMoreUserCount returns count of not shown users in reaction tooltip +func (commitComment *CommitComment) GetMoreUserCount(reaction string) int { + if reaction == "" { + return 0 + } + reactions, err := commitComment.GroupReactionsByType() + if err != nil { + return 0 + } + list := reactions[reaction] + + if len(list) <= setting.UI.ReactionMaxUserNum { + return 0 + } + return len(list) - setting.UI.ReactionMaxUserNum +} + +func GetCommitCommentByID(ctx context.Context, repoID, ID int64) (*CommitComment, error) { + commitComment := &CommitComment{ + RefRepoID: repoID, + ID: ID, + } + has, err := db.GetEngine(ctx).Get(commitComment) + if err != nil { + return nil, err + } else if !has { + return nil, err + } + err = commitComment.LoadRepo(ctx) + if err != nil { + return nil, err + } + err = commitComment.LoadPoster(ctx) + if err != nil { + return nil, err + } + return commitComment, err +} + +func GetCommitCommentBySHA(ctx context.Context, repoID int64, commitSHA string) (*CommitComment, error) { + commitComment := &CommitComment{ + RefRepoID: repoID, + CommitSHA: commitSHA, + } + has, err := db.GetEngine(ctx).Get(commitComment) + if err != nil { + return nil, err + } else if !has { + return nil, err + } + err = commitComment.LoadRepo(ctx) + if err != nil { + return nil, err + } + err = commitComment.LoadPoster(ctx) + if err != nil { + return nil, err + } + return commitComment, err +} + +// CreateCommitComment creates comment with context +func CreateCommitComment(ctx context.Context, opts *CreateCommitCommentOptions) (_ *CommitComment, err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + e := db.GetEngine(ctx) + reactions := make(ReactionMap) + jsonBytes, err := json.Marshal(reactions) + if err != nil { + return nil, err + } + + jsonString := string(jsonBytes) + + attachmentsJSONBytes, err := json.Marshal(opts.Attachments) + if err != nil { + return nil, err + } + + attachmentsJSON := string(attachmentsJSONBytes) + + commit := &CommitComment{ + PosterID: opts.Doer.ID, + Poster: opts.Doer, + CommitSHA: opts.CommitSHA, + FileName: opts.FileName, + Line: opts.LineNum, + Comment: opts.Comment, + Reactions: jsonString, + Attachments: attachmentsJSON, + RefRepoID: opts.RefRepoID, + ReplyToCommentID: opts.ReplyToCommentID, + } + if _, err = e.Insert(commit); err != nil { + return nil, err + } + + if err = committer.Commit(); err != nil { + return nil, err + } + return commit, nil +} + +func UpdateCommitComment(ctx context.Context, attachmentMap *AttachmentMap, commitComment *CommitComment) (err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + attachmentsJSONBytes, err := json.Marshal(attachmentMap) + if err != nil { + return err + } + + attachmentsJSON := string(attachmentsJSONBytes) + + commit := &CommitComment{ + PosterID: commitComment.PosterID, + Poster: commitComment.Poster, + CommitSHA: commitComment.CommitSHA, + FileName: commitComment.FileName, + Line: commitComment.Line, + Comment: commitComment.Comment, + ContentVersion: commitComment.ContentVersion, + Attachments: attachmentsJSON, + RefRepoID: commitComment.RefRepoID, + } + + sess := db.GetEngine(ctx) + _, err = sess.ID(commitComment.ID).Where("commit_sha = ?", commitComment.CommitSHA).Update(commit) + if err != nil { + return err + } + err = committer.Commit() + return err +} + +// DeleteComment deletes the comment +func DeleteCommitComment(ctx context.Context, commitComment *CommitComment) error { + e := db.GetEngine(ctx) + if _, err := e.ID(commitComment.ID).NoAutoCondition().Delete(commitComment); err != nil { + return err + } + return nil +} + +func FindCommitCommentsByCommit(ctx context.Context, opts *FindCommitCommentOptions, commitComment *CommitComment) (CommitCommentList, error) { + var commitCommentList CommitCommentList + sess := db.GetEngine(ctx).Where(opts.ToConds()) + + if opts.CommitSHA == "" { + return nil, nil + } + + if opts.Page > 0 { + sess = db.SetSessionPagination(sess, opts) + } + + err := sess.Table(&CommitComment{}).Where(opts.ToConds()).Find(&commitCommentList) + if err != nil { + return nil, err + } + err = commitComment.LoadRepo(ctx) + if err != nil { + return nil, err + } + err = commitComment.LoadPoster(ctx) + if err != nil { + return nil, err + } + for _, cd := range commitCommentList { + var err error + rctx := renderhelper.NewRenderContextRepoComment(ctx, commitComment.Repo, renderhelper.RepoCommentOptions{ + FootnoteContextID: strconv.FormatInt(commitComment.ID, 10), + }) + + if cd.RenderedComment, err = markdown.RenderString(rctx, cd.Comment); err != nil { + return nil, err + } + cd.Repo = commitComment.Repo + cd.Poster = commitComment.Poster + } + return commitCommentList, nil +} + +func FindCommitCommentsByLine(ctx context.Context, opts *FindCommitCommentOptions, commitComment *CommitComment) (CommitCommentList, error) { + var commitCommentList CommitCommentList + + sess := db.GetEngine(ctx) + + err := sess.Table(&CommitComment{}).Where("commit_sha=? AND line=? ", opts.CommitSHA, opts.Line).Find(&commitCommentList) + if err != nil { + return nil, err + } + err = commitComment.LoadRepo(ctx) + if err != nil { + return nil, err + } + err = commitComment.LoadPoster(ctx) + if err != nil { + return nil, err + } + for _, cd := range commitCommentList { + var err error + rctx := renderhelper.NewRenderContextRepoComment(ctx, commitComment.Repo, renderhelper.RepoCommentOptions{ + FootnoteContextID: strconv.FormatInt(commitComment.ID, 10), + }) + + if cd.RenderedComment, err = markdown.RenderString(rctx, cd.Comment); err != nil { + return nil, err + } + cd.Repo = commitComment.Repo + cd.Poster = commitComment.Poster + } + return commitCommentList, nil +} + +func CreateCommitCommentReaction(ctx context.Context, reaction string, userID int64, commitComment *CommitComment) error { + if !setting.UI.ReactionsLookup.Contains(reaction) { + return nil + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + + reactions := make(ReactionMap) + + err = json.Unmarshal([]byte(commitComment.Reactions), &reactions) + if err != nil { + return err + } + + reactions[reaction] = append(reactions[reaction], strconv.FormatInt(userID, 10)) + + jsonBytes, err := json.Marshal(reactions) + if err != nil { + return err + } + + jsonString := string(jsonBytes) + + commitComment.Reactions = jsonString + sess := db.GetEngine(ctx) + _, err = sess.ID(commitComment.ID).Where("commit_sha = ?", commitComment.CommitSHA).Update(commitComment) + if err != nil { + return err + } + + err = committer.Commit() + if err != nil { + return err + } + + return nil +} + +func DeleteCommentReaction(ctx context.Context, reaction string, userID int64, commitComment *CommitComment) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + + reactions := make(ReactionMap) + + err = json.Unmarshal([]byte(commitComment.Reactions), &reactions) + if err != nil { + return err + } + + list := reactions[reaction] + userid := strconv.FormatInt(userID, 10) + idx := slices.Index(list, userid) + reactions[reaction] = slices.Delete(list, idx, idx+1) + + for reactionType, userIDs := range reactions { + if len(userIDs) == 0 { + delete(reactions, reactionType) // Delete the key with an empty slice + } + } + + jsonBytes, err := json.Marshal(reactions) + if err != nil { + return err + } + + jsonString := string(jsonBytes) + + commitComment.Reactions = jsonString + sess := db.GetEngine(ctx) + _, err = sess.ID(commitComment.ID).Where("commit_sha = ?", commitComment.CommitSHA).Update(commitComment) + if err != nil { + return err + } + + err = committer.Commit() + if err != nil { + return err + } + + return nil +} + +func SaveTemporaryAttachment(ctx context.Context, file io.Reader, opts *AttachmentOptions) (string, error) { + attachmentUUID := uuid.New().String() + _, err := storage.Attachments.Save(attachmentUUID, file, opts.Size) + return attachmentUUID, err +} + +func UploadCommitAttachment(ctx context.Context, file io.Reader, commitComment *CommitComment, opts *AttachmentOptions) error { + attachmentUUID := uuid.New().String() + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + + attachment := make(AttachmentMap) + attachment[attachmentUUID] = opts + + jsonBytes, err := json.Marshal(attachment) + if err != nil { + return err + } + jsonString := string(jsonBytes) + commitComment.Attachments = jsonString + + sess := db.GetEngine(ctx) + _, err = sess.ID(commitComment.ID).Where("commit_sha = ?", commitComment.CommitSHA).Update(commitComment) + if err != nil { + return err + } + + err = committer.Commit() + if err != nil { + return err + } + return err +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 0c60abcecd07f..6aad78b44d4f7 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -8,14 +8,17 @@ import ( "errors" "fmt" "html/template" + "maps" "net/http" "path" + "strconv" "strings" asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" "code.gitea.io/gitea/models/renderhelper" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" @@ -25,24 +28,35 @@ import ( "code.gitea.io/gitea/modules/fileicon" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/json" "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/storage" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" 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 ( - tplCommits templates.TplName = "repo/commits" - tplGraph templates.TplName = "repo/graph" - tplGraphDiv templates.TplName = "repo/graph/div" - tplCommitPage templates.TplName = "repo/commit_page" + tplCommits templates.TplName = "repo/commits" + tplGraph templates.TplName = "repo/graph" + tplGraphDiv templates.TplName = "repo/graph/div" + tplCommitPage templates.TplName = "repo/commit_page" + tplCommitConversation templates.TplName = "repo/diff/commit_conversation" + tplCommitCommentReactions templates.TplName = "repo/issue/view_content/commit_reactions" + tplCommitAttachment templates.TplName = "repo/issue/view_content/commit_attachments" ) // RefCommits render commits page @@ -345,6 +359,7 @@ func Diff(ctx *context.Context) { ctx.Data["AfterCommitID"] = commitID ctx.Data["Username"] = userName ctx.Data["Reponame"] = repoName + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled var parentCommit *git.Commit var parentCommitID string @@ -417,6 +432,24 @@ func Diff(ctx *context.Context) { ctx.Data["MergedPRIssueNumber"] = pr.Index } + commitComment, err := git_model.GetCommitCommentBySHA(ctx, ctx.Repo.Repository.ID, commitID) + if err != nil { + ctx.ServerError("GetCommitCommentBySHA", err) + return + } + + if commitComment != nil { + err := git_service.LoadCommitComments(ctx, diff, commitComment, ctx.Doer) + if err != nil { + ctx.ServerError("LoadCommitComments", err) + return + } + } + upload.AddUploadContext(ctx, "comment") + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + ctx.HTML(http.StatusOK, tplCommitPage) } @@ -469,3 +502,455 @@ func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_m } return commits, nil } + +func RenderCommitCommentForm(ctx *context.Context) { + CommitSHA := ctx.PathParam("sha") + + ctx.Data["PageIsDiff"] = true + ctx.Data["CommitSHA"] = CommitSHA + ctx.Data["AfterCommitID"] = CommitSHA + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + ctx.HTML(http.StatusOK, tplNewComment) +} + +func CreateCommitComment(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CodeCommentForm) + if ctx.Written() { + return + } + + signedLine := form.Line + if form.Side == "previous" { + signedLine *= -1 + } + replyid := form.Reply + attachmentsMap := make(git_model.AttachmentMap) + + if ctx.Session.Get("attachmentsMaps") != nil { + attachmentsMap = ctx.Session.Get("attachmentsMaps").(git_model.AttachmentMap) + } + + opts := git_model.CreateCommitCommentOptions{ + RefRepoID: ctx.Repo.Repository.ID, + Repo: ctx.Repo.Repository, + Doer: ctx.Doer, + Comment: form.Content, + FileName: form.TreePath, + LineNum: signedLine, + ReplyToCommentID: replyid, + CommitSHA: form.LatestCommitID, + Attachments: attachmentsMap, + } + commitComment, err := git_service.CreateCommitComment(ctx, + ctx.Doer, + ctx.Repo.GitRepo, + opts, + ) + if err != nil { + ctx.ServerError("CreateCommitComment", err) + return + } + if ctx.Session.Get("attachmentsMaps") != nil { + err = ctx.Session.Delete("attachmentsMaps") + if err != nil { + ctx.ServerError("CreateCommitComment", err) + return + } + } + renderCommitComment(ctx, commitComment, form.Origin, signedLine) +} + +func renderCommitComment(ctx *context.Context, commitComment *git_model.CommitComment, origin string, signedLine int64) { + ctx.Data["PageIsDiff"] = true + + opts := git_model.FindCommitCommentOptions{ + CommitSHA: commitComment.CommitSHA, + Line: signedLine, + } + comments, err := git_model.FindCommitCommentsByLine(ctx, &opts, commitComment) + if err != nil { + ctx.ServerError("FetchCodeCommentsByLine", err) + return + } + + if len(comments) == 0 { + ctx.HTML(http.StatusOK, tplConversationOutdated) + return + } + + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + + upload.AddUploadContext(ctx, "comment") + ctx.Data["comments"] = comments + ctx.Data["AfterCommitID"] = commitComment.CommitSHA + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + + switch origin { + case "diff": + ctx.HTML(http.StatusOK, tplCommitConversation) + case "timeline": + ctx.HTML(http.StatusOK, tplTimelineConversation) + default: + ctx.HTTPError(http.StatusBadRequest, "Unknown origin: "+origin) + } +} + +// DeleteCommitComment delete comment of commit +func DeleteCommitComment(ctx *context.Context) { + commitComment, err := git_model.GetCommitCommentByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + return + } + if !ctx.IsSigned || (ctx.Doer.ID != commitComment.PosterID) { + ctx.HTTPError(http.StatusForbidden) + return + } + + if err = git_model.DeleteCommitComment(ctx, commitComment); err != nil { + ctx.ServerError("DeleteCommitComment", err) + return + } + + ctx.Status(http.StatusOK) +} + +func UpdateCommitComment(ctx *context.Context) { + commitComment, err := git_model.GetCommitCommentByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + ctx.ServerError("GetCommitCommentByID", err) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != commitComment.PosterID) { + ctx.HTTPError(http.StatusForbidden) + return + } + + newContent := ctx.FormString("content") + + if newContent != commitComment.Comment { + commitComment.Comment = newContent + commitComment.ContentVersion++ + } + files := ctx.FormStrings("files[]") + filesMap := make(map[string]struct{}) + for _, key := range files { + filesMap[key] = struct{}{} + } + + uploadedAttachments := make(git_model.AttachmentMap) + if ctx.Session.Get("attachmentsMaps") != nil { + uploadedAttachments = ctx.Session.Get("attachmentsMaps").(git_model.AttachmentMap) + } + + attachmentMap := make(git_model.AttachmentMap) + err = json.Unmarshal([]byte(commitComment.Attachments), &attachmentMap) + if err != nil { + ctx.ServerError("UpdateCommitComment", err) + return + } + + // handle removed files + for key := range attachmentMap { + if _, exists := filesMap[key]; !exists { + delete(attachmentMap, key) + } + } + maps.Copy(attachmentMap, uploadedAttachments) + err = git_model.UpdateCommitComment(ctx, &attachmentMap, commitComment) + if err != nil { + ctx.ServerError("UpdateCommitComment", err) + return + } + + var renderedContent template.HTML + if commitComment.Comment != "" { + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{ + FootnoteContextID: strconv.FormatInt(commitComment.ID, 10), + }) + renderedContent, err = markdown.RenderString(rctx, commitComment.Comment) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + } else { + contentEmpty := fmt.Sprintf(`%s`, ctx.Tr("repo.issues.no_content")) + renderedContent = template.HTML(contentEmpty) + } + + attachHTML, err := ctx.RenderToHTML(tplCommitAttachment, map[string]any{ + "Attachments": attachmentMap, + "CommitComment": commitComment, + }) + if err != nil { + ctx.ServerError("attachmentsHTML.HTMLString", err) + return + } + + if ctx.Session.Get("attachmentsMaps") != nil { + err = ctx.Session.Delete("attachmentsMaps") + if err != nil { + ctx.ServerError("UpdateCommitComment", err) + return + } + } + upload.AddUploadContext(ctx, "comment") + ctx.JSON(http.StatusOK, map[string]any{ + "content": renderedContent, + "contentVersion": commitComment.ContentVersion, + "attachments": attachHTML, + }) +} + +func CancelCommitComment(ctx *context.Context) { + if ctx.Session.Get("attachmentsMaps") != nil { + err := ctx.Session.Delete("attachmentsMaps") + if err != nil { + ctx.ServerError("CancelCommitComment", err) + return + } + } +} + +func ChangeCommitCommentReaction(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ReactionForm) + commitComment, err := git_model.GetCommitCommentByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + ctx.ServerError("GetCommitCommentByID", err) + return + } + + action := ctx.PathParam("action") + + if !ctx.IsSigned || (ctx.Doer.ID != commitComment.PosterID) { + if log.IsTrace() { + if ctx.IsSigned { + log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+ + "User in Repo has Permissions: %-+v", + ctx.Doer, + commitComment.PosterID, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + + ctx.HTTPError(http.StatusForbidden) + return + } + + switch action { + case "react": + err := git_service.CreateCommentReaction(ctx, ctx.Doer, commitComment, form.Content) + if err != nil { + log.Info("CreateCommentReaction: %s", err) + break + } + + case "unreact": + if err := git_service.DeleteCommentReaction(ctx, ctx.Doer, commitComment, form.Content); err != nil { + ctx.ServerError("DeleteCommentReaction", err) + return + } + + default: + ctx.NotFound(nil) + return + } + + if len(commitComment.Reactions) == 0 { + ctx.JSON(http.StatusOK, map[string]any{ + "empty": true, + "html": "", + }) + return + } + reactions, _ := commitComment.GroupReactionsByType() + html, err := ctx.RenderToHTML(tplCommitCommentReactions, map[string]any{ + "ActionURL": fmt.Sprintf("%s/commit/%s/comments/%d/reactions", ctx.Repo.RepoLink, commitComment.CommitSHA, commitComment.ID), + "Reactions": reactions, + "CommitComment": commitComment, + }) + if err != nil { + ctx.ServerError("ChangeCommentReaction.HTMLString", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "html": html, + }) +} + +func UploadCommitAttachment(ctx *context.Context) { + if !ctx.IsSigned { + ctx.HTTPError(http.StatusForbidden) + return + } + + if !setting.Attachment.Enabled { + ctx.HTTPError(http.StatusNotFound, "attachment is not enabled") + return + } + + file, header, err := ctx.Req.FormFile("file") + if err != nil { + ctx.HTTPError(http.StatusInternalServerError, fmt.Sprintf("FormFile: %v", err)) + return + } + + validExt := strings.Contains(setting.Attachment.AllowedTypes, path.Ext(header.Filename)) + if !validExt { + ctx.HTTPError(http.StatusNotFound, "attachment type not valid") + return + } + ctx.Data["PageIsDiff"] = true + + opts := git_model.AttachmentOptions{ + FileName: header.Filename, + Size: header.Size, + UploaderID: ctx.Doer.ID, + } + + attachmentUUID, err := git_model.SaveTemporaryAttachment(ctx, file, &opts) + if err != nil { + ctx.ServerError("SaveTemporaryAttachment", err) + return + } + + attachmentsMap := ctx.Session.Get("attachmentsMaps") + if attachmentsMap != nil { + attachmentsMap := ctx.Session.Get("attachmentsMaps").(git_model.AttachmentMap) + attachmentsMap[attachmentUUID] = &opts + err = ctx.Session.Set("attachmentsMaps", attachmentsMap) + if err != nil { + ctx.ServerError("UploadCommitAttachment", err) + return + } + } else { + attachmentsMap := make(git_model.AttachmentMap) + attachmentsMap[attachmentUUID] = &opts + err = ctx.Session.Set("attachmentsMaps", attachmentsMap) + if err != nil { + ctx.ServerError("UploadCommitAttachment", err) + return + } + } + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + ctx.JSON(http.StatusOK, map[string]string{ + "uuid": attachmentUUID, + }) +} + +func DeleteCommitAttachment(ctx *context.Context) { + uuid := ctx.FormString("file") + + err := storage.Attachments.Delete(uuid) + if err != nil { + ctx.ServerError("delete ", err) + return + } + + if ctx.Session.Get("attachmentsMaps") != nil { + attachmentsMap := ctx.Session.Get("attachmentsMaps").(git_model.AttachmentMap) + delete(attachmentsMap, uuid) + err = ctx.Session.Set("attachmentsMaps", attachmentsMap) + if err != nil { + ctx.ServerError("UploadCommitAttachment", err) + return + } + } +} + +func GetCommitAttachmentByUUID(ctx *context.Context) { + commitComment, err := git_model.GetCommitCommentByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + return + } + + repository, err := repo_model.GetRepositoryByID(ctx, commitComment.RefRepoID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + + attachmentMap := make(git_model.AttachmentMap) + err = json.Unmarshal([]byte(commitComment.Attachments), &attachmentMap) + if err != nil { + ctx.ServerError("GetCommitAttachmentByUUID", err) + return + } + uuid := ctx.PathParam("uuid") + + attachment := attachmentMap[uuid] + if attachment == nil { + ctx.HTTPError(http.StatusNotFound) + return + } + if repository == nil { + if !(ctx.IsSigned && attachment.UploaderID == ctx.Doer.ID) { + ctx.HTTPError(http.StatusNotFound) + return + } + } else { + _, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer) + if err != nil { + ctx.HTTPError(http.StatusInternalServerError, "GetUserRepoPermission", err.Error()) + return + } + } + + fr, err := storage.Attachments.Open(uuid) + if err != nil { + ctx.ServerError("Open", err) + return + } + defer fr.Close() + + common.ServeContentByReadSeeker(ctx.Base, attachment.FileName, util.ToPointer(commitComment.CreatedUnix.AsTime()), fr) +} + +func GetCommitAttachments(ctx *context.Context) { + commitComment, err := git_model.GetCommitCommentByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + return + } + + repository, err := repo_model.GetRepositoryByID(ctx, commitComment.RefRepoID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + if repository == nil { + if !(ctx.IsSigned) { + ctx.HTTPError(http.StatusNotFound) + return + } + } + + type AttachmentItem struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Size int64 `json:"size"` + } + + attachmentMap := make(git_model.AttachmentMap) + err = json.Unmarshal([]byte(commitComment.Attachments), &attachmentMap) + if err != nil { + ctx.ServerError("GetCommitAttachments", err) + return + } + + attachmentItems := []*AttachmentItem{} + for key, value := range attachmentMap { + item := &AttachmentItem{ + Name: value.FileName, Size: value.Size, UUID: key, + } + attachmentItems = append(attachmentItems, item) + } + + ctx.JSON(http.StatusOK, attachmentItems) +} diff --git a/routers/web/web.go b/routers/web/web.go index 09be0c39045e0..3492b088b43ac 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1551,6 +1551,26 @@ func registerWebRoutes(m *web.Router) { }, optSignIn, context.RepoAssignment, repo.MustAllowPulls, reqUnitPullsReader) // end "/{username}/{reponame}/pulls/{index}": repo pull request + m.Group("/{username}/{reponame}", func() { + m.Group("/commit/{sha:([a-f0-9]{7,64})$}", func() { + m.Get("", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) + m.Post("/attachments", repo.UploadCommitAttachment) + m.Get("/{id}/attachments/{uuid}", repo.GetCommitAttachmentByUUID) + m.Group("/comments", func() { + m.Get("/new_comment", repo.RenderCommitCommentForm) + m.Post("/cancel", repo.CancelCommitComment) + m.Post("/add", web.Bind(forms.CodeCommentForm{}), repo.SetShowOutdatedComments, repo.CreateCommitComment) + m.Post("/{id}/delete", repo.DeleteCommitComment) + m.Post("/{id}/update", repo.UpdateCommitComment) + m.Get("/{id}/attachments", repo.GetCommitAttachments) + m.Post("/attachments/remove", repo.DeleteCommitAttachment) + }, context.RepoMustNotBeArchived()) + m.Group("/comments/{id}", func() { + m.Post("/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeCommitCommentReaction) + }) + }) + }, optSignIn, context.RepoAssignment, repo.MustAllowPulls, reqUnitPullsReader) + m.Group("/{username}/{reponame}", func() { m.Group("/activity_author_data", func() { m.Get("", repo.ActivityAuthors) diff --git a/services/context/upload/upload.go b/services/context/upload/upload.go index 23707950d4a5c..dfabcad16014b 100644 --- a/services/context/upload/upload.go +++ b/services/context/upload/upload.go @@ -4,6 +4,7 @@ package upload import ( + "fmt" "mime" "net/http" "net/url" @@ -89,6 +90,9 @@ func Verify(buf []byte, fileName, allowedTypesStr string) error { // AddUploadContext renders template values for dropzone func AddUploadContext(ctx *context.Context, uploadType string) { + PageIsDiff := ctx.Data["PageIsDiff"] + ctx.Data["CommitSHA"] = ctx.PathParam("sha") + CommitSHA := ctx.PathParam("sha") switch uploadType { case "release": ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/releases/attachments" @@ -98,8 +102,14 @@ func AddUploadContext(ctx *context.Context, uploadType string) { ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize case "comment": - ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/issues/attachments" - ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/issues/attachments/remove" + if PageIsDiff == true { + ctx.Data["UploadUrl"] = fmt.Sprintf("%s/commit/%s/attachments", ctx.Repo.RepoLink, CommitSHA) + ctx.Data["UploadRemoveUrl"] = fmt.Sprintf("%s/commit/%s/comments/attachments/remove", ctx.Repo.RepoLink, CommitSHA) + } else { + ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/issues/attachments" + ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/issues/attachments/remove" + } + if len(ctx.PathParam("index")) > 0 { ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/issues/" + url.PathEscape(ctx.PathParam("index")) + "/attachments" } else { diff --git a/services/git/commit.go b/services/git/commit.go index e4755ef93d7b0..120c07a614561 100644 --- a/services/git/commit.go +++ b/services/git/commit.go @@ -5,6 +5,7 @@ package git import ( "context" + "sort" asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" @@ -14,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/gitdiff" ) // ParseCommitsWithSignature checks if signaute of commits are corresponding to users gpg keys. @@ -88,3 +90,65 @@ func ParseCommitsWithStatus(ctx context.Context, oldCommits []*asymkey_model.Sig } return newCommits, nil } + +func CreateCommitComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, opts git_model.CreateCommitCommentOptions) (*git_model.CommitComment, error) { + comment, err := git_model.CreateCommitComment(ctx, &opts) + if err != nil { + return nil, err + } + return comment, nil +} + +// LoadComments loads comments into each line +func LoadCommitComments(ctx context.Context, diff *gitdiff.Diff, commitComment *git_model.CommitComment, currentUser *user_model.User) error { + opts := git_model.FindCommitCommentOptions{ + CommitSHA: commitComment.CommitSHA, + } + + commitComments, err := git_model.FindCommitCommentsByCommit(ctx, &opts, commitComment) + if err != nil { + return err + } + + for _, file := range diff.Files { + for _, cc := range commitComments { + if cc.FileName == file.Name { + for _, section := range file.Sections { + for _, line := range section.Lines { + if cc.Line == int64(line.LeftIdx*-1) { + line.CommitComments = append(line.CommitComments, cc) + cc.Repo = commitComment.Repo + cc.Poster = commitComment.Poster + } + if cc.Line == int64(line.RightIdx) { + line.CommitComments = append(line.CommitComments, cc) + cc.Repo = commitComment.Repo + cc.Poster = commitComment.Poster + } + sort.SliceStable(line.CommitComments, func(i, j int) bool { + return line.CommitComments[i].CreatedUnix < line.CommitComments[j].CreatedUnix + }) + } + } + } + } + } + return nil +} + +// CreateCommentReaction creates a reaction on a comment. +func CreateCommentReaction(ctx context.Context, doer *user_model.User, commitComment *git_model.CommitComment, reaction string) error { + err := git_model.CreateCommitCommentReaction(ctx, reaction, doer.ID, commitComment) + if err != nil { + return err + } + return nil +} + +func DeleteCommentReaction(ctx context.Context, doer *user_model.User, commitComment *git_model.CommitComment, reaction string) error { + err := git_model.DeleteCommentReaction(ctx, reaction, doer.ID, commitComment) + if err != nil { + return err + } + return nil +} diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go index 7c99e049d548a..c16c06c2b56f9 100644 --- a/services/gitdiff/gitdiff.go +++ b/services/gitdiff/gitdiff.go @@ -77,13 +77,14 @@ const ( // DiffLine represents a line difference in a DiffSection. type DiffLine struct { - LeftIdx int // line number, 1-based - RightIdx int // line number, 1-based - Match int // the diff matched index. -1: no match. 0: plain and no need to match. >0: for add/del, "Lines" slice index of the other side - Type DiffLineType - Content string - Comments issues_model.CommentList // related PR code comments - SectionInfo *DiffLineSectionInfo + LeftIdx int // line number, 1-based + RightIdx int // line number, 1-based + Match int // the diff matched index. -1: no match. 0: plain and no need to match. >0: for add/del, "Lines" slice index of the other side + Type DiffLineType + Content string + Comments issues_model.CommentList // related PR code commzents + CommitComments git_model.CommitCommentList // related to commit comments + SectionInfo *DiffLineSectionInfo } // DiffLineSectionInfo represents diff line section meta data @@ -133,6 +134,11 @@ func (d *DiffLine) CanComment() bool { return len(d.Comments) == 0 && d.Type != DiffLineSection } +// CanCommitComment returns whether a line can get commented +func (d *DiffLine) CanCommitComment() bool { + return len(d.CommitComments) == 0 && d.Type != DiffLineSection +} + // GetCommentSide returns the comment side of the first comment, if not set returns empty string func (d *DiffLine) GetCommentSide() string { if len(d.Comments) == 0 { @@ -141,6 +147,14 @@ func (d *DiffLine) GetCommentSide() string { return d.Comments[0].DiffSide() } +// GetCommentSide returns the comment side of the first comment, if not set returns empty string +func (d *DiffLine) GetCommitCommentSide() string { + if len(d.CommitComments) == 0 { + return "" + } + return d.CommitComments[0].DiffSide() +} + // GetLineTypeMarker returns the line type marker func (d *DiffLine) GetLineTypeMarker() string { if strings.IndexByte(" +-", d.Content[0]) > -1 { diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index e4d1efac57bd9..8ad91e485a420 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 $.PageIsDiff}} + {{$newCommentUrl = printf "%s/commit/%s/comments/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 58b675467c035..fa511d09f071e 100644 --- a/templates/repo/diff/comment_form.tmpl +++ b/templates/repo/diff/comment_form.tmpl @@ -1,7 +1,18 @@ {{if and $.root.SignedUserID (not $.Repository.IsArchived)}} - + {{- $action := "" -}} + {{- $origin := "" -}} + {{if $.root.PageIsPullFiles}} + {{$action = printf "%s/files/reviews/comments" $.root.Issue.Link -}} + {{$origin = "diff"}} + {{else if $.root.PageIsDiff}} + {{$action = printf "%s/commit/%s/comments/add" $.root.RepoLink $.root.CommitSHA -}} + {{$origin = "diff"}} + {{else}} + {{$origin = "timeline"}} + {{end}} + {{$.root.CsrfTokenHtml}} - + @@ -36,12 +47,16 @@ {{if $.root.CurrentReview}} {{else}} - - + {{if $.root.PageIsDiff}} + + {{else}} + + + {{end}} {{end}} {{end}} {{if or (not $.HasComments) $.hidden}} - + {{end}} diff --git a/templates/repo/diff/commit_comments.tmpl b/templates/repo/diff/commit_comments.tmpl new file mode 100644 index 0000000000000..fc9b2b4b7efac --- /dev/null +++ b/templates/repo/diff/commit_comments.tmpl @@ -0,0 +1,43 @@ +{{range .comments}} +{{$createdStr:= DateUtils.TimeSince .CreatedUnix}} +
+
+ {{template "shared/user/avatarlink" dict "user" .Poster}} +
+
+
+
+ + {{template "shared/user/namelink" .Poster}} + {{ctx.Locale.Tr "repo.issues.commented_at" .HashTag $createdStr}} + +
+
+ {{if not $.root.Repository.IsArchived}} + {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/commit/%s/comments/%d/reactions" $.root.RepoLink .CommitSHA .ID)}} + {{end}} + {{template "repo/issue/view_content/context_menu" dict "item" . "delete" true "issue" false "diff" true "IsCommentPoster" (and $.root.IsSigned (eq $.root.SignedUserID .PosterID))}} +
+
+
+
+ {{if .RenderedComment}} + {{.RenderedComment}} + {{else}} + {{ctx.Locale.Tr "repo.issues.no_content"}} + {{end}} +
+
{{.Comment}}
+
+ {{$attachments := .GroupAttachmentsByUUID}} + {{if $attachments}} + {{template "repo/issue/view_content/commit_attachments" dict "CommitComment" . "Attachments" $attachments "RenderedContent" .RenderedComment}} + {{end}} +
+ {{$reactions := .GroupReactionsByType}} + {{if $reactions}} + {{template "repo/issue/view_content/commit_reactions" dict "CommitComment" . "ActionURL" (printf "%s/commit/%s/comments/%d/reactions" $.root.RepoLink .CommitSHA .ID) "Reactions" $reactions}} + {{end}} +
+
+{{end}} diff --git a/templates/repo/diff/commit_conversation.tmpl b/templates/repo/diff/commit_conversation.tmpl new file mode 100644 index 0000000000000..3026e4ff7345c --- /dev/null +++ b/templates/repo/diff/commit_conversation.tmpl @@ -0,0 +1,30 @@ +{{if len .comments}} + {{$comment := index .comments 0}} +
+
+
+
+ {{template "repo/diff/commit_comments" dict "root" $ "comments" .comments}} +
+
+
+
+ + +
+ {{if and $.SignedUserID (not $.Repository.IsArchived)}} + + {{end}} +
+ {{template "repo/diff/comment_form_datahandler" dict "hidden" true "reply" $comment.ID "root" $ "comment" $comment}} +
+
+{{else}} + {{template "repo/diff/conversation_outdated"}} +{{end}} diff --git a/templates/repo/diff/section_split.tmpl b/templates/repo/diff/section_split.tmpl index 9953db5eb234c..36b8330f2e3f6 100644 --- a/templates/repo/diff/section_split.tmpl +++ b/templates/repo/diff/section_split.tmpl @@ -47,7 +47,12 @@ + {{else if $line.CommitComments}} + + + + {{end}} {{end}} {{end}} diff --git a/templates/repo/diff/section_unified.tmpl b/templates/repo/diff/section_unified.tmpl index cb612bc27c4e8..f3bab63300020 100644 --- a/templates/repo/diff/section_unified.tmpl +++ b/templates/repo/diff/section_unified.tmpl @@ -54,7 +54,12 @@ {{else}} {{end}} - {{if $line.Comments}} - - - + {{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} + {{if $line.Comments}} + + + + {{end}} + {{end}} + {{- if and $.root.SignedUserID $.root.PageIsDiff -}} + {{if $line.CommitComments}} + + + + {{end}} {{end}} {{end}} {{end}} diff --git a/templates/repo/issue/view_content/commit_attachments.tmpl b/templates/repo/issue/view_content/commit_attachments.tmpl new file mode 100644 index 0000000000000..b43aaaf8f3c9e --- /dev/null +++ b/templates/repo/issue/view_content/commit_attachments.tmpl @@ -0,0 +1,43 @@ +
+ {{if .Attachments}} +
+ {{end}} + {{$hasThumbnails := false}} + {{- $commitComment := .CommitComment -}} + {{- range $key, $value := .Attachments -}} +
+ +
+ {{$value.Size | FileSize}} +
+
+ {{end -}} + + {{if $hasThumbnails}} +
+
+ {{- range $key, $value := .Attachments -}} + {{if FilenameIsImage $value.FileName}} + {{if not (StringUtils.Contains (StringUtils.ToString $.RenderedContent) $key)}} + + {{$value.FileName}} + + {{end}} + {{end}} + {{end -}} +
+ {{end}} + +
diff --git a/templates/repo/issue/view_content/commit_reactions.tmpl b/templates/repo/issue/view_content/commit_reactions.tmpl new file mode 100644 index 0000000000000..9b3f65c706a56 --- /dev/null +++ b/templates/repo/issue/view_content/commit_reactions.tmpl @@ -0,0 +1,18 @@ +{{- $commitComment := .CommitComment -}} +
+{{range $key, $value := .Reactions}} + {{$hasReacted := $commitComment.HasUser $key ctx.RootData.SignedUserID}} + + {{ReactionToEmoji $key}} + {{len $value}} + +{{end}} +{{if AllowedReactions}} + {{template "repo/issue/view_content/add_reaction" dict "ActionURL" .ActionURL}} +{{end}} +
diff --git a/templates/repo/issue/view_content/context_menu.tmpl b/templates/repo/issue/view_content/context_menu.tmpl index 749a2fa0ddc0f..c3fe612e80ebd 100644 --- a/templates/repo/issue/view_content/context_menu.tmpl +++ b/templates/repo/issue/view_content/context_menu.tmpl @@ -9,6 +9,9 @@ {{else}} {{$referenceUrl = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}} {{end}} + {{if ctx.RootData.PageIsDiff}} + {{$referenceUrl = printf "%s/commit/%s#%s" ctx.RootData.RepoLink .item.CommitSHA .item.HashTag}} + {{end}}
{{ctx.Locale.Tr "repo.issues.context.copy_link"}}
{{if ctx.RootData.IsSigned}} {{$needDivider := false}} @@ -20,9 +23,12 @@ {{end}} {{if or ctx.RootData.Permission.IsAdmin .IsCommentPoster ctx.RootData.HasIssuesOrPullsWritePermission}}
-
{{ctx.Locale.Tr "repo.issues.context.edit"}}
- {{if .delete}} -
{{ctx.Locale.Tr "repo.issues.context.delete"}}
+
{{ctx.Locale.Tr "repo.issues.context.edit"}}
+ {{if and .delete ctx.RootData.PageIsPullFiles}} +
{{ctx.Locale.Tr "repo.issues.context.delete"}}
+ {{end}} + {{if and .delete ctx.RootData.PageIsDiff}} +
{{ctx.Locale.Tr "repo.issues.context.delete"}}
{{end}} {{end}} {{end}} diff --git a/web_src/js/features/repo-issue-edit.ts b/web_src/js/features/repo-issue-edit.ts index e89e5a787a503..1b1692d3173cc 100644 --- a/web_src/js/features/repo-issue-edit.ts +++ b/web_src/js/features/repo-issue-edit.ts @@ -7,6 +7,7 @@ import {attachRefIssueContextPopup} from './contextpopup.ts'; import {triggerUploadStateChanged} from './comp/EditorUpload.ts'; import {convertHtmlToMarkdown} from '../markup/html2markdown.ts'; import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts'; +import {registerGlobalEventFunc} from '../modules/observer.ts'; async function tryOnEditContent(e: DOMEvent) { const clickTarget = e.target.closest('.edit-content'); @@ -153,7 +154,7 @@ async function tryOnQuoteReply(e: Event) { } export function initRepoIssueCommentEdit() { - document.addEventListener('click', (e) => { + registerGlobalEventFunc('click', 'onEditCodeCommentButtonClick', async (_el: HTMLElement, e: DOMEvent) => { tryOnEditContent(e); // Edit issue or comment content tryOnQuoteReply(e); // Quote reply to the comment editor }); diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts index b330b4869ba8f..21b08595b232e 100644 --- a/web_src/js/features/repo-issue.ts +++ b/web_src/js/features/repo-issue.ts @@ -17,7 +17,7 @@ import {showErrorToast} from '../modules/toast.ts'; import {initRepoIssueSidebar} from './repo-issue-sidebar.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; -import {registerGlobalInitFunc} from '../modules/observer.ts'; +import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts'; const {appSubUrl} = window.config; @@ -127,7 +127,7 @@ export function initRepoIssueFilterItemLabel() { export function initRepoIssueCommentDelete() { // Delete comment - document.addEventListener('click', async (e: DOMEvent) => { + registerGlobalEventFunc('click', 'onDeleteCodeCommentButtonClick', async (_el: HTMLElement, e: DOMEvent) => { if (!e.target.matches('.delete-comment')) return; e.preventDefault(); @@ -184,7 +184,7 @@ export function initRepoIssueCommentDelete() { export function initRepoIssueCodeCommentCancel() { // Cancel inline code comment - document.addEventListener('click', (e: DOMEvent) => { + registerGlobalEventFunc('click', 'onCancelCodeCommentButtonClick', async (_el: HTMLElement, e: DOMEvent) => { if (!e.target.matches('.cancel-code-comment')) return; const form = e.target.closest('form'); @@ -194,6 +194,11 @@ export function initRepoIssueCodeCommentCancel() { } else { form.closest('.comment-code-cloud')?.remove(); } + const cancelButton = e.target; + if (cancelButton.getAttribute('data-action-url')) { + const response = await POST(cancelButton.getAttribute('data-action-url')); + if (!response.ok) throw new Error('Failed to cancel comment'); + } }); } @@ -255,6 +260,49 @@ export async function handleReply(el: HTMLElement) { return editor; } +export function initRepoAddCommentButton() { + registerGlobalEventFunc('click', 'onAddCodeCommentButtonClick', async (el: HTMLElement, e: DOMEvent) => { + // addDelegatedEventListener(document, 'click', '.add-code-comment', async (el, e) => { + e.preventDefault(); + + const isSplit = el.closest('.code-diff')?.classList.contains('code-diff-split'); + const side = el.getAttribute('data-side'); + const idx = el.getAttribute('data-idx'); + const path = el.closest('[data-path]')?.getAttribute('data-path'); + const tr = el.closest('tr'); + const lineType = tr.getAttribute('data-line-type'); + + let ntr = tr.nextElementSibling; + if (!ntr?.classList.contains('add-comment')) { + ntr = createElementFromHTML(` + + ${isSplit ? ` + + + ` : ` + + `} + `); + tr.after(ntr); + } + 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')); + if (!response.ok) { + showErrorToast(`Failed to create comment`); + return; + } + td.innerHTML = await response.text(); + td.querySelector("input[name='line']").value = idx; + td.querySelector("input[name='side']").value = (side === 'left' ? 'previous' : 'proposed'); + td.querySelector("input[name='path']").value = path; + const editor = await initComboMarkdownEditor(td.querySelector('.combo-markdown-editor')); + editor.focus(); + } + }); +} + export function initRepoPullRequestReview() { if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) { const commentDiv = document.querySelector(window.location.hash); @@ -319,42 +367,6 @@ export function initRepoPullRequestReview() { }); elReviewPanel.querySelector('.close').addEventListener('click', () => tippy.hide()); } - - addDelegatedEventListener(document, 'click', '.add-code-comment', async (el, e) => { - e.preventDefault(); - - const isSplit = el.closest('.code-diff')?.classList.contains('code-diff-split'); - const side = el.getAttribute('data-side'); - const idx = el.getAttribute('data-idx'); - const path = el.closest('[data-path]')?.getAttribute('data-path'); - const tr = el.closest('tr'); - const lineType = tr.getAttribute('data-line-type'); - - let ntr = tr.nextElementSibling; - if (!ntr?.classList.contains('add-comment')) { - ntr = createElementFromHTML(` - - ${isSplit ? ` - - - ` : ` - - `} - `); - tr.after(ntr); - } - 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')); - td.innerHTML = await response.text(); - td.querySelector("input[name='line']").value = idx; - td.querySelector("input[name='side']").value = (side === 'left' ? 'previous' : 'proposed'); - td.querySelector("input[name='path']").value = path; - const editor = await initComboMarkdownEditor(td.querySelector('.combo-markdown-editor')); - editor.focus(); - } - }); } export function initRepoIssueReferenceIssue() { diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts index 770c7fc00c642..ab59b3aa3137b 100644 --- a/web_src/js/index-domready.ts +++ b/web_src/js/index-domready.ts @@ -22,7 +22,8 @@ import {initFindFileInRepo} from './features/repo-findfile.ts'; import {initMarkupContent} from './markup/content.ts'; import {initRepoFileView} from './features/file-view.ts'; import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; -import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts'; +import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel, initRepoAddCommentButton, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete} from './features/repo-issue.ts'; +import {initRepoIssueCommentEdit} from './features/repo-issue-edit.ts'; import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; import {initRepoTopicBar} from './features/repo-home.ts'; import {initAdminCommon} from './features/admin/common.ts'; @@ -37,6 +38,7 @@ import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/use import {initRepoRelease, initRepoReleaseNew} from './features/repo-release.ts'; import {initRepoEditor} from './features/repo-editor.ts'; import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts'; +import {initCompReactionSelector} from './features/comp/ReactionSelector.ts'; import {initInstall} from './features/install.ts'; import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts'; import {initRepoBranchButton} from './features/repo-branch.ts'; @@ -91,6 +93,7 @@ const initPerformanceTracer = callInitFunctions([ initCommonIssueListQuickGoto, initCompSearchUserBox, + initCompReactionSelector, initCompWebHookEditor, initInstall, @@ -132,6 +135,10 @@ const initPerformanceTracer = callInitFunctions([ initRepoIssueContentHistory, initRepoIssueList, initRepoIssueFilterItemLabel, + initRepoAddCommentButton, + initRepoIssueCodeCommentCancel, + initRepoIssueCommentDelete, + initRepoIssueCommentEdit, initRepoIssueSidebarDependency, initRepoMigration, initRepoMigrationStatusChecker,
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} - + {{- end -}} + {{- if and $.root.SignedUserID $.root.PageIsDiff -}} + {{- end -}} @@ -62,7 +67,12 @@ {{if $match.RightIdx}}{{end}} {{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} - + {{- end -}} + {{- if and $.root.SignedUserID $.root.PageIsDiff -}} + {{- end -}} @@ -79,7 +89,12 @@ {{if $line.LeftIdx}}{{end}} {{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 2)) -}} - + {{- end -}} + {{- if and $.root.SignedUserID $.root.PageIsDiff (not (eq .GetType 2)) -}} + {{- end -}} @@ -94,7 +109,12 @@ {{if $line.RightIdx}}{{end}} {{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 3)) -}} - + {{- end -}} + {{- if and $.root.SignedUserID $.root.PageIsDiff (not (eq .GetType 3)) -}} + {{- end -}} @@ -149,6 +169,19 @@ {{end}}
+ {{if eq $line.GetCommitCommentSide "previous"}} + {{template "repo/diff/commit_conversation" dict "." $.root "comments" $line.CommitComments}} + {{end}} + + {{if eq $line.GetCommitCommentSide "proposed"}} + {{template "repo/diff/commit_conversation" dict "." $.root "comments" $line.CommitComments}} + {{end}} +
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} - + {{- end -}} + {{- if and $.root.SignedUserID $.root.PageIsDiff -}} + {{- end -}} @@ -62,12 +67,23 @@
- {{template "repo/diff/conversation" dict "." $.root "comments" $line.Comments}} -
+ {{template "repo/diff/conversation" dict "." $.root "comments" $line.Comments}} +
+ {{template "repo/diff/commit_conversation" dict "." $.root "comments" $line.CommitComments}} +