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:
commit
92ef59794f
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user