0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-02-21 11:28:12 +01:00

Merge db03128bf9f2057b55ef4368d369b5e12600bc5e into ed587ca71ba06d71c21ed7cbb1ba1e88ddeb9752

This commit is contained in:
silverwind 2026-02-21 10:31:55 +08:00 committed by GitHub
commit 92ef59794f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 447 additions and 41 deletions

View File

@ -2562,6 +2562,8 @@
"repo.diff.load": "Load Diff",
"repo.diff.generated": "Generated",
"repo.diff.vendored": "Vendored",
"repo.diff.expand_all": "Expand all",
"repo.diff.collapse_expanded": "Collapse expanded",
"repo.diff.comment.add_line_comment": "Add line comment",
"repo.diff.comment.placeholder": "Leave a comment",
"repo.diff.comment.add_single_comment": "Add single comment",

View File

@ -12,6 +12,7 @@ import (
"net/url"
"path/filepath"
"sort"
"strconv"
"strings"
"code.gitea.io/gitea/models/db"
@ -746,20 +747,25 @@ func attachHiddenCommentIDs(section *gitdiff.DiffSection, lineComments map[int64
}
}
// splitInts splits a comma-separated string of integers into a slice.
func splitInts(s string) ([]int, error) {
parts := strings.Split(s, ",")
result := make([]int, len(parts))
for i, p := range parts {
v, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
return nil, err
}
result[i] = v
}
return result, nil
}
// ExcerptBlob render blob excerpt contents
func ExcerptBlob(ctx *context.Context) {
commitID := ctx.PathParam("sha")
opts := gitdiff.BlobExcerptOptions{
LastLeft: ctx.FormInt("last_left"),
LastRight: ctx.FormInt("last_right"),
LeftIndex: ctx.FormInt("left"),
RightIndex: ctx.FormInt("right"),
LeftHunkSize: ctx.FormInt("left_hunk_size"),
RightHunkSize: ctx.FormInt("right_hunk_size"),
Direction: ctx.FormString("direction"),
Language: ctx.FormString("filelang"),
}
filePath := ctx.FormString("path")
language := ctx.FormString("filelang")
gitRepo := ctx.Repo.GitRepo
diffBlobExcerptData := &gitdiff.DiffBlobExcerptData{
@ -778,6 +784,30 @@ func ExcerptBlob(ctx *context.Context) {
diffBlobExcerptData.BaseLink = ctx.Repo.RepoLink + "/wiki/blob_excerpt"
}
// Detect batch mode: comma in last_left means comma-separated arrays
isBatch := strings.Contains(ctx.FormString("last_left"), ",")
// Parse options: batch parses comma-separated arrays, single parses individual values
var optsList []gitdiff.BlobExcerptOptions
if isBatch {
var ok bool
optsList, ok = parseBatchBlobExcerptOptions(ctx, language)
if !ok {
return
}
} else {
optsList = []gitdiff.BlobExcerptOptions{{
LastLeft: ctx.FormInt("last_left"),
LastRight: ctx.FormInt("last_right"),
LeftIndex: ctx.FormInt("left"),
RightIndex: ctx.FormInt("right"),
LeftHunkSize: ctx.FormInt("left_hunk_size"),
RightHunkSize: ctx.FormInt("right_hunk_size"),
Direction: ctx.FormString("direction"),
Language: language,
}}
}
commit, err := gitRepo.GetCommit(commitID)
if err != nil {
ctx.ServerError("GetCommit", err)
@ -788,26 +818,35 @@ func ExcerptBlob(ctx *context.Context) {
ctx.ServerError("GetBlobByPath", err)
return
}
if blob.Size() > setting.UI.MaxDisplayFileSize {
ctx.HTTPError(http.StatusRequestEntityTooLarge, "blob too large for expansion")
return
}
reader, err := blob.DataAsync()
if err != nil {
ctx.ServerError("DataAsync", err)
return
}
defer reader.Close()
section, err := gitdiff.BuildBlobExcerptDiffSection(filePath, reader, opts)
blobData, err := io.ReadAll(reader)
reader.Close()
if err != nil {
ctx.ServerError("BuildBlobExcerptDiffSection", err)
ctx.ServerError("ReadAll", err)
return
}
sections, err := gitdiff.BuildBlobExcerptDiffSections(filePath, blobData, optsList)
if err != nil {
ctx.ServerError("BuildBlobExcerptDiffSections", err)
return
}
// Fetch PR comments and attach to sections
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)
@ -824,8 +863,8 @@ func ExcerptBlob(ctx *context.Context) {
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 {
} else if lineComments, ok := allComments[filePath]; ok {
for _, section := range sections {
attachCommentsToLines(section, lineComments)
attachHiddenCommentIDs(section, lineComments)
}
@ -833,9 +872,62 @@ func ExcerptBlob(ctx *context.Context) {
}
}
ctx.Data["section"] = section
ctx.Data["FileNameHash"] = git.HashFilePathForWebUI(filePath)
ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData
ctx.HTML(http.StatusOK, tplBlobExcerpt)
// Respond: single returns HTML, batch returns JSON array of HTML strings
if isBatch {
htmlStrings := make([]string, len(sections))
for i, section := range sections {
ctx.Data["section"] = section
html, err := ctx.RenderToHTML(tplBlobExcerpt, ctx.Data)
if err != nil {
ctx.ServerError("RenderToHTML", err)
return
}
htmlStrings[i] = string(html)
}
ctx.JSON(http.StatusOK, htmlStrings)
} else {
ctx.Data["section"] = sections[0]
ctx.HTML(http.StatusOK, tplBlobExcerpt)
}
}
// parseBatchBlobExcerptOptions parses comma-separated per-gap parameters for batch expansion.
// Returns false if an error response has been sent.
func parseBatchBlobExcerptOptions(ctx *context.Context, language string) ([]gitdiff.BlobExcerptOptions, bool) {
paramNames := [6]string{"last_left", "last_right", "left", "right", "left_hunk_size", "right_hunk_size"}
var parsed [6][]int
for i, name := range paramNames {
vals, err := splitInts(ctx.FormString(name))
if err != nil {
ctx.HTTPError(http.StatusBadRequest, "invalid "+name+" values")
return nil, false
}
parsed[i] = vals
}
n := len(parsed[0])
for i := 1; i < len(parsed); i++ {
if len(parsed[i]) != n {
ctx.HTTPError(http.StatusBadRequest, "all per-gap parameter arrays must have the same length")
return nil, false
}
}
optsList := make([]gitdiff.BlobExcerptOptions, n)
for i := range n {
optsList[i] = gitdiff.BlobExcerptOptions{
LastLeft: parsed[0][i],
LastRight: parsed[1][i],
LeftIndex: parsed[2][i],
RightIndex: parsed[3][i],
LeftHunkSize: parsed[4][i],
RightHunkSize: parsed[5][i],
Direction: "full",
Language: language,
}
}
return optsList, true
}

View File

@ -228,7 +228,7 @@ func (d *DiffLine) RenderBlobExcerptButtons(fileNameHash string, data *DiffBlobE
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>`,
`<button class="code-expander-button" data-global-click="onExpanderButtonClick" data-url="%s" data-hidden-comment-ids=",%s,">%s</button>`,
link, dataHiddenCommentIDs, svg.RenderHTML(svgName),
)
}

View File

@ -26,7 +26,9 @@ type BlobExcerptOptions struct {
Language string
}
func fillExcerptLines(section *DiffSection, filePath string, reader io.Reader, lang string, idxLeft, idxRight, chunkSize int) error {
// fillExcerptLines reads from reader and populates section.Lines.
// It returns the accumulated content buffer for later highlighting.
func fillExcerptLines(section *DiffSection, reader io.Reader, idxLeft, idxRight, chunkSize int) ([]byte, error) {
buf := &bytes.Buffer{}
scanner := bufio.NewScanner(reader)
var diffLines []*DiffLine
@ -51,36 +53,36 @@ func fillExcerptLines(section *DiffSection, filePath string, reader io.Reader, l
diffLines = append(diffLines, diffLine)
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("fillExcerptLines scan: %w", err)
return nil, fmt.Errorf("fillExcerptLines scan: %w", err)
}
section.Lines = diffLines
// DiffLinePlain always uses right lines
section.highlightedRightLines.value = highlightCodeLines(filePath, lang, []*DiffSection{section}, false /* right */, buf.Bytes())
return nil
return buf.Bytes(), nil
}
func BuildBlobExcerptDiffSection(filePath string, reader io.Reader, opts BlobExcerptOptions) (*DiffSection, error) {
// buildExcerptDiffSection builds a single excerpt section without highlighting.
// It returns the section and the accumulated content buffer.
func buildExcerptDiffSection(filePath string, reader io.Reader, opts BlobExcerptOptions) (*DiffSection, []byte, error) {
lastLeft, lastRight, idxLeft, idxRight := opts.LastLeft, opts.LastRight, opts.LeftIndex, opts.RightIndex
leftHunkSize, rightHunkSize, direction := opts.LeftHunkSize, opts.RightHunkSize, opts.Direction
language := opts.Language
chunkSize := BlobExcerptChunkSize
section := &DiffSection{
language: &diffVarMutable[string]{value: language},
language: &diffVarMutable[string]{value: opts.Language},
highlightLexer: &diffVarMutable[chroma.Lexer]{},
highlightedLeftLines: &diffVarMutable[map[int]template.HTML]{},
highlightedRightLines: &diffVarMutable[map[int]template.HTML]{},
FileName: filePath,
}
var bufContent []byte
var err error
if direction == "up" && (idxLeft-lastLeft) > chunkSize {
idxLeft -= chunkSize
idxRight -= chunkSize
leftHunkSize += chunkSize
rightHunkSize += chunkSize
err = fillExcerptLines(section, filePath, reader, language, idxLeft-1, idxRight-1, chunkSize)
bufContent, err = fillExcerptLines(section, reader, idxLeft-1, idxRight-1, chunkSize)
} else if direction == "down" && (idxLeft-lastLeft) > chunkSize {
err = fillExcerptLines(section, filePath, reader, language, lastLeft, lastRight, chunkSize)
bufContent, err = fillExcerptLines(section, reader, lastLeft, lastRight, chunkSize)
lastLeft += chunkSize
lastRight += chunkSize
} else {
@ -88,14 +90,14 @@ func BuildBlobExcerptDiffSection(filePath string, reader io.Reader, opts BlobExc
if direction == "down" {
offset = 0
}
err = fillExcerptLines(section, filePath, reader, language, lastLeft, lastRight, idxRight-lastRight+offset)
bufContent, err = fillExcerptLines(section, reader, lastLeft, lastRight, idxRight-lastRight+offset)
leftHunkSize = 0
rightHunkSize = 0
idxLeft = lastLeft
idxRight = lastRight
}
if err != nil {
return nil, err
return nil, nil, err
}
newLineSection := &DiffLine{
@ -120,5 +122,39 @@ func BuildBlobExcerptDiffSection(filePath string, reader io.Reader, opts BlobExc
section.Lines = append(section.Lines, newLineSection)
}
}
return section, bufContent, nil
}
// BuildBlobExcerptDiffSection builds a single excerpt section with highlighting.
func BuildBlobExcerptDiffSection(filePath string, reader io.Reader, opts BlobExcerptOptions) (*DiffSection, error) {
section, bufContent, err := buildExcerptDiffSection(filePath, reader, opts)
if err != nil {
return nil, err
}
// DiffLinePlain always uses right lines
section.highlightedRightLines.value = highlightCodeLines(filePath, opts.Language, []*DiffSection{section}, false /* right */, bufContent)
return section, nil
}
// BuildBlobExcerptDiffSections builds multiple excerpt sections from the same file content,
// highlighting the content only once for all sections.
func BuildBlobExcerptDiffSections(filePath string, content []byte, optsList []BlobExcerptOptions) ([]*DiffSection, error) {
sections := make([]*DiffSection, len(optsList))
for i, opts := range optsList {
section, _, err := buildExcerptDiffSection(filePath, bytes.NewReader(content), opts)
if err != nil {
return nil, err
}
sections[i] = section
}
// Highlight once for all sections
if len(optsList) > 0 {
highlighted := highlightCodeLines(filePath, optsList[0].Language, sections, false /* right */, content)
for _, section := range sections {
section.highlightedRightLines.value = highlighted
}
}
return sections, nil
}

View File

@ -97,7 +97,10 @@
{{if $file.IsRenamed}}{{$file.OldName}}{{end}}{{$file.Name}}
</a>
</div>
<button class="btn interact-fg tw-p-2 tw-shrink-0" data-clipboard-text="{{$file.Name}}" data-tooltip-content="{{ctx.Locale.Tr "copy_path"}}">{{svg "octicon-copy" 14}}</button>
<button class="btn interact-fg tw-p-2 tw-shrink-0 tw-ml-2" data-clipboard-text="{{$file.Name}}" data-tooltip-content="{{ctx.Locale.Tr "copy_path"}}">{{svg "octicon-copy" 14}}</button>
{{if and $.DiffBlobExcerptData (not $file.IsSubmodule) (not $file.IsBin) (not $file.IsIncomplete)}}
<button class="btn interact-fg tw-p-2 tw-shrink-0 diff-expand-all" data-tooltip-content="{{ctx.Locale.Tr "repo.diff.expand_all"}}" data-expand-tooltip="{{ctx.Locale.Tr "repo.diff.expand_all"}}" data-collapse-tooltip="{{ctx.Locale.Tr "repo.diff.collapse_expanded"}}">{{svg "octicon-unfold" 14}}</button>
{{end}}
{{if $file.IsLFSFile}}
<span class="ui label">LFS</span>
{{end}}

View File

@ -12,11 +12,13 @@ import (
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/test"
repo_service "code.gitea.io/gitea/services/repository"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCompareTag(t *testing.T) {
@ -147,13 +149,146 @@ func TestCompareCodeExpand(t *testing.T) {
req := NewRequest(t, "GET", "/user1/test_blob_excerpt/compare/main...user2/test_blob_excerpt-fork:forked-branch")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
els := htmlDoc.Find(`button.code-expander-button[hx-get]`)
els := htmlDoc.Find(`button.code-expander-button[data-url]`)
// all the links in the comparison should be to the forked repo&branch
assert.NotZero(t, els.Length())
for i := 0; i < els.Length(); i++ {
link := els.Eq(i).AttrOr("hx-get", "")
link := els.Eq(i).AttrOr("data-url", "")
assert.True(t, strings.HasPrefix(link, "/user2/test_blob_excerpt-fork/blob_excerpt/"))
}
})
}
func TestBlobExcerptSingleAndBatch(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
repo, err := repo_service.CreateRepositoryDirectly(t.Context(), user1, user1, repo_service.CreateRepoOptions{
Name: "test_blob_excerpt_batch",
Readme: "Default",
AutoInit: true,
DefaultBranch: "main",
}, true)
require.NoError(t, err)
session := loginUser(t, user1.Name)
// Create a file with 50 lines so the diff has multiple collapsed sections
lines := make([]string, 50)
for i := range lines {
lines[i] = fmt.Sprintf("line %d", i+1)
}
testEditFile(t, session, user1.Name, repo.Name, "main", "README.md", strings.Join(lines, "\n")+"\n")
// Create a branch and change a line in the middle to produce two expander gaps
testEditFileToNewBranch(t, session, user1.Name, repo.Name, "main", "excerpt-branch", "README.md",
func() string {
modified := make([]string, 50)
copy(modified, lines)
modified[24] = "CHANGED line 25"
return strings.Join(modified, "\n") + "\n"
}(),
)
// Load the compare page
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/compare/main...excerpt-branch", user1.Name, repo.Name))
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
els := htmlDoc.Find(`button.code-expander-button[data-url]`)
// We need at least 2 expander buttons to test batch mode
require.GreaterOrEqual(t, els.Length(), 2, "expected at least 2 expander buttons")
// Deduplicate by anchor param to get one URL per collapsed section
// (updown rows have two buttons with the same anchor but different directions)
seen := map[string]bool{}
var expanderURLs []string
for i := range els.Length() {
link := els.Eq(i).AttrOr("data-url", "")
parsed, err := url.Parse(link)
require.NoError(t, err)
anchor := parsed.Query().Get("anchor")
if !seen[anchor] {
seen[anchor] = true
expanderURLs = append(expanderURLs, link)
}
}
require.GreaterOrEqual(t, len(expanderURLs), 2, "expected at least 2 unique expander sections")
t.Run("SingleFetch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Rewrite direction to "full" as the frontend does for expand-all
singleURL := strings.Replace(expanderURLs[0], "direction=down", "direction=full", 1)
singleURL = strings.Replace(singleURL, "direction=up", "direction=full", 1)
req := NewRequest(t, "GET", singleURL)
resp := session.MakeRequest(t, req, http.StatusOK)
body := resp.Body.String()
// Single mode returns HTML directly, should contain diff table rows
assert.Contains(t, body, `class="lines-`)
assert.NotContains(t, body, `[`) // should not be JSON
})
t.Run("BatchFetch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Parse per-gap params from each expander URL and join with commas
paramKeys := []string{"last_left", "last_right", "left", "right", "left_hunk_size", "right_hunk_size"}
batchValues := make(map[string][]string)
var basePath string
var sharedParams url.Values
for i, expanderURL := range expanderURLs {
parsed, err := url.Parse(expanderURL)
require.NoError(t, err)
if i == 0 {
basePath = parsed.Path
sharedParams = parsed.Query()
}
q := parsed.Query()
for _, key := range paramKeys {
batchValues[key] = append(batchValues[key], q.Get(key))
}
}
// Build batch URL
batchParams := url.Values{}
for _, key := range paramKeys {
batchParams.Set(key, strings.Join(batchValues[key], ","))
}
for _, key := range []string{"path", "filelang", "style"} {
if v := sharedParams.Get(key); v != "" {
batchParams.Set(key, v)
}
}
batchParams.Set("direction", "full")
batchURL := basePath + "?" + batchParams.Encode()
req := NewRequest(t, "GET", batchURL)
resp := session.MakeRequest(t, req, http.StatusOK)
// Batch mode returns a JSON array of HTML strings
var htmlArray []string
err := json.Unmarshal(resp.Body.Bytes(), &htmlArray)
require.NoError(t, err, "response should be valid JSON string array")
assert.Len(t, htmlArray, len(expanderURLs))
for i, html := range htmlArray {
assert.Contains(t, html, `class="lines-`, "batch result %d should contain diff HTML", i)
}
})
t.Run("BatchFetchMismatchedParams", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Build a batch URL with mismatched param lengths — should return 400
parsed, err := url.Parse(expanderURLs[0])
require.NoError(t, err)
q := parsed.Query()
q.Set("last_left", q.Get("last_left")+",0") // 2 values
// other params remain with 1 value
badURL := parsed.Path + "?" + q.Encode()
req := NewRequest(t, "GET", badURL)
session.MakeRequest(t, req, http.StatusBadRequest)
})
})
}

View File

@ -7,10 +7,11 @@ import {initImageDiff} from './imagediff.ts';
import {showErrorToast} from '../modules/toast.ts';
import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts';
import {POST, GET} from '../modules/fetch.ts';
import {createTippy} from '../modules/tippy.ts';
import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
import {invertFileFolding} from './file-fold.ts';
import {parseDom, sleep} from '../utils.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {parseDom} from '../utils.ts';
import {registerGlobalEventFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
import {svg} from '../svg.ts';
function initRepoDiffFileBox(el: HTMLElement) {
// switch between "rendered" and "source", for image and CSV files
@ -24,6 +25,12 @@ function initRepoDiffFileBox(el: HTMLElement) {
hideElem(queryElemSiblings(target));
showElem(target);
}));
// Hide "expand all" button when there are no expandable sections
const expandAllBtn = el.querySelector('.diff-expand-all');
if (expandAllBtn && !el.querySelector('.code-expander-button')) {
hideElem(expandAllBtn);
}
}
function initRepoDiffConversationForm() {
@ -247,12 +254,16 @@ async function onLocationHashChange() {
const commentId = currentHash.substring(issueCommentPrefix.length);
const expandButton = document.querySelector<HTMLElement>(`.code-expander-button[data-hidden-comment-ids*=",${commentId},"]`);
if (expandButton) {
// avoid infinite loop, do not re-click the button if already clicked
// avoid infinite loop, do not re-expand the same button
const attrAutoLoadClicked = 'data-auto-load-clicked';
if (expandButton.hasAttribute(attrAutoLoadClicked)) return;
expandButton.setAttribute(attrAutoLoadClicked, 'true');
expandButton.click();
await sleep(500); // Wait for HTMX to load the content. FIXME: need to drop htmx in the future
try {
await fetchBlobExcerpt(expandButton.closest('tr')!, expandButton.getAttribute('data-url')!);
} catch (err) {
showErrorToast(`Failed to expand: ${err.message}`);
return;
}
continue; // Try again to find the element
}
}
@ -274,6 +285,128 @@ function initRepoDiffHashChangeListener() {
onLocationHashChange();
}
const expandAllSavedState = new Map<string, HTMLElement>();
async function fetchBlobExcerpt(tr: Element, url: string): Promise<void> {
const response = await GET(url);
if (!response.ok) throw new Error(`Failed to fetch blob excerpt: ${response.status}`);
const tempTbody = document.createElement('tbody');
tempTbody.innerHTML = await response.text();
tr.replaceWith(...tempTbody.children);
}
async function expandAllLines(btn: HTMLElement, fileBox: HTMLElement) {
const fileBody = fileBox.querySelector('.diff-file-body');
if (!fileBody) return;
// Save original state for later collapse
const tbody = fileBody.querySelector('table.chroma tbody');
if (!tbody) return;
expandAllSavedState.set(fileBox.id, tbody.cloneNode(true) as HTMLElement);
btn.classList.add('disabled');
try {
const expanders = collectExpanderButtons(fileBody, true);
if (expanders.length === 0) return;
if (expanders.length === 1) {
await fetchBlobExcerpt(expanders[0].tr, expanders[0].url);
} else {
// Batch mode: join per-gap params with commas into a single request
const parsed = expanders.map(({url}) => new URL(url, window.location.origin));
const batchParams = new URLSearchParams();
for (const key of ['last_left', 'last_right', 'left', 'right', 'left_hunk_size', 'right_hunk_size']) {
batchParams.set(key, parsed.map((u) => u.searchParams.get(key) ?? '0').join(','));
}
for (const [key, val] of parsed[0].searchParams) {
if (!batchParams.has(key)) batchParams.set(key, val);
}
batchParams.set('direction', 'full');
const response = await GET(`${parsed[0].pathname}?${batchParams}`);
if (!response.ok) throw new Error(`Failed to fetch blob excerpts: ${response.status}`);
const htmlArray: string[] = await response.json();
for (const [index, html] of htmlArray.entries()) {
const tempTbody = document.createElement('tbody');
tempTbody.innerHTML = html;
expanders[index].tr.replaceWith(...tempTbody.children);
}
}
} catch (err) {
expandAllSavedState.delete(fileBox.id);
showErrorToast(`Failed to expand: ${err.message}`);
return;
} finally {
btn.classList.remove('disabled');
}
// Update button to "collapse" state
btn.innerHTML = svg('octicon-fold', 14);
const collapseTooltip = btn.getAttribute('data-collapse-tooltip')!;
btn.setAttribute('data-tooltip-content', collapseTooltip);
showTemporaryTooltip(btn, collapseTooltip);
}
function collapseExpandedLines(btn: HTMLElement, fileBox: HTMLElement) {
const savedTbody = expandAllSavedState.get(fileBox.id);
if (!savedTbody) return;
const tbody = fileBox.querySelector('.diff-file-body table.chroma tbody');
if (tbody) {
tbody.replaceWith(savedTbody.cloneNode(true));
}
expandAllSavedState.delete(fileBox.id);
// Update button to "expand" state
btn.innerHTML = svg('octicon-unfold', 14);
const expandTooltip = btn.getAttribute('data-expand-tooltip')!;
btn.setAttribute('data-tooltip-content', expandTooltip);
showTemporaryTooltip(btn, expandTooltip);
}
// Collect one expander button per <tr> (skip duplicates from "updown" rows
// that have both up/down buttons targeting the same row).
// When fullExpand is true, the direction parameter is rewritten so the backend
// expands the entire gap in a single response instead of chunk-by-chunk.
function collectExpanderButtons(container: Element, fullExpand?: boolean): {tr: Element, url: string}[] {
const seen = new Set<Element>();
const expanders: {tr: Element, url: string}[] = [];
for (const btn of container.querySelectorAll<HTMLElement>('.code-expander-button')) {
const tr = btn.closest('tr');
let url = btn.getAttribute('data-url');
if (tr && url && !seen.has(tr)) {
seen.add(tr);
if (fullExpand) url = url.replace(/direction=[^&]*/, 'direction=full');
expanders.push({tr, url});
}
}
return expanders;
}
function initDiffExpandAllLines() {
addDelegatedEventListener(document, 'click', '.diff-expand-all', (btn, e) => {
e.preventDefault();
if (btn.classList.contains('disabled')) return;
const fileBox = btn.closest<HTMLElement>('.diff-file-box');
if (!fileBox) return;
if (expandAllSavedState.has(fileBox.id)) {
collapseExpandedLines(btn, fileBox);
} else {
expandAllLines(btn, fileBox);
}
});
registerGlobalEventFunc('click', 'onExpanderButtonClick', async (btn: HTMLElement) => {
try {
await fetchBlobExcerpt(btn.closest('tr')!, btn.getAttribute('data-url')!);
} catch (err) {
showErrorToast(`Failed to expand: ${err.message}`);
}
});
}
export function initRepoDiffView() {
initRepoDiffConversationForm(); // such form appears on the "conversation" page and "diff" page
@ -285,6 +418,7 @@ export function initRepoDiffView() {
initDiffHeaderPopup();
initViewedCheckboxListenerFor();
initExpandAndCollapseFilesButton();
initDiffExpandAllLines();
initRepoDiffHashChangeListener();
registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);

View File

@ -34,6 +34,7 @@ import octiconFileDirectoryOpenFill from '../../public/assets/img/svg/octicon-fi
import octiconFileSubmodule from '../../public/assets/img/svg/octicon-file-submodule.svg';
import octiconFileSymlinkFile from '../../public/assets/img/svg/octicon-file-symlink-file.svg';
import octiconFilter from '../../public/assets/img/svg/octicon-filter.svg';
import octiconFold from '../../public/assets/img/svg/octicon-fold.svg';
import octiconGear from '../../public/assets/img/svg/octicon-gear.svg';
import octiconGitBranch from '../../public/assets/img/svg/octicon-git-branch.svg';
import octiconGitCommit from '../../public/assets/img/svg/octicon-git-commit.svg';
@ -78,6 +79,7 @@ import octiconTable from '../../public/assets/img/svg/octicon-table.svg';
import octiconTag from '../../public/assets/img/svg/octicon-tag.svg';
import octiconTrash from '../../public/assets/img/svg/octicon-trash.svg';
import octiconTriangleDown from '../../public/assets/img/svg/octicon-triangle-down.svg';
import octiconUnfold from '../../public/assets/img/svg/octicon-unfold.svg';
import octiconX from '../../public/assets/img/svg/octicon-x.svg';
import octiconXCircleFill from '../../public/assets/img/svg/octicon-x-circle-fill.svg';
import octiconZoomIn from '../../public/assets/img/svg/octicon-zoom-in.svg';
@ -117,6 +119,7 @@ const svgs = {
'octicon-file-submodule': octiconFileSubmodule,
'octicon-file-symlink-file': octiconFileSymlinkFile,
'octicon-filter': octiconFilter,
'octicon-fold': octiconFold,
'octicon-gear': octiconGear,
'octicon-git-branch': octiconGitBranch,
'octicon-git-commit': octiconGitCommit,
@ -161,6 +164,7 @@ const svgs = {
'octicon-tag': octiconTag,
'octicon-trash': octiconTrash,
'octicon-triangle-down': octiconTriangleDown,
'octicon-unfold': octiconUnfold,
'octicon-x': octiconX,
'octicon-x-circle-fill': octiconXCircleFill,
'octicon-zoom-in': octiconZoomIn,