diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 1a86a62fae..0383e4ca9e 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -276,20 +276,24 @@ func Diff(ctx *context.Context) { userName := ctx.Repo.Owner.Name repoName := ctx.Repo.Repository.Name commitID := ctx.PathParam("sha") - var ( - gitRepo *git.Repository - err error - ) + + diffBlobExcerptData := &gitdiff.DiffBlobExcerptData{ + BaseLink: ctx.Repo.RepoLink + "/blob_excerpt", + DiffStyle: ctx.FormString("style"), + AfterCommitID: commitID, + } + gitRepo := ctx.Repo.GitRepo + var gitRepoStore gitrepo.Repository = ctx.Repo.Repository if ctx.Data["PageIsWiki"] != nil { - gitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository.WikiStorageRepo()) + var err error + gitRepoStore = ctx.Repo.Repository.WikiStorageRepo() + gitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, gitRepoStore) if err != nil { ctx.ServerError("Repo.GitRepo.GetCommit", err) return } - defer gitRepo.Close() - } else { - gitRepo = ctx.Repo.GitRepo + diffBlobExcerptData.BaseLink = ctx.Repo.RepoLink + "/wiki/blob_excerpt" } commit, err := gitRepo.GetCommit(commitID) @@ -324,7 +328,7 @@ func Diff(ctx *context.Context) { ctx.NotFound(err) return } - diffShortStat, err := gitdiff.GetDiffShortStat(ctx, ctx.Repo.Repository, gitRepo, "", commitID) + diffShortStat, err := gitdiff.GetDiffShortStat(ctx, gitRepoStore, gitRepo, "", commitID) if err != nil { ctx.ServerError("GetDiffShortStat", err) return @@ -360,6 +364,7 @@ func Diff(ctx *context.Context) { ctx.Data["Title"] = commit.Summary() + " ยท " + base.ShortSha(commitID) ctx.Data["Commit"] = commit ctx.Data["Diff"] = diff + ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData if !fileOnly { diffTree, err := gitdiff.GetDiffTree(ctx, gitRepo, false, parentCommitID, commitID) diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 9262703078..08bd0a7e74 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" "path/filepath" + "sort" "strings" "code.gitea.io/gitea/models/db" @@ -43,6 +44,7 @@ import ( "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/gitdiff" pull_service "code.gitea.io/gitea/services/pull" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -638,6 +640,11 @@ func PrepareCompareDiff( } ctx.Data["DiffShortStat"] = diffShortStat ctx.Data["Diff"] = diff + ctx.Data["DiffBlobExcerptData"] = &gitdiff.DiffBlobExcerptData{ + BaseLink: ci.HeadRepo.Link() + "/blob_excerpt", + DiffStyle: ctx.FormString("style"), + AfterCommitID: headCommitID, + } ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 if !fileOnly { @@ -865,6 +872,28 @@ func CompareDiff(ctx *context.Context) { ctx.HTML(http.StatusOK, tplCompare) } +// attachCommentsToLines attaches comments to their corresponding diff lines +func attachCommentsToLines(section *gitdiff.DiffSection, lineComments map[int64][]*issues_model.Comment) { + for _, line := range section.Lines { + if comments, ok := lineComments[int64(line.LeftIdx*-1)]; ok { + line.Comments = append(line.Comments, comments...) + } + if comments, ok := lineComments[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 + }) + } +} + +// attachHiddenCommentIDs calculates and attaches hidden comment IDs to expand buttons +func attachHiddenCommentIDs(section *gitdiff.DiffSection, lineComments map[int64][]*issues_model.Comment) { + for _, line := range section.Lines { + gitdiff.FillHiddenCommentIDsForDiffLine(line, lineComments) + } +} + // ExcerptBlob render blob excerpt contents func ExcerptBlob(ctx *context.Context) { commitID := ctx.PathParam("sha") @@ -874,19 +903,26 @@ func ExcerptBlob(ctx *context.Context) { idxRight := ctx.FormInt("right") leftHunkSize := ctx.FormInt("left_hunk_size") rightHunkSize := ctx.FormInt("right_hunk_size") - anchor := ctx.FormString("anchor") direction := ctx.FormString("direction") filePath := ctx.FormString("path") gitRepo := ctx.Repo.GitRepo + + diffBlobExcerptData := &gitdiff.DiffBlobExcerptData{ + BaseLink: ctx.Repo.RepoLink + "/blob_excerpt", + DiffStyle: ctx.FormString("style"), + AfterCommitID: commitID, + } + if ctx.Data["PageIsWiki"] == true { var err error - gitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository.WikiStorageRepo()) + gitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository.WikiStorageRepo()) if err != nil { ctx.ServerError("OpenRepository", err) return } - defer gitRepo.Close() + diffBlobExcerptData.BaseLink = ctx.Repo.RepoLink + "/wiki/blob_excerpt" } + chunkSize := gitdiff.BlobExcerptChunkSize commit, err := gitRepo.GetCommit(commitID) if err != nil { @@ -947,10 +983,43 @@ func ExcerptBlob(ctx *context.Context) { section.Lines = append(section.Lines, lineSection) } } + + diffBlobExcerptData.PullIssueIndex = ctx.FormInt64("pull_issue_index") + if diffBlobExcerptData.PullIssueIndex > 0 { + if !ctx.Repo.CanRead(unit.TypePullRequests) { + ctx.NotFound(nil) + return + } + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, diffBlobExcerptData.PullIssueIndex) + if err != nil { + log.Error("GetIssueByIndex error: %v", err) + } else if issue.IsPull { + // FIXME: DIFF-CONVERSATION-DATA: the following data assignment is fragile + ctx.Data["Issue"] = issue + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + // and "diff/comment_form.tmpl" (reply comment) needs them + ctx.Data["PageIsPullFiles"] = true + ctx.Data["AfterCommitID"] = diffBlobExcerptData.AfterCommitID + + allComments, err := issues_model.FetchCodeComments(ctx, issue, ctx.Doer, ctx.FormBool("show_outdated")) + if err != nil { + log.Error("FetchCodeComments error: %v", err) + } else { + if lineComments, ok := allComments[filePath]; ok { + attachCommentsToLines(section, lineComments) + attachHiddenCommentIDs(section, lineComments) + } + } + } + } + ctx.Data["section"] = section ctx.Data["FileNameHash"] = git.HashFilePathForWebUI(filePath) - ctx.Data["AfterCommitID"] = commitID - ctx.Data["Anchor"] = anchor + ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData + ctx.HTML(http.StatusOK, tplBlobExcerpt) } diff --git a/routers/web/repo/compare_test.go b/routers/web/repo/compare_test.go new file mode 100644 index 0000000000..61472dc71e --- /dev/null +++ b/routers/web/repo/compare_test.go @@ -0,0 +1,40 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/services/gitdiff" + + "github.com/stretchr/testify/assert" +) + +func TestAttachCommentsToLines(t *testing.T) { + section := &gitdiff.DiffSection{ + Lines: []*gitdiff.DiffLine{ + {LeftIdx: 5, RightIdx: 10}, + {LeftIdx: 6, RightIdx: 11}, + }, + } + + lineComments := map[int64][]*issues_model.Comment{ + -5: {{ID: 100, CreatedUnix: 1000}}, // left side comment + 10: {{ID: 200, CreatedUnix: 2000}}, // right side comment + 11: {{ID: 300, CreatedUnix: 1500}, {ID: 301, CreatedUnix: 2500}}, // multiple comments + } + + attachCommentsToLines(section, lineComments) + + // First line should have left and right comments + assert.Len(t, section.Lines[0].Comments, 2) + assert.Equal(t, int64(100), section.Lines[0].Comments[0].ID) + assert.Equal(t, int64(200), section.Lines[0].Comments[1].ID) + + // Second line should have two comments, sorted by creation time + assert.Len(t, section.Lines[1].Comments, 2) + assert.Equal(t, int64(300), section.Lines[1].Comments[0].ID) + assert.Equal(t, int64(301), section.Lines[1].Comments[1].ID) +} diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index f61571231f..cfe9a7ae02 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -827,6 +827,12 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) { } ctx.Data["Diff"] = diff + ctx.Data["DiffBlobExcerptData"] = &gitdiff.DiffBlobExcerptData{ + BaseLink: ctx.Repo.RepoLink + "/blob_excerpt", + PullIssueIndex: pull.Index, + DiffStyle: ctx.FormString("style"), + AfterCommitID: afterCommitID, + } ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 if ctx.IsSigned && ctx.Doer != nil { diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go index db0f565b52..96aea8308c 100644 --- a/services/gitdiff/gitdiff.go +++ b/services/gitdiff/gitdiff.go @@ -22,19 +22,21 @@ 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" - 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" "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/attribute" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/highlight" + "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/util" @@ -67,18 +69,6 @@ const ( DiffFileCopy ) -// DiffLineExpandDirection represents the DiffLineSection expand direction -type DiffLineExpandDirection uint8 - -// DiffLineExpandDirection possible values. -const ( - DiffLineExpandNone DiffLineExpandDirection = iota + 1 - DiffLineExpandSingle - DiffLineExpandUpDown - DiffLineExpandUp - DiffLineExpandDown -) - // DiffLine represents a line difference in a DiffSection. type DiffLine struct { LeftIdx int // line number, 1-based @@ -99,6 +89,8 @@ type DiffLineSectionInfo struct { RightIdx int LeftHunkSize int RightHunkSize int + + HiddenCommentIDs []int64 // IDs of hidden comments in this section } // DiffHTMLOperation is the HTML version of diffmatchpatch.Diff @@ -153,8 +145,7 @@ func (d *DiffLine) GetLineTypeMarker() string { return "" } -// GetBlobExcerptQuery builds query string to get blob excerpt -func (d *DiffLine) GetBlobExcerptQuery() string { +func (d *DiffLine) getBlobExcerptQuery() string { query := fmt.Sprintf( "last_left=%d&last_right=%d&"+ "left=%d&right=%d&"+ @@ -167,19 +158,88 @@ func (d *DiffLine) GetBlobExcerptQuery() string { return query } -// GetExpandDirection gets DiffLineExpandDirection -func (d *DiffLine) GetExpandDirection() DiffLineExpandDirection { +func (d *DiffLine) getExpandDirection() string { if d.Type != DiffLineSection || d.SectionInfo == nil || d.SectionInfo.LeftIdx-d.SectionInfo.LastLeftIdx <= 1 || d.SectionInfo.RightIdx-d.SectionInfo.LastRightIdx <= 1 { - return DiffLineExpandNone + return "" } if d.SectionInfo.LastLeftIdx <= 0 && d.SectionInfo.LastRightIdx <= 0 { - return DiffLineExpandUp + return "up" } else if d.SectionInfo.RightIdx-d.SectionInfo.LastRightIdx > BlobExcerptChunkSize && d.SectionInfo.RightHunkSize > 0 { - return DiffLineExpandUpDown + return "updown" } else if d.SectionInfo.LeftHunkSize <= 0 && d.SectionInfo.RightHunkSize <= 0 { - return DiffLineExpandDown + return "down" } - return DiffLineExpandSingle + return "single" +} + +type DiffBlobExcerptData struct { + BaseLink string + IsWikiRepo bool + PullIssueIndex int64 + DiffStyle string + AfterCommitID string +} + +func (d *DiffLine) RenderBlobExcerptButtons(fileNameHash string, data *DiffBlobExcerptData) template.HTML { + dataHiddenCommentIDs := strings.Join(base.Int64sToStrings(d.SectionInfo.HiddenCommentIDs), ",") + anchor := fmt.Sprintf("diff-%sK%d", fileNameHash, d.SectionInfo.RightIdx) + + makeButton := func(direction, svgName string) template.HTML { + style := util.IfZero(data.DiffStyle, "unified") + link := data.BaseLink + "/" + data.AfterCommitID + fmt.Sprintf("?style=%s&direction=%s&anchor=%s", url.QueryEscape(style), direction, url.QueryEscape(anchor)) + "&" + d.getBlobExcerptQuery() + if data.PullIssueIndex > 0 { + link += fmt.Sprintf("&pull_issue_index=%d", data.PullIssueIndex) + } + return htmlutil.HTMLFormat( + ``, + link, dataHiddenCommentIDs, svg.RenderHTML(svgName), + ) + } + var content template.HTML + + if len(d.SectionInfo.HiddenCommentIDs) > 0 { + tooltip := fmt.Sprintf("%d hidden comment(s)", len(d.SectionInfo.HiddenCommentIDs)) + content += htmlutil.HTMLFormat(`%d`, tooltip, len(d.SectionInfo.HiddenCommentIDs)) + } + + expandDirection := d.getExpandDirection() + if expandDirection == "up" || expandDirection == "updown" { + content += makeButton("up", "octicon-fold-up") + } + if expandDirection == "updown" || expandDirection == "down" { + content += makeButton("down", "octicon-fold-down") + } + if expandDirection == "single" { + content += makeButton("single", "octicon-fold") + } + return htmlutil.HTMLFormat(`
`, expandDirection, content) +} + +// FillHiddenCommentIDsForDiffLine finds comment IDs that are in the hidden range of an expand button +func FillHiddenCommentIDsForDiffLine(line *DiffLine, lineComments map[int64][]*issues_model.Comment) { + if line.Type != DiffLineSection { + return + } + + var hiddenCommentIDs []int64 + for commentLineNum, comments := range lineComments { + if commentLineNum < 0 { + // ATTENTION: BLOB-EXCERPT-COMMENT-RIGHT: skip left-side, unchanged lines always use "right (proposed)" side for comments + continue + } + lineNum := int(commentLineNum) + isEndOfFileExpansion := line.SectionInfo.RightHunkSize == 0 + inRange := lineNum > line.SectionInfo.LastRightIdx && + (isEndOfFileExpansion && lineNum <= line.SectionInfo.RightIdx || + !isEndOfFileExpansion && lineNum < line.SectionInfo.RightIdx) + + if inRange { + for _, comment := range comments { + hiddenCommentIDs = append(hiddenCommentIDs, comment.ID) + } + } + } + line.SectionInfo.HiddenCommentIDs = hiddenCommentIDs } func getDiffLineSectionInfo(treePath, line string, lastLeftIdx, lastRightIdx int) *DiffLineSectionInfo { @@ -485,6 +545,8 @@ func (diff *Diff) LoadComments(ctx context.Context, issue *issues_model.Issue, c sort.SliceStable(line.Comments, func(i, j int) bool { return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix }) + // Mark expand buttons that have comments in hidden lines + FillHiddenCommentIDsForDiffLine(line, lineCommits) } } } @@ -1281,7 +1343,7 @@ type DiffShortStat struct { NumFiles, TotalAddition, TotalDeletion int } -func GetDiffShortStat(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, beforeCommitID, afterCommitID string) (*DiffShortStat, error) { +func GetDiffShortStat(ctx context.Context, repoStorage gitrepo.Repository, gitRepo *git.Repository, beforeCommitID, afterCommitID string) (*DiffShortStat, error) { afterCommit, err := gitRepo.GetCommit(afterCommitID) if err != nil { return nil, err @@ -1293,7 +1355,7 @@ func GetDiffShortStat(ctx context.Context, repo *repo_model.Repository, gitRepo } diff := &DiffShortStat{} - diff.NumFiles, diff.TotalAddition, diff.TotalDeletion, err = gitrepo.GetDiffShortStatByCmdArgs(ctx, repo, nil, actualBeforeCommitID.String(), afterCommitID) + diff.NumFiles, diff.TotalAddition, diff.TotalDeletion, err = gitrepo.GetDiffShortStatByCmdArgs(ctx, repoStorage, nil, actualBeforeCommitID.String(), afterCommitID) if err != nil { return nil, err } @@ -1386,6 +1448,75 @@ func CommentAsDiff(ctx context.Context, c *issues_model.Comment) (*Diff, error) return diff, nil } +// GeneratePatchForUnchangedLine creates a patch showing code context for an unchanged line +func GeneratePatchForUnchangedLine(gitRepo *git.Repository, commitID, treePath string, line int64, contextLines int) (string, error) { + commit, err := gitRepo.GetCommit(commitID) + if err != nil { + return "", fmt.Errorf("GetCommit: %w", err) + } + + entry, err := commit.GetTreeEntryByPath(treePath) + if err != nil { + return "", fmt.Errorf("GetTreeEntryByPath: %w", err) + } + + blob := entry.Blob() + dataRc, err := blob.DataAsync() + if err != nil { + return "", fmt.Errorf("DataAsync: %w", err) + } + defer dataRc.Close() + + return generatePatchForUnchangedLineFromReader(dataRc, treePath, line, contextLines) +} + +// generatePatchForUnchangedLineFromReader is the testable core logic that generates a patch from a reader +func generatePatchForUnchangedLineFromReader(reader io.Reader, treePath string, line int64, contextLines int) (string, error) { + // Calculate line range (commented line + lines above it) + commentLine := int(line) + if line < 0 { + commentLine = int(-line) + } + startLine := max(commentLine-contextLines, 1) + endLine := commentLine + + // Read only the needed lines efficiently + scanner := bufio.NewScanner(reader) + currentLine := 0 + var lines []string + for scanner.Scan() { + currentLine++ + if currentLine >= startLine && currentLine <= endLine { + lines = append(lines, scanner.Text()) + } + if currentLine > endLine { + break + } + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("scanner error: %w", err) + } + + if len(lines) == 0 { + return "", fmt.Errorf("no lines found in range %d-%d", startLine, endLine) + } + + // Generate synthetic patch + var patchBuilder strings.Builder + patchBuilder.WriteString(fmt.Sprintf("diff --git a/%s b/%s\n", treePath, treePath)) + patchBuilder.WriteString(fmt.Sprintf("--- a/%s\n", treePath)) + patchBuilder.WriteString(fmt.Sprintf("+++ b/%s\n", treePath)) + patchBuilder.WriteString(fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", startLine, len(lines), startLine, len(lines))) + + for _, lineContent := range lines { + patchBuilder.WriteString(" ") + patchBuilder.WriteString(lineContent) + patchBuilder.WriteString("\n") + } + + return patchBuilder.String(), nil +} + // CommentMustAsDiff executes AsDiff and logs the error instead of returning func CommentMustAsDiff(ctx context.Context, c *issues_model.Comment) *Diff { if c == nil { diff --git a/services/gitdiff/gitdiff_test.go b/services/gitdiff/gitdiff_test.go index 7b64b6b5f8..51fb9b58d6 100644 --- a/services/gitdiff/gitdiff_test.go +++ b/services/gitdiff/gitdiff_test.go @@ -640,3 +640,346 @@ func TestNoCrashes(t *testing.T) { ParsePatch(t.Context(), setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(testcase.gitdiff), "") } } + +func TestGeneratePatchForUnchangedLineFromReader(t *testing.T) { + tests := []struct { + name string + content string + treePath string + line int64 + contextLines int + want string + wantErr bool + }{ + { + name: "single line with context", + content: "line1\nline2\nline3\nline4\nline5\n", + treePath: "test.txt", + line: 3, + contextLines: 1, + want: `diff --git a/test.txt b/test.txt +--- a/test.txt ++++ b/test.txt +@@ -2,2 +2,2 @@ + line2 + line3 +`, + }, + { + name: "negative line number (left side)", + content: "line1\nline2\nline3\nline4\nline5\n", + treePath: "test.txt", + line: -3, + contextLines: 1, + want: `diff --git a/test.txt b/test.txt +--- a/test.txt ++++ b/test.txt +@@ -2,2 +2,2 @@ + line2 + line3 +`, + }, + { + name: "line near start of file", + content: "line1\nline2\nline3\n", + treePath: "test.txt", + line: 2, + contextLines: 5, + want: `diff --git a/test.txt b/test.txt +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,2 @@ + line1 + line2 +`, + }, + { + name: "first line with context", + content: "line1\nline2\nline3\n", + treePath: "test.txt", + line: 1, + contextLines: 3, + want: `diff --git a/test.txt b/test.txt +--- a/test.txt ++++ b/test.txt +@@ -1,1 +1,1 @@ + line1 +`, + }, + { + name: "zero context lines", + content: "line1\nline2\nline3\n", + treePath: "test.txt", + line: 2, + contextLines: 0, + want: `diff --git a/test.txt b/test.txt +--- a/test.txt ++++ b/test.txt +@@ -2,1 +2,1 @@ + line2 +`, + }, + { + name: "multi-line context", + content: "package main\n\nfunc main() {\n fmt.Println(\"Hello\")\n}\n", + treePath: "main.go", + line: 4, + contextLines: 2, + want: `diff --git a/main.go b/main.go +--- a/main.go ++++ b/main.go +@@ -2,3 +2,3 @@ +{{$inlineDiff.Content}}
{{$inlineDiff.Content}}
+