mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	feat(diff): Enable commenting on expanded lines in PR diffs (#35662)
Fixes #32257 /claim #32257 Implemented commenting on unchanged lines in Pull Request diffs, lines are accessed by expanding the diff preview. Comments also appear in the "Files Changed" tab on the unchanged lines where they were placed. --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		| @@ -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( | ||||
| 			`<button class="code-expander-button" hx-target="closest tr" hx-get="%s" data-hidden-comment-ids=",%s,">%s</button>`, | ||||
| 			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(`<span class="code-comment-more" data-tooltip-content="%s">%d</span>`, 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(`<div class="code-expander-buttons" data-expand-direction="%s">%s</div>`, 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 { | ||||
|   | ||||
| @@ -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 @@ | ||||
| <SP> | ||||
|  func main() { | ||||
|    fmt.Println("Hello") | ||||
| `, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:         "empty file", | ||||
| 			content:      "", | ||||
| 			treePath:     "empty.txt", | ||||
| 			line:         1, | ||||
| 			contextLines: 1, | ||||
| 			wantErr:      true, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			reader := strings.NewReader(tt.content) | ||||
| 			got, err := generatePatchForUnchangedLineFromReader(reader, tt.treePath, tt.line, tt.contextLines) | ||||
| 			if tt.wantErr { | ||||
| 				assert.Error(t, err) | ||||
| 			} else { | ||||
| 				require.NoError(t, err) | ||||
| 				assert.Equal(t, strings.ReplaceAll(tt.want, "<SP>", " "), got) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestCalculateHiddenCommentIDsForLine(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name         string | ||||
| 		line         *DiffLine | ||||
| 		lineComments map[int64][]*issues_model.Comment | ||||
| 		expected     []int64 | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "comments in hidden range", | ||||
| 			line: &DiffLine{ | ||||
| 				Type: DiffLineSection, | ||||
| 				SectionInfo: &DiffLineSectionInfo{ | ||||
| 					LastRightIdx: 10, | ||||
| 					RightIdx:     20, | ||||
| 				}, | ||||
| 			}, | ||||
| 			lineComments: map[int64][]*issues_model.Comment{ | ||||
| 				15: {{ID: 100}, {ID: 101}}, | ||||
| 				12: {{ID: 102}}, | ||||
| 			}, | ||||
| 			expected: []int64{100, 101, 102}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "comments outside hidden range", | ||||
| 			line: &DiffLine{ | ||||
| 				Type: DiffLineSection, | ||||
| 				SectionInfo: &DiffLineSectionInfo{ | ||||
| 					LastRightIdx: 10, | ||||
| 					RightIdx:     20, | ||||
| 				}, | ||||
| 			}, | ||||
| 			lineComments: map[int64][]*issues_model.Comment{ | ||||
| 				5:  {{ID: 100}}, | ||||
| 				25: {{ID: 101}}, | ||||
| 			}, | ||||
| 			expected: nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "negative line numbers (left side)", | ||||
| 			line: &DiffLine{ | ||||
| 				Type: DiffLineSection, | ||||
| 				SectionInfo: &DiffLineSectionInfo{ | ||||
| 					LastRightIdx: 10, | ||||
| 					RightIdx:     20, | ||||
| 				}, | ||||
| 			}, | ||||
| 			lineComments: map[int64][]*issues_model.Comment{ | ||||
| 				-15: {{ID: 100}}, // Left-side comment, should NOT be counted | ||||
| 				15:  {{ID: 101}}, // Right-side comment, should be counted | ||||
| 			}, | ||||
| 			expected: []int64{101}, // Only right-side comment | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "boundary conditions - normal expansion (both boundaries exclusive)", | ||||
| 			line: &DiffLine{ | ||||
| 				Type: DiffLineSection, | ||||
| 				SectionInfo: &DiffLineSectionInfo{ | ||||
| 					LastRightIdx:  10, | ||||
| 					RightIdx:      20, | ||||
| 					RightHunkSize: 5, // Normal case: next section has content | ||||
| 				}, | ||||
| 			}, | ||||
| 			lineComments: map[int64][]*issues_model.Comment{ | ||||
| 				10: {{ID: 100}}, // at LastRightIdx (visible line), should NOT be included | ||||
| 				20: {{ID: 101}}, // at RightIdx (visible line), should NOT be included | ||||
| 				11: {{ID: 102}}, // just inside range, should be included | ||||
| 				19: {{ID: 103}}, // just inside range, should be included | ||||
| 			}, | ||||
| 			expected: []int64{102, 103}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "boundary conditions - end of file expansion (RightIdx inclusive)", | ||||
| 			line: &DiffLine{ | ||||
| 				Type: DiffLineSection, | ||||
| 				SectionInfo: &DiffLineSectionInfo{ | ||||
| 					LastRightIdx:  54, | ||||
| 					RightIdx:      70, | ||||
| 					RightHunkSize: 0, // End of file: no more content after | ||||
| 				}, | ||||
| 			}, | ||||
| 			lineComments: map[int64][]*issues_model.Comment{ | ||||
| 				54: {{ID: 54}}, // at LastRightIdx (visible line), should NOT be included | ||||
| 				70: {{ID: 70}}, // at RightIdx (last hidden line), SHOULD be included | ||||
| 				60: {{ID: 60}}, // inside range, should be included | ||||
| 			}, | ||||
| 			expected: []int64{60, 70}, // Lines 60 and 70 are hidden | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "real-world scenario - start of file with hunk", | ||||
| 			line: &DiffLine{ | ||||
| 				Type: DiffLineSection, | ||||
| 				SectionInfo: &DiffLineSectionInfo{ | ||||
| 					LastRightIdx:  0,  // No previous visible section | ||||
| 					RightIdx:      26, // Line 26 is first visible line of hunk | ||||
| 					RightHunkSize: 9,  // Normal hunk with content | ||||
| 				}, | ||||
| 			}, | ||||
| 			lineComments: map[int64][]*issues_model.Comment{ | ||||
| 				1:  {{ID: 1}},  // Line 1 is hidden | ||||
| 				26: {{ID: 26}}, // Line 26 is visible (hunk start) - should NOT be hidden | ||||
| 				10: {{ID: 10}}, // Line 10 is hidden | ||||
| 				15: {{ID: 15}}, // Line 15 is hidden | ||||
| 			}, | ||||
| 			expected: []int64{1, 10, 15}, // Lines 1, 10, 15 are hidden; line 26 is visible | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			FillHiddenCommentIDsForDiffLine(tt.line, tt.lineComments) | ||||
| 			assert.ElementsMatch(t, tt.expected, tt.line.SectionInfo.HiddenCommentIDs) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestDiffLine_RenderBlobExcerptButtons(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name             string | ||||
| 		line             *DiffLine | ||||
| 		fileNameHash     string | ||||
| 		data             *DiffBlobExcerptData | ||||
| 		expectContains   []string | ||||
| 		expectNotContain []string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "expand up button with hidden comments", | ||||
| 			line: &DiffLine{ | ||||
| 				Type: DiffLineSection, | ||||
| 				SectionInfo: &DiffLineSectionInfo{ | ||||
| 					LastRightIdx:     0, | ||||
| 					RightIdx:         26, | ||||
| 					LeftIdx:          26, | ||||
| 					LastLeftIdx:      0, | ||||
| 					LeftHunkSize:     0, | ||||
| 					RightHunkSize:    0, | ||||
| 					HiddenCommentIDs: []int64{100}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			fileNameHash: "abc123", | ||||
| 			data: &DiffBlobExcerptData{ | ||||
| 				BaseLink:      "/repo/blob_excerpt", | ||||
| 				AfterCommitID: "commit123", | ||||
| 				DiffStyle:     "unified", | ||||
| 			}, | ||||
| 			expectContains: []string{ | ||||
| 				"octicon-fold-up", | ||||
| 				"direction=up", | ||||
| 				"code-comment-more", | ||||
| 				"1 hidden comment(s)", | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "expand up and down buttons with pull request", | ||||
| 			line: &DiffLine{ | ||||
| 				Type: DiffLineSection, | ||||
| 				SectionInfo: &DiffLineSectionInfo{ | ||||
| 					LastRightIdx:     10, | ||||
| 					RightIdx:         50, | ||||
| 					LeftIdx:          10, | ||||
| 					LastLeftIdx:      5, | ||||
| 					LeftHunkSize:     5, | ||||
| 					RightHunkSize:    5, | ||||
| 					HiddenCommentIDs: []int64{200, 201}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			fileNameHash: "def456", | ||||
| 			data: &DiffBlobExcerptData{ | ||||
| 				BaseLink:       "/repo/blob_excerpt", | ||||
| 				AfterCommitID:  "commit456", | ||||
| 				DiffStyle:      "split", | ||||
| 				PullIssueIndex: 42, | ||||
| 			}, | ||||
| 			expectContains: []string{ | ||||
| 				"octicon-fold-down", | ||||
| 				"octicon-fold-up", | ||||
| 				"direction=down", | ||||
| 				"direction=up", | ||||
| 				`data-hidden-comment-ids=",200,201,"`, // use leading and trailing commas to ensure exact match by CSS selector `attr*=",id,"` | ||||
| 				"pull_issue_index=42", | ||||
| 				"2 hidden comment(s)", | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "no hidden comments", | ||||
| 			line: &DiffLine{ | ||||
| 				Type: DiffLineSection, | ||||
| 				SectionInfo: &DiffLineSectionInfo{ | ||||
| 					LastRightIdx:     10, | ||||
| 					RightIdx:         20, | ||||
| 					LeftIdx:          10, | ||||
| 					LastLeftIdx:      5, | ||||
| 					LeftHunkSize:     5, | ||||
| 					RightHunkSize:    5, | ||||
| 					HiddenCommentIDs: nil, | ||||
| 				}, | ||||
| 			}, | ||||
| 			fileNameHash: "ghi789", | ||||
| 			data: &DiffBlobExcerptData{ | ||||
| 				BaseLink:      "/repo/blob_excerpt", | ||||
| 				AfterCommitID: "commit789", | ||||
| 			}, | ||||
| 			expectContains: []string{ | ||||
| 				"code-expander-button", | ||||
| 			}, | ||||
| 			expectNotContain: []string{ | ||||
| 				"code-comment-more", | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			result := tt.line.RenderBlobExcerptButtons(tt.fileNameHash, tt.data) | ||||
| 			resultStr := string(result) | ||||
|  | ||||
| 			for _, expected := range tt.expectContains { | ||||
| 				assert.Contains(t, resultStr, expected, "Expected to contain: %s", expected) | ||||
| 			} | ||||
|  | ||||
| 			for _, notExpected := range tt.expectNotContain { | ||||
| 				assert.NotContains(t, resultStr, notExpected, "Expected NOT to contain: %s", notExpected) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user