Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
17be181
feat(diff): Enable commenting on expanded lines in PR diffs
brymut Oct 14, 2025
1b9ac2c
feat(gitdiff): Use generatePatchForUnchangedLine instead to generate …
brymut Oct 16, 2025
8216008
fix(PR): Add permission check
brymut Oct 16, 2025
30e3fd1
fix(diff): update hidden comment count on code expand buttons
brymut Oct 16, 2025
b234f31
feat(gitdiff): fix code context in conversation tab
brymut Oct 16, 2025
4b85c50
fix(gitdiff): remove patching logic duplication
brymut Oct 17, 2025
2c33c7d
feat(diff): Enhance hidden comment expansion when sharing link
brymut Oct 18, 2025
19ed046
fix: highlighting to shared hidden comments
brymut Oct 18, 2025
3e0c892
test(diff): add tests for helper functions in ExcerptBlob
brymut Oct 18, 2025
aafa231
refactor(gitdiff): Extract hidden comment calculation to shared metho…
brymut Oct 18, 2025
fd2fc61
clean up code-expander-button
wxiaoguang Oct 18, 2025
a95ebbb
fix
wxiaoguang Oct 18, 2025
38d40a6
fix tmpl var
wxiaoguang Oct 18, 2025
6408c43
add missing comment
wxiaoguang Oct 18, 2025
bbd4799
fine tune tests
wxiaoguang Oct 18, 2025
c0b8201
remove unnecessary code
wxiaoguang Oct 18, 2025
590df91
fix: handle righthunksize == 0 scenario
brymut Oct 18, 2025
d1fc0d7
fine tune
wxiaoguang Oct 19, 2025
9356dbe
fix: remove reduntant test
brymut Oct 19, 2025
78cd2ce
fix(gitdiff): fix line number handling in `FillHiddenCommentIDsForDif…
brymut Oct 19, 2025
bb714b4
fine tune
wxiaoguang Oct 19, 2025
c5c1c2b
Merge branch 'main' into enable-commenting-unchanged-line
wxiaoguang Oct 19, 2025
af864c2
fix assumption
wxiaoguang Oct 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions routers/web/repo/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"net/http"
"net/url"
"path/filepath"
"sort"
"strings"

"code.gitea.io/gitea/models/db"
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -947,6 +949,69 @@ func ExcerptBlob(ctx *context.Context) {
section.Lines = append(section.Lines, lineSection)
}
}
if ctx.Doer != nil {
ctx.Data["SignedUserID"] = ctx.Doer.ID
}
isPull := ctx.FormBool("is_pull")
ctx.Data["PageIsPullFiles"] = isPull

if isPull {
if !ctx.Repo.CanRead(unit.TypePullRequests) {
ctx.NotFound(nil)
return
}
issueIndex := ctx.FormInt64("issue_index")
ctx.Data["IssueIndex"] = issueIndex
if issueIndex > 0 {
if issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, issueIndex); err == nil && issue.IsPull {
ctx.Data["Issue"] = issue
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
}

if allComments, err := issues_model.FetchCodeComments(ctx, issue, ctx.Doer, ctx.FormBool("show_outdated")); err == nil {
if lineComments, ok := allComments[filePath]; ok {
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
})
}

// Recalculate hidden comment counts for expand buttons after loading comments
for _, line := range section.Lines {
if line.Type == gitdiff.DiffLineSection && line.SectionInfo != nil {
var hiddenCommentIDs []int64
// Check if there are comments in the hidden range
for commentLineNum, comments := range lineComments {
absLineNum := commentLineNum
if absLineNum < 0 {
absLineNum = -absLineNum
}
// Count comments that are in the hidden range
if int(absLineNum) > line.SectionInfo.LastRightIdx && int(absLineNum) < line.SectionInfo.RightIdx {
// Collect comment IDs
for _, comment := range comments {
hiddenCommentIDs = append(hiddenCommentIDs, comment.ID)
}
}
}
if len(hiddenCommentIDs) > 0 {
line.HiddenCommentIDs = hiddenCommentIDs
}
}
}
}
}
}
}
}

ctx.Data["section"] = section
ctx.Data["FileNameHash"] = git.HashFilePathForWebUI(filePath)
ctx.Data["AfterCommitID"] = commitID
Expand Down
100 changes: 93 additions & 7 deletions services/gitdiff/gitdiff.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,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 comments
SectionInfo *DiffLineSectionInfo
HiddenCommentIDs []int64 // IDs of hidden comments in this section
}

// DiffLineSectionInfo represents diff line section meta data
Expand Down Expand Up @@ -485,6 +486,27 @@ 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
if line.Type == DiffLineSection && line.SectionInfo != nil {
var hiddenCommentIDs []int64
// Check if there are comments in the hidden range
for commentLineNum, comments := range lineCommits {
absLineNum := int(commentLineNum)
if commentLineNum < 0 {
absLineNum = int(-commentLineNum)
}
if absLineNum > line.SectionInfo.LastRightIdx && absLineNum < line.SectionInfo.RightIdx {
// Collect comment IDs
for _, comment := range comments {
hiddenCommentIDs = append(hiddenCommentIDs, comment.ID)
}
}
}
if len(hiddenCommentIDs) > 0 {
line.HiddenCommentIDs = hiddenCommentIDs
}
}
}
}
}
Expand Down Expand Up @@ -1386,6 +1408,70 @@ 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()

// 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(dataRc)
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 {
Expand Down
11 changes: 11 additions & 0 deletions services/pull/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/gitdiff"
notify_service "code.gitea.io/gitea/services/notify"
)

Expand Down Expand Up @@ -283,6 +284,16 @@ func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_mo
log.Error("Error whilst generating patch: %v", err)
return nil, err
}

// If patch is still empty (unchanged line), generate code context
if len(patch) == 0 && len(commitID) > 0 {
patch, err = gitdiff.GeneratePatchForUnchangedLine(gitRepo, commitID, treePath, line, setting.UI.CodeCommentLines)
if err != nil {
// Log the error but don't fail comment creation
// This can happen for deleted/renamed files or other edge cases
log.Warn("Unable to generate patch for unchanged line (file=%s, line=%d, commit=%s): %v", treePath, line, commitID, err)
}
}
}
return issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeCode,
Expand Down
64 changes: 54 additions & 10 deletions templates/repo/diff/blob_excerpt.tmpl
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
{{$blobExcerptLink := print $.RepoLink (Iif $.PageIsWiki "/wiki" "") "/blob_excerpt/" (PathEscape $.AfterCommitID) (QueryBuild "?" "anchor" $.Anchor)}}
{{$blobExcerptLink := print $.RepoLink (Iif $.PageIsWiki "/wiki" "") "/blob_excerpt/" (PathEscape $.AfterCommitID) (QueryBuild "?" "anchor" $.Anchor) (Iif $.PageIsPullFiles (print "&is_pull=true&issue_index=" $.IssueIndex) "")}}
{{if $.IsSplitStyle}}
{{range $k, $line := $.section.Lines}}
<tr class="{{.GetHTMLDiffLineType}}-code nl-{{$k}} ol-{{$k}} line-expanded">
<tr class="{{.GetHTMLDiffLineType}}-code nl-{{$k}} ol-{{$k}} line-expanded" data-line-type="{{.GetHTMLDiffLineType}}">
{{if eq .GetType 4}}
{{$expandDirection := $line.GetExpandDirection}}
<td class="lines-num lines-num-old" data-line-num="{{if $line.LeftIdx}}{{$line.LeftIdx}}{{end}}">
<div class="code-expander-buttons" data-expand-direction="{{$expandDirection}}">
{{if or (eq $expandDirection 3) (eq $expandDirection 5)}}
<button class="code-expander-button" hx-target="closest tr" hx-get="{{$blobExcerptLink}}&{{$line.GetBlobExcerptQuery}}&style=split&direction=down">
<button class="code-expander-button{{if $line.HiddenCommentIDs}} has-hidden-comments{{end}}" hx-target="closest tr" hx-get="{{$blobExcerptLink}}&{{$line.GetBlobExcerptQuery}}&style=split&direction=down" title="{{if $line.HiddenCommentIDs}}{{len $line.HiddenCommentIDs}} hidden comment(s){{end}}">
{{svg "octicon-fold-down"}}
{{if $line.HiddenCommentIDs}}<span class="ui mini label">{{len $line.HiddenCommentIDs}}</span>{{end}}
</button>
{{end}}
{{if or (eq $expandDirection 3) (eq $expandDirection 4)}}
<button class="code-expander-button" hx-target="closest tr" hx-get="{{$blobExcerptLink}}&{{$line.GetBlobExcerptQuery}}&style=split&direction=up">
<button class="code-expander-button{{if $line.HiddenCommentIDs}} has-hidden-comments{{end}}" hx-target="closest tr" hx-get="{{$blobExcerptLink}}&{{$line.GetBlobExcerptQuery}}&style=split&direction=up" title="{{if $line.HiddenCommentIDs}}{{len $line.HiddenCommentIDs}} hidden comment(s){{end}}">
{{svg "octicon-fold-up"}}
{{if $line.HiddenCommentIDs}}<span class="ui mini label">{{len $line.HiddenCommentIDs}}</span>{{end}}
</button>
{{end}}
{{if eq $expandDirection 2}}
<button class="code-expander-button" hx-target="closest tr" hx-get="{{$blobExcerptLink}}&{{$line.GetBlobExcerptQuery}}&style=split">
<button class="code-expander-button{{if $line.HiddenCommentIDs}} has-hidden-comments{{end}}" hx-target="closest tr" hx-get="{{$blobExcerptLink}}&{{$line.GetBlobExcerptQuery}}&style=split" title="{{if $line.HiddenCommentIDs}}{{len $line.HiddenCommentIDs}} hidden comment(s){{end}}">
{{svg "octicon-fold"}}
{{if $line.HiddenCommentIDs}}<span class="ui mini label">{{len $line.HiddenCommentIDs}}</span>{{end}}
</button>
{{end}}
</div>
Expand All @@ -33,6 +36,11 @@
<td class="lines-escape lines-escape-old">{{if and $line.LeftIdx $inlineDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"></button>{{end}}</td>
<td class="lines-type-marker lines-type-marker-old">{{if $line.LeftIdx}}<span class="tw-font-mono" data-type-marker=""></span>{{end}}</td>
<td class="lines-code lines-code-old">
{{- if and $.SignedUserID $.PageIsPullFiles $line.LeftIdx -}}
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-left{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="left" data-idx="{{$line.LeftIdx}}">
{{- svg "octicon-plus" -}}
</button>
{{- end -}}
{{- if $line.LeftIdx -}}
{{- template "repo/diff/section_code" dict "diff" $inlineDiff -}}
{{- else -}}
Expand All @@ -43,6 +51,11 @@
<td class="lines-escape lines-escape-new">{{if and $line.RightIdx $inlineDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"></button>{{end}}</td>
<td class="lines-type-marker lines-type-marker-new">{{if $line.RightIdx}}<span class="tw-font-mono" data-type-marker=""></span>{{end}}</td>
<td class="lines-code lines-code-new">
{{- if and $.SignedUserID $.PageIsPullFiles $line.RightIdx -}}
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-right{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="right" data-idx="{{$line.RightIdx}}">
{{- svg "octicon-plus" -}}
</button>
{{- end -}}
{{- if $line.RightIdx -}}
{{- template "repo/diff/section_code" dict "diff" $inlineDiff -}}
{{- else -}}
Expand All @@ -51,27 +64,44 @@
</td>
{{end}}
</tr>
{{if $line.Comments}}
<tr class="add-comment" data-line-type="{{.GetHTMLDiffLineType}}">
<td class="add-comment-left" colspan="4">
{{if eq $line.GetCommentSide "previous"}}
{{template "repo/diff/conversation" dict "." $ "comments" $line.Comments}}
{{end}}
</td>
<td class="add-comment-right" colspan="4">
{{if eq $line.GetCommentSide "proposed"}}
{{template "repo/diff/conversation" dict "." $ "comments" $line.Comments}}
{{end}}
</td>
</tr>
{{end}}
{{end}}
{{else}}
{{range $k, $line := $.section.Lines}}
<tr class="{{.GetHTMLDiffLineType}}-code nl-{{$k}} ol-{{$k}} line-expanded">
<tr class="{{.GetHTMLDiffLineType}}-code nl-{{$k}} ol-{{$k}} line-expanded" data-line-type="{{.GetHTMLDiffLineType}}">
{{if eq .GetType 4}}
{{$expandDirection := $line.GetExpandDirection}}
<td colspan="2" class="lines-num">
<div class="code-expander-buttons" data-expand-direction="{{$expandDirection}}">
{{if or (eq $expandDirection 3) (eq $expandDirection 5)}}
<button class="code-expander-button" hx-target="closest tr" hx-get="{{$blobExcerptLink}}&{{$line.GetBlobExcerptQuery}}&style=unified&direction=down">
<button class="code-expander-button{{if $line.HiddenCommentIDs}} has-hidden-comments{{end}}" hx-target="closest tr" hx-get="{{$blobExcerptLink}}&{{$line.GetBlobExcerptQuery}}&style=unified&direction=down" title="{{if $line.HiddenCommentIDs}}{{len $line.HiddenCommentIDs}} hidden comment(s){{end}}"{{if $line.HiddenCommentIDs}} data-hidden-comment-ids="{{StringUtils.ToString $line.HiddenCommentIDs}}"{{end}}>
{{svg "octicon-fold-down"}}
{{if $line.HiddenCommentIDs}}<span class="ui mini label">{{len $line.HiddenCommentIDs}}</span>{{end}}
</button>
{{end}}
{{if or (eq $expandDirection 3) (eq $expandDirection 4)}}
<button class="code-expander-button" hx-target="closest tr" hx-get="{{$blobExcerptLink}}&{{$line.GetBlobExcerptQuery}}&style=unified&direction=up">
<button class="code-expander-button{{if $line.HiddenCommentIDs}} has-hidden-comments{{end}}" hx-target="closest tr" hx-get="{{$blobExcerptLink}}&{{$line.GetBlobExcerptQuery}}&style=unified&direction=up" title="{{if $line.HiddenCommentIDs}}{{len $line.HiddenCommentIDs}} hidden comment(s){{end}}"{{if $line.HiddenCommentIDs}} data-hidden-comment-ids="{{StringUtils.ToString $line.HiddenCommentIDs}}"{{end}}>
{{svg "octicon-fold-up"}}
{{if $line.HiddenCommentIDs}}<span class="ui mini label">{{len $line.HiddenCommentIDs}}</span>{{end}}
</button>
{{end}}
{{if eq $expandDirection 2}}
<button class="code-expander-button" hx-target="closest tr" hx-get="{{$blobExcerptLink}}&{{$line.GetBlobExcerptQuery}}&style=unified">
<button class="code-expander-button{{if $line.HiddenCommentIDs}} has-hidden-comments{{end}}" hx-target="closest tr" hx-get="{{$blobExcerptLink}}&{{$line.GetBlobExcerptQuery}}&style=unified" title="{{if $line.HiddenCommentIDs}}{{len $line.HiddenCommentIDs}} hidden comment(s){{end}}"{{if $line.HiddenCommentIDs}} data-hidden-comment-ids="{{StringUtils.ToString $line.HiddenCommentIDs}}"{{end}}>
{{svg "octicon-fold"}}
{{if $line.HiddenCommentIDs}}<span class="ui mini label">{{len $line.HiddenCommentIDs}}</span>{{end}}
</button>
{{end}}
</div>
Expand All @@ -83,7 +113,21 @@
{{$inlineDiff := $.section.GetComputedInlineDiffFor $line ctx.Locale}}
<td class="lines-escape">{{if $inlineDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"></button>{{end}}</td>
<td class="lines-type-marker"><span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span></td>
<td class="lines-code{{if (not $line.RightIdx)}} lines-code-old{{end}}"><code {{if $inlineDiff.EscapeStatus.Escaped}}class="code-inner has-escaped" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"{{else}}class="code-inner"{{end}}>{{$inlineDiff.Content}}</code></td>
<td class="lines-code{{if (not $line.RightIdx)}} lines-code-old{{end}}">
{{- if and $.SignedUserID $.PageIsPullFiles -}}
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-{{if $line.RightIdx}}right{{else}}left{{end}}{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="{{if $line.RightIdx}}right{{else}}left{{end}}" data-idx="{{if $line.RightIdx}}{{$line.RightIdx}}{{else}}{{$line.LeftIdx}}{{end}}">
{{- svg "octicon-plus" -}}
</button>
{{- end -}}
<code {{if $inlineDiff.EscapeStatus.Escaped}}class="code-inner has-escaped" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"{{else}}class="code-inner"{{end}}>{{$inlineDiff.Content}}</code>
</td>
</tr>
{{if $line.Comments}}
<tr class="add-comment" data-line-type="{{.GetHTMLDiffLineType}}">
<td class="add-comment-left add-comment-right" colspan="5">
{{template "repo/diff/conversation" dict "." $ "comments" $line.Comments}}
</td>
</tr>
{{end}}
{{end}}
{{end}}
Loading