detached 4cc0ba1

Merge 55fcc8fd17ec2d8214aafd64e606433db222abb7 into fe192203ee99178ad1bcbf9cd0c6e9b8e02cf667

commit
4cc0ba15fbc4842cb68c9605209fc05db2665cf4
author
ermite · · 2026-10-20 09:21 UTC
merge of
fe19220 + 55fcc8f · diff against the first parent
signature
unsigned
61 files · +2371 -675
M docs/merge-requests.md +8 -0
@@ -63,6 +63,14 @@ In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `c
6363
6464 In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.
6565
66+## Large diffs on the web
67+
68+The merge request, commit and compare pages list every changed file. Generated files (lockfiles, `vendor/`, `node_modules/`, minified or generated code) and large files (over 2,000 changed lines) are collapsed: the header and the counts show, the lines do not. "load diff" fetches that one file in place, in full, and you can comment on its lines. Without scripts the link reloads the page with that file loaded. After the first 25 files of a merge request, the other files load as you scroll.
69+
70+A commit page reads the hunks of its first 300 files with the page; the other files are listed collapsed and load one at a time. A file that cannot be shown in the browser, over 8 MiB of text or over 50,000 changed lines when loaded alone, has no "load diff": the page links to its raw blob instead, and its lines take no comment.
71+
72+The API and `mr show --diff --json` keep their limits (300 files, 2,000 changed lines per file, 20,000 in total, 8 MiB). A file read without its hunks has `truncated` set and `omitted` giving the reason: `large`, `budget` (the earlier files used the limits) or `unavailable` (over 8 MiB of text, or a line over 64 KiB, even alone). Files past the 300th are not listed and `diff_truncated` is set.
73+
6674 ## Notes
6775
6876 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
@@ -63,6 +63,14 @@ In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `c
63 63
64 In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.64 In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.
65 65
66+## Large diffs on the web
67+
68+The merge request, commit and compare pages list every changed file. Generated files (lockfiles, `vendor/`, `node_modules/`, minified or generated code) and large files (over 2,000 changed lines) are collapsed: the header and the counts show, the lines do not. "load diff" fetches that one file in place, in full, and you can comment on its lines. Without scripts the link reloads the page with that file loaded. After the first 25 files of a merge request, the other files load as you scroll.
69+
70+A commit page reads the hunks of its first 300 files with the page; the other files are listed collapsed and load one at a time. A file that cannot be shown in the browser, over 8 MiB of text or over 50,000 changed lines when loaded alone, has no "load diff": the page links to its raw blob instead, and its lines take no comment.
71+
72+The API and `mr show --diff --json` keep their limits (300 files, 2,000 changed lines per file, 20,000 in total, 8 MiB). A file read without its hunks has `truncated` set and `omitted` giving the reason: `large`, `budget` (the earlier files used the limits) or `unavailable` (over 8 MiB of text, or a line over 64 KiB, even alone). Files past the 300th are not listed and `diff_truncated` is set.
73+
66 ## Notes74 ## Notes
67 75
68 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.76 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.10.0
16+ bnema.dev/ermite-api v0.11.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.10.016+ bnema.dev/ermite-api v0.11.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/gitcli/diff.go +38 -30
@@ -88,8 +88,6 @@ type rawEntry struct {
8888 }
8989
9090 // readDiff parses the output of diff-tree --raw --numstat -p.
91-//
92-//hexcheck:ignore suspicious-business-logic-in-adapter streams raw, numstat and patch sections; domain.DiffBudget decides window and hunk eligibility and accounts retained lines and bytes; adapter only skips sections and stops the process
9391 func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {
9492 entries, err := readRaw(r)
9593 if err != nil {
@@ -115,7 +113,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
115113 // rename, mode change) have a section without hunks.
116114 lastRead := -1
117115 for i := from; i < to; i++ {
118- if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) {
116+ if read, _ := budget.Admit(entries[i].file.Binary, entries[i].file.CommentPath(), entries[i].added+entries[i].del); read {
119117 lastRead = i
120118 }
121119 }
@@ -134,19 +132,24 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
134132 sections = 2
135133 }
136134 inWindow := i >= from && i < to
135+ read, why := false, domain.OmitNone
136+ if inWindow {
137+ read, why = pr.budget.Admit(e.file.Binary, e.file.CommentPath(), e.added+e.del)
138+ }
137139 switch {
138- case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del):
140+ case !inWindow:
139141 // Nothing to show.
140- case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del):
141- e.file.Truncated = true
142+ case !read:
143+ e.file.Omitted = why
142144 default:
143145 before := pr.budget
144- hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines())
146+ pr.budget.StartFile()
147+ hunks, _, why, err := pr.file(sections, pr.budget.FileLines())
145148 if err != nil {
146149 return domain.Diff{}, domain.DiffStats{}, err
147150 }
148- e.file.Hunks, e.file.Truncated = hunks, truncated
149- if truncated {
151+ e.file.Hunks, e.file.Omitted = hunks, why
152+ if why != domain.OmitNone {
150153 pr.budget = before // nothing of this file is kept
151154 }
152155 sections = 0
@@ -156,7 +159,6 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
156159 return domain.Diff{}, domain.DiffStats{}, err
157160 }
158161 }
159- diff.Truncated = diff.Truncated || e.file.Truncated
160162 diff.Files = append(diff.Files, e.file)
161163 }
162164 if lastRead < len(entries)-1 {
@@ -379,55 +381,56 @@ func (p *patchReader) skip(n int) error {
379381 }
380382
381383 // file reads the sections of one file and returns its hunks, the number of
382-// hunk lines, and whether it exceeded max lines or had a line over
383-// maxDiffLine. When truncated, the rest of the file is skipped.
384-func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {
384+// changed lines, and why it was omitted: it exceeded max changed lines or the
385+// budget, or had a line over maxDiffLine. When omitted, the rest of the file is
386+// skipped.
387+func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
385388 for done := range sections {
386389 ok, err := p.enterSection()
387390 if err != nil {
388- return nil, 0, false, err
391+ return nil, 0, domain.OmitNone, err
389392 }
390393 if !ok {
391394 break
392395 }
393- h, n, trunc, err := p.section(max - lines)
396+ h, n, why, err := p.section(max - lines)
394397 if err != nil {
395- return nil, 0, false, err
398+ return nil, 0, domain.OmitNone, err
396399 }
397400 hunks = append(hunks, h...)
398401 lines += n
399- if trunc {
402+ if why != domain.OmitNone {
400403 // Leave the stream at the start of the next file.
401- return nil, lines, true, p.skip(sections - done - 1)
404+ return nil, lines, why, p.skip(sections - done - 1)
402405 }
403406 }
404- return hunks, lines, false, nil
407+ return hunks, lines, domain.OmitNone, nil
405408 }
406409
407410 // section reads the hunks of the current section up to the next marker, which
408-// stays pending. It gives up (truncated) after more than max lines.
409-func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {
411+// stays pending. It gives up (with a reason) after more than max changed lines.
412+func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
410413 var cur *domain.Hunk
411414 var oldN, newN int
412415 for {
413416 line, ok, err := p.line()
414417 if err != nil {
415- return nil, 0, false, err
418+ return nil, 0, domain.OmitNone, err
416419 }
417420 if !ok {
418- return hunks, lines, false, nil
421+ return hunks, lines, domain.OmitNone, nil
419422 }
420423 if bytes.HasPrefix(line, []byte(sectionMarker)) {
421424 p.unread(line)
422- return hunks, lines, false, nil
425+ return hunks, lines, domain.OmitNone, nil
423426 }
424427 if p.cut {
425- return nil, lines, true, nil
428+ return nil, lines, domain.OmitUnavailable, nil
426429 }
427430 if bytes.HasPrefix(line, []byte("@@ ")) {
428431 h, err := parseHunkHeader(string(line))
429432 if err != nil {
430- return nil, 0, false, err
433+ return nil, 0, domain.OmitNone, err
431434 }
432435 hunks = append(hunks, h)
433436 cur = &hunks[len(hunks)-1]
@@ -459,10 +462,15 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
459462 default:
460463 continue
461464 }
462- lines++
463- fits := p.budget.ChargeLine(len(text))
464- if lines > max || !fits {
465- return nil, lines, true, nil
465+ changed := l.Kind == domain.LineAddition || l.Kind == domain.LineDeletion
466+ if changed {
467+ lines++
468+ }
469+ if why := p.budget.ChargeLine(len(text), changed); why != domain.OmitNone {
470+ return nil, lines, why, nil
471+ }
472+ if lines > max {
473+ return nil, lines, domain.OmitLarge, nil
466474 }
467475 cur.Lines = append(cur.Lines, l)
468476 }
@@ -88,8 +88,6 @@ type rawEntry struct {
88 }88 }
89 89
90 // readDiff parses the output of diff-tree --raw --numstat -p.90 // readDiff parses the output of diff-tree --raw --numstat -p.
91-//
92-//hexcheck:ignore suspicious-business-logic-in-adapter streams raw, numstat and patch sections; domain.DiffBudget decides window and hunk eligibility and accounts retained lines and bytes; adapter only skips sections and stops the process
93 func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {91 func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {
94 entries, err := readRaw(r)92 entries, err := readRaw(r)
95 if err != nil {93 if err != nil {
@@ -115,7 +113,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
115 // rename, mode change) have a section without hunks.113 // rename, mode change) have a section without hunks.
116 lastRead := -1114 lastRead := -1
117 for i := from; i < to; i++ {115 for i := from; i < to; i++ {
118- if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) {116+ if read, _ := budget.Admit(entries[i].file.Binary, entries[i].file.CommentPath(), entries[i].added+entries[i].del); read {
119 lastRead = i117 lastRead = i
120 }118 }
121 }119 }
@@ -134,19 +132,24 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
134 sections = 2132 sections = 2
135 }133 }
136 inWindow := i >= from && i < to134 inWindow := i >= from && i < to
135+ read, why := false, domain.OmitNone
136+ if inWindow {
137+ read, why = pr.budget.Admit(e.file.Binary, e.file.CommentPath(), e.added+e.del)
138+ }
137 switch {139 switch {
138- case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del):140+ case !inWindow:
139 // Nothing to show.141 // Nothing to show.
140- case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del):142+ case !read:
141- e.file.Truncated = true143+ e.file.Omitted = why
142 default:144 default:
143 before := pr.budget145 before := pr.budget
144- hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines())146+ pr.budget.StartFile()
147+ hunks, _, why, err := pr.file(sections, pr.budget.FileLines())
145 if err != nil {148 if err != nil {
146 return domain.Diff{}, domain.DiffStats{}, err149 return domain.Diff{}, domain.DiffStats{}, err
147 }150 }
148- e.file.Hunks, e.file.Truncated = hunks, truncated151+ e.file.Hunks, e.file.Omitted = hunks, why
149- if truncated {152+ if why != domain.OmitNone {
150 pr.budget = before // nothing of this file is kept153 pr.budget = before // nothing of this file is kept
151 }154 }
152 sections = 0155 sections = 0
@@ -156,7 +159,6 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
156 return domain.Diff{}, domain.DiffStats{}, err159 return domain.Diff{}, domain.DiffStats{}, err
157 }160 }
158 }161 }
159- diff.Truncated = diff.Truncated || e.file.Truncated
160 diff.Files = append(diff.Files, e.file)162 diff.Files = append(diff.Files, e.file)
161 }163 }
162 if lastRead < len(entries)-1 {164 if lastRead < len(entries)-1 {
@@ -379,55 +381,56 @@ func (p *patchReader) skip(n int) error {
379 }381 }
380 382
381 // file reads the sections of one file and returns its hunks, the number of383 // file reads the sections of one file and returns its hunks, the number of
382-// hunk lines, and whether it exceeded max lines or had a line over384+// changed lines, and why it was omitted: it exceeded max changed lines or the
383-// maxDiffLine. When truncated, the rest of the file is skipped.385+// budget, or had a line over maxDiffLine. When omitted, the rest of the file is
384-func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {386+// skipped.
387+func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
385 for done := range sections {388 for done := range sections {
386 ok, err := p.enterSection()389 ok, err := p.enterSection()
387 if err != nil {390 if err != nil {
388- return nil, 0, false, err391+ return nil, 0, domain.OmitNone, err
389 }392 }
390 if !ok {393 if !ok {
391 break394 break
392 }395 }
393- h, n, trunc, err := p.section(max - lines)396+ h, n, why, err := p.section(max - lines)
394 if err != nil {397 if err != nil {
395- return nil, 0, false, err398+ return nil, 0, domain.OmitNone, err
396 }399 }
397 hunks = append(hunks, h...)400 hunks = append(hunks, h...)
398 lines += n401 lines += n
399- if trunc {402+ if why != domain.OmitNone {
400 // Leave the stream at the start of the next file.403 // Leave the stream at the start of the next file.
401- return nil, lines, true, p.skip(sections - done - 1)404+ return nil, lines, why, p.skip(sections - done - 1)
402 }405 }
403 }406 }
404- return hunks, lines, false, nil407+ return hunks, lines, domain.OmitNone, nil
405 }408 }
406 409
407 // section reads the hunks of the current section up to the next marker, which410 // section reads the hunks of the current section up to the next marker, which
408-// stays pending. It gives up (truncated) after more than max lines.411+// stays pending. It gives up (with a reason) after more than max changed lines.
409-func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {412+func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
410 var cur *domain.Hunk413 var cur *domain.Hunk
411 var oldN, newN int414 var oldN, newN int
412 for {415 for {
413 line, ok, err := p.line()416 line, ok, err := p.line()
414 if err != nil {417 if err != nil {
415- return nil, 0, false, err418+ return nil, 0, domain.OmitNone, err
416 }419 }
417 if !ok {420 if !ok {
418- return hunks, lines, false, nil421+ return hunks, lines, domain.OmitNone, nil
419 }422 }
420 if bytes.HasPrefix(line, []byte(sectionMarker)) {423 if bytes.HasPrefix(line, []byte(sectionMarker)) {
421 p.unread(line)424 p.unread(line)
422- return hunks, lines, false, nil425+ return hunks, lines, domain.OmitNone, nil
423 }426 }
424 if p.cut {427 if p.cut {
425- return nil, lines, true, nil428+ return nil, lines, domain.OmitUnavailable, nil
426 }429 }
427 if bytes.HasPrefix(line, []byte("@@ ")) {430 if bytes.HasPrefix(line, []byte("@@ ")) {
428 h, err := parseHunkHeader(string(line))431 h, err := parseHunkHeader(string(line))
429 if err != nil {432 if err != nil {
430- return nil, 0, false, err433+ return nil, 0, domain.OmitNone, err
431 }434 }
432 hunks = append(hunks, h)435 hunks = append(hunks, h)
433 cur = &hunks[len(hunks)-1]436 cur = &hunks[len(hunks)-1]
@@ -459,10 +462,15 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
459 default:462 default:
460 continue463 continue
461 }464 }
462- lines++465+ changed := l.Kind == domain.LineAddition || l.Kind == domain.LineDeletion
463- fits := p.budget.ChargeLine(len(text))466+ if changed {
464- if lines > max || !fits {467+ lines++
465- return nil, lines, true, nil468+ }
469+ if why := p.budget.ChargeLine(len(text), changed); why != domain.OmitNone {
470+ return nil, lines, why, nil
471+ }
472+ if lines > max {
473+ return nil, lines, domain.OmitLarge, nil
466 }474 }
467 cur.Lines = append(cur.Lines, l)475 cur.Lines = append(cur.Lines, l)
468 }476 }
M internal/adapters/gitcli/diff_test.go +40 -38
@@ -177,7 +177,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
177177 assert.Equal(t, domain.FileModified, bin.Status)
178178 assert.True(t, bin.Binary)
179179 assert.Empty(t, bin.Hunks)
180- assert.False(t, bin.Truncated)
180+ assert.Equal(t, domain.OmitNone, bin.Omitted)
181181
182182 mode := files["d/f>d/f"]
183183 assert.Equal(t, domain.FileModified, mode.Status)
@@ -185,7 +185,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
185185 assert.Equal(t, uint32(0o100755), mode.NewMode)
186186 assert.Empty(t, mode.Hunks)
187187 assert.False(t, mode.Binary)
188- assert.False(t, mode.Truncated)
188+ assert.Equal(t, domain.OmitNone, mode.Omitted)
189189
190190 del := files["README.md>"]
191191 assert.Equal(t, domain.FileDeleted, del.Status)
@@ -315,22 +315,22 @@ func TestDiffLimits(t *testing.T) {
315315 require.NoError(t, err)
316316 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")
317317
318- assert.False(t, exact.Truncated)
318+ assert.Equal(t, domain.OmitNone, exact.Omitted)
319319 require.Len(t, exact.Hunks, 1)
320320 assert.Len(t, exact.Hunks[0].Lines, 2000)
321- assert.True(t, over.Truncated)
321+ assert.NotEqual(t, domain.OmitNone, over.Omitted)
322322 assert.Empty(t, over.Hunks)
323323 assert.Equal(t, 2001, over.Additions, "counts stay accurate")
324- assert.False(t, small.Truncated)
324+ assert.Equal(t, domain.OmitNone, small.Omitted)
325325 assert.Len(t, small.Hunks, 1)
326- assert.True(t, d.Diff.Truncated)
326+ assert.False(t, d.Diff.Truncated)
327327 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)
328328 })
329329
330- t.Run("context counts towards the file limit", func(t *testing.T) {
330+ t.Run("context does not count towards the file limit", func(t *testing.T) {
331331 f := readertest.NewFixture(t, data, "limit-context")
332332 f.Lines("big", "l", 3000).Commit("v1")
333- // Every 7th line changes: ~428 changed lines, but the hunks merge
333+ // Every 7th line changes: 429 additions plus 429 deletions, but the hunks merge
334334 // into one that spans the file with its context.
335335 var b strings.Builder
336336 for i := range 3000 {
@@ -346,11 +346,13 @@ func TestDiffLimits(t *testing.T) {
346346 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
347347 require.NoError(t, err)
348348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
349- assert.True(t, big.Truncated)
350- assert.Empty(t, big.Hunks)
349+ assert.Equal(t, domain.OmitNone, big.Omitted)
350+ require.Len(t, big.Hunks, 1)
351+ assert.Len(t, big.Hunks[0].Lines, 3000+429)
352+ assert.False(t, d.Diff.Truncated)
351353 assert.Equal(t, 429, big.Additions)
352354 assert.Equal(t, 429, big.Deletions)
353- assert.False(t, tiny.Truncated, "the file after a truncated one is still read")
355+ assert.Equal(t, domain.OmitNone, tiny.Omitted, "the following file still fits the changed-line budget")
354356 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))
355357 assert.Equal(t, id, d.Commit.ID.String())
356358 })
@@ -376,13 +378,13 @@ func TestDiffLimits(t *testing.T) {
376378 }
377379 assert.LessOrEqual(t, total, 20000)
378380 for i := range 10 {
379- assert.False(t, find(d.Diff, fmt.Sprintf("f%02d", i)).Truncated)
381+ assert.Equal(t, domain.OmitNone, find(d.Diff, fmt.Sprintf("f%02d", i)).Omitted)
380382 }
381- assert.True(t, find(d.Diff, "f10").Truncated, "1,900 lines do not fit in the 1,000 left")
382- assert.False(t, find(d.Diff, "g-small").Truncated)
383- assert.True(t, find(d.Diff, "h-big").Truncated)
384- assert.False(t, find(d.Diff, "i-small").Truncated)
385- assert.True(t, d.Diff.Truncated)
383+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "f10").Omitted, "1,900 lines do not fit in the 1,000 left")
384+ assert.Equal(t, domain.OmitNone, find(d.Diff, "g-small").Omitted)
385+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "h-big").Omitted)
386+ assert.Equal(t, domain.OmitNone, find(d.Diff, "i-small").Omitted)
387+ assert.False(t, d.Diff.Truncated)
386388 assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)
387389 assert.Equal(t, 14, d.Stats.FilesChanged)
388390 })
@@ -401,7 +403,7 @@ func TestDiffLimits(t *testing.T) {
401403 assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)
402404 assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)
403405 for _, file := range d.Diff.Files {
404- assert.False(t, file.Truncated)
406+ assert.Equal(t, domain.OmitNone, file.Omitted)
405407 assert.Len(t, file.Hunks, 1)
406408 }
407409 })
@@ -424,16 +426,16 @@ func TestDiffLimits(t *testing.T) {
424426 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})
425427 require.NoError(t, err)
426428 require.Len(t, d.Diff.Files, 2)
427- assert.False(t, d.Diff.Files[0].Truncated)
428- assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left")
429+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
430+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "10 lines do not fit in the 5 left")
429431 assert.Equal(t, 3, d.Stats.FilesChanged)
430432
431433 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})
432434 require.NoError(t, err)
433435 require.Len(t, d.Diff.Files, 3)
434- assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50")
436+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "30 bytes of text fit in 50")
435437 for _, file := range d.Diff.Files[1:] {
436- assert.True(t, file.Truncated, "the byte budget has 20 bytes left for 30")
438+ assert.NotEqual(t, domain.OmitNone, file.Omitted, "the byte budget has 20 bytes left for 30")
437439 assert.Empty(t, file.Hunks)
438440 }
439441 })
@@ -444,9 +446,9 @@ func TestDiffLimits(t *testing.T) {
444446 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
445447 require.NoError(t, err)
446448 long, short := find(d.Diff, "long"), find(d.Diff, "short")
447- assert.True(t, long.Truncated)
449+ assert.NotEqual(t, domain.OmitNone, long.Omitted)
448450 assert.Empty(t, long.Hunks)
449- assert.False(t, short.Truncated)
451+ assert.Equal(t, domain.OmitNone, short.Omitted)
450452 assert.Len(t, short.Hunks, 1)
451453 })
452454
@@ -458,10 +460,10 @@ func TestDiffLimits(t *testing.T) {
458460 id := f.Commit("v2")
459461 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
460462 require.NoError(t, err)
461- assert.True(t, find(d.Diff, "a-big").Truncated)
463+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "a-big").Omitted)
462464 assert.True(t, find(d.Diff, "b-bin").Binary)
463- assert.False(t, find(d.Diff, "b-bin").Truncated)
464- assert.False(t, find(d.Diff, "c-mode").Truncated)
465+ assert.Equal(t, domain.OmitNone, find(d.Diff, "b-bin").Omitted)
466+ assert.Equal(t, domain.OmitNone, find(d.Diff, "c-mode").Omitted)
465467 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)
466468 // What is kept matches git.
467469 sections := showSections(f, id)
@@ -480,8 +482,8 @@ func TestGetCommitStopsGitEarly(t *testing.T) {
480482 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
481483 require.NoError(t, err)
482484 require.Len(t, d.Diff.Files, 2)
483- assert.False(t, d.Diff.Files[0].Truncated)
484- assert.True(t, d.Diff.Files[1].Truncated)
485+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
486+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted)
485487 assert.Equal(t, 100005, d.Stats.Additions)
486488 }
487489
@@ -580,23 +582,23 @@ func TestDiffLimitsWindow(t *testing.T) {
580582 for i, file := range d.Diff.Files {
581583 switch i {
582584 case 3:
583- assert.False(t, file.Truncated)
585+ assert.Equal(t, domain.OmitNone, file.Omitted)
584586 assert.Len(t, file.Hunks, 1, "10 lines fit in 15")
585587 case 4:
586- assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left")
588+ assert.NotEqual(t, domain.OmitNone, file.Omitted, "the second file of the window does not fit in the 5 lines left")
587589 default:
588- assert.False(t, file.Truncated)
590+ assert.Equal(t, domain.OmitNone, file.Omitted)
589591 assert.Empty(t, file.Hunks)
590592 assert.Equal(t, 10, file.Additions, "counts stay accurate")
591593 }
592594 }
593- assert.True(t, d.Diff.Truncated, "a file of the window is truncated")
595+ assert.False(t, d.Diff.Truncated, "an omitted file does not set Diff.Truncated")
594596
595597 limits.From, limits.To = 0, 0
596598 limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file
597599 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
598600 require.NoError(t, err)
599- assert.True(t, d.Diff.Files[0].Truncated)
601+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[0].Omitted)
600602 })
601603
602604 t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {
@@ -608,14 +610,14 @@ func TestDiffLimitsWindow(t *testing.T) {
608610 require.NoError(t, err)
609611 require.Len(t, d.Diff.Files, 2)
610612 assert.Len(t, d.Diff.Files[0].Hunks, 1)
611- assert.False(t, d.Diff.Files[1].Truncated)
613+ assert.Equal(t, domain.OmitNone, d.Diff.Files[1].Omitted)
612614 assert.Empty(t, d.Diff.Files[1].Hunks)
613615 assert.False(t, d.Diff.Truncated)
614616 limits.From, limits.To = 1, 2
615617 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
616618 require.NoError(t, err)
617- assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit")
618- assert.True(t, d.Diff.Truncated)
619+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "in the window, over the per-file limit")
620+ assert.False(t, d.Diff.Truncated)
619621 })
620622
621623 t.Run("MaxFiles 0 lists every file", func(t *testing.T) {
@@ -650,7 +652,7 @@ func TestDiffLimitsWindow(t *testing.T) {
650652 } else {
651653 assert.Empty(t, file.Hunks)
652654 }
653- assert.False(t, file.Truncated)
655+ assert.Equal(t, domain.OmitNone, file.Omitted)
654656 }
655657
656658 // The default limits still cut at 300.
@@ -177,7 +177,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
177 assert.Equal(t, domain.FileModified, bin.Status)177 assert.Equal(t, domain.FileModified, bin.Status)
178 assert.True(t, bin.Binary)178 assert.True(t, bin.Binary)
179 assert.Empty(t, bin.Hunks)179 assert.Empty(t, bin.Hunks)
180- assert.False(t, bin.Truncated)180+ assert.Equal(t, domain.OmitNone, bin.Omitted)
181 181
182 mode := files["d/f>d/f"]182 mode := files["d/f>d/f"]
183 assert.Equal(t, domain.FileModified, mode.Status)183 assert.Equal(t, domain.FileModified, mode.Status)
@@ -185,7 +185,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
185 assert.Equal(t, uint32(0o100755), mode.NewMode)185 assert.Equal(t, uint32(0o100755), mode.NewMode)
186 assert.Empty(t, mode.Hunks)186 assert.Empty(t, mode.Hunks)
187 assert.False(t, mode.Binary)187 assert.False(t, mode.Binary)
188- assert.False(t, mode.Truncated)188+ assert.Equal(t, domain.OmitNone, mode.Omitted)
189 189
190 del := files["README.md>"]190 del := files["README.md>"]
191 assert.Equal(t, domain.FileDeleted, del.Status)191 assert.Equal(t, domain.FileDeleted, del.Status)
@@ -315,22 +315,22 @@ func TestDiffLimits(t *testing.T) {
315 require.NoError(t, err)315 require.NoError(t, err)
316 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")316 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")
317 317
318- assert.False(t, exact.Truncated)318+ assert.Equal(t, domain.OmitNone, exact.Omitted)
319 require.Len(t, exact.Hunks, 1)319 require.Len(t, exact.Hunks, 1)
320 assert.Len(t, exact.Hunks[0].Lines, 2000)320 assert.Len(t, exact.Hunks[0].Lines, 2000)
321- assert.True(t, over.Truncated)321+ assert.NotEqual(t, domain.OmitNone, over.Omitted)
322 assert.Empty(t, over.Hunks)322 assert.Empty(t, over.Hunks)
323 assert.Equal(t, 2001, over.Additions, "counts stay accurate")323 assert.Equal(t, 2001, over.Additions, "counts stay accurate")
324- assert.False(t, small.Truncated)324+ assert.Equal(t, domain.OmitNone, small.Omitted)
325 assert.Len(t, small.Hunks, 1)325 assert.Len(t, small.Hunks, 1)
326- assert.True(t, d.Diff.Truncated)326+ assert.False(t, d.Diff.Truncated)
327 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)327 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)
328 })328 })
329 329
330- t.Run("context counts towards the file limit", func(t *testing.T) {330+ t.Run("context does not count towards the file limit", func(t *testing.T) {
331 f := readertest.NewFixture(t, data, "limit-context")331 f := readertest.NewFixture(t, data, "limit-context")
332 f.Lines("big", "l", 3000).Commit("v1")332 f.Lines("big", "l", 3000).Commit("v1")
333- // Every 7th line changes: ~428 changed lines, but the hunks merge333+ // Every 7th line changes: 429 additions plus 429 deletions, but the hunks merge
334 // into one that spans the file with its context.334 // into one that spans the file with its context.
335 var b strings.Builder335 var b strings.Builder
336 for i := range 3000 {336 for i := range 3000 {
@@ -346,11 +346,13 @@ func TestDiffLimits(t *testing.T) {
346 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)346 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
347 require.NoError(t, err)347 require.NoError(t, err)
348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
349- assert.True(t, big.Truncated)349+ assert.Equal(t, domain.OmitNone, big.Omitted)
350- assert.Empty(t, big.Hunks)350+ require.Len(t, big.Hunks, 1)
351+ assert.Len(t, big.Hunks[0].Lines, 3000+429)
352+ assert.False(t, d.Diff.Truncated)
351 assert.Equal(t, 429, big.Additions)353 assert.Equal(t, 429, big.Additions)
352 assert.Equal(t, 429, big.Deletions)354 assert.Equal(t, 429, big.Deletions)
353- assert.False(t, tiny.Truncated, "the file after a truncated one is still read")355+ assert.Equal(t, domain.OmitNone, tiny.Omitted, "the following file still fits the changed-line budget")
354 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))356 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))
355 assert.Equal(t, id, d.Commit.ID.String())357 assert.Equal(t, id, d.Commit.ID.String())
356 })358 })
@@ -376,13 +378,13 @@ func TestDiffLimits(t *testing.T) {
376 }378 }
377 assert.LessOrEqual(t, total, 20000)379 assert.LessOrEqual(t, total, 20000)
378 for i := range 10 {380 for i := range 10 {
379- assert.False(t, find(d.Diff, fmt.Sprintf("f%02d", i)).Truncated)381+ assert.Equal(t, domain.OmitNone, find(d.Diff, fmt.Sprintf("f%02d", i)).Omitted)
380 }382 }
381- assert.True(t, find(d.Diff, "f10").Truncated, "1,900 lines do not fit in the 1,000 left")383+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "f10").Omitted, "1,900 lines do not fit in the 1,000 left")
382- assert.False(t, find(d.Diff, "g-small").Truncated)384+ assert.Equal(t, domain.OmitNone, find(d.Diff, "g-small").Omitted)
383- assert.True(t, find(d.Diff, "h-big").Truncated)385+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "h-big").Omitted)
384- assert.False(t, find(d.Diff, "i-small").Truncated)386+ assert.Equal(t, domain.OmitNone, find(d.Diff, "i-small").Omitted)
385- assert.True(t, d.Diff.Truncated)387+ assert.False(t, d.Diff.Truncated)
386 assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)388 assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)
387 assert.Equal(t, 14, d.Stats.FilesChanged)389 assert.Equal(t, 14, d.Stats.FilesChanged)
388 })390 })
@@ -401,7 +403,7 @@ func TestDiffLimits(t *testing.T) {
401 assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)403 assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)
402 assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)404 assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)
403 for _, file := range d.Diff.Files {405 for _, file := range d.Diff.Files {
404- assert.False(t, file.Truncated)406+ assert.Equal(t, domain.OmitNone, file.Omitted)
405 assert.Len(t, file.Hunks, 1)407 assert.Len(t, file.Hunks, 1)
406 }408 }
407 })409 })
@@ -424,16 +426,16 @@ func TestDiffLimits(t *testing.T) {
424 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})426 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})
425 require.NoError(t, err)427 require.NoError(t, err)
426 require.Len(t, d.Diff.Files, 2)428 require.Len(t, d.Diff.Files, 2)
427- assert.False(t, d.Diff.Files[0].Truncated)429+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
428- assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left")430+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "10 lines do not fit in the 5 left")
429 assert.Equal(t, 3, d.Stats.FilesChanged)431 assert.Equal(t, 3, d.Stats.FilesChanged)
430 432
431 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})433 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})
432 require.NoError(t, err)434 require.NoError(t, err)
433 require.Len(t, d.Diff.Files, 3)435 require.Len(t, d.Diff.Files, 3)
434- assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50")436+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "30 bytes of text fit in 50")
435 for _, file := range d.Diff.Files[1:] {437 for _, file := range d.Diff.Files[1:] {
436- assert.True(t, file.Truncated, "the byte budget has 20 bytes left for 30")438+ assert.NotEqual(t, domain.OmitNone, file.Omitted, "the byte budget has 20 bytes left for 30")
437 assert.Empty(t, file.Hunks)439 assert.Empty(t, file.Hunks)
438 }440 }
439 })441 })
@@ -444,9 +446,9 @@ func TestDiffLimits(t *testing.T) {
444 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)446 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
445 require.NoError(t, err)447 require.NoError(t, err)
446 long, short := find(d.Diff, "long"), find(d.Diff, "short")448 long, short := find(d.Diff, "long"), find(d.Diff, "short")
447- assert.True(t, long.Truncated)449+ assert.NotEqual(t, domain.OmitNone, long.Omitted)
448 assert.Empty(t, long.Hunks)450 assert.Empty(t, long.Hunks)
449- assert.False(t, short.Truncated)451+ assert.Equal(t, domain.OmitNone, short.Omitted)
450 assert.Len(t, short.Hunks, 1)452 assert.Len(t, short.Hunks, 1)
451 })453 })
452 454
@@ -458,10 +460,10 @@ func TestDiffLimits(t *testing.T) {
458 id := f.Commit("v2")460 id := f.Commit("v2")
459 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)461 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
460 require.NoError(t, err)462 require.NoError(t, err)
461- assert.True(t, find(d.Diff, "a-big").Truncated)463+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "a-big").Omitted)
462 assert.True(t, find(d.Diff, "b-bin").Binary)464 assert.True(t, find(d.Diff, "b-bin").Binary)
463- assert.False(t, find(d.Diff, "b-bin").Truncated)465+ assert.Equal(t, domain.OmitNone, find(d.Diff, "b-bin").Omitted)
464- assert.False(t, find(d.Diff, "c-mode").Truncated)466+ assert.Equal(t, domain.OmitNone, find(d.Diff, "c-mode").Omitted)
465 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)467 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)
466 // What is kept matches git.468 // What is kept matches git.
467 sections := showSections(f, id)469 sections := showSections(f, id)
@@ -480,8 +482,8 @@ func TestGetCommitStopsGitEarly(t *testing.T) {
480 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())482 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
481 require.NoError(t, err)483 require.NoError(t, err)
482 require.Len(t, d.Diff.Files, 2)484 require.Len(t, d.Diff.Files, 2)
483- assert.False(t, d.Diff.Files[0].Truncated)485+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
484- assert.True(t, d.Diff.Files[1].Truncated)486+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted)
485 assert.Equal(t, 100005, d.Stats.Additions)487 assert.Equal(t, 100005, d.Stats.Additions)
486 }488 }
487 489
@@ -580,23 +582,23 @@ func TestDiffLimitsWindow(t *testing.T) {
580 for i, file := range d.Diff.Files {582 for i, file := range d.Diff.Files {
581 switch i {583 switch i {
582 case 3:584 case 3:
583- assert.False(t, file.Truncated)585+ assert.Equal(t, domain.OmitNone, file.Omitted)
584 assert.Len(t, file.Hunks, 1, "10 lines fit in 15")586 assert.Len(t, file.Hunks, 1, "10 lines fit in 15")
585 case 4:587 case 4:
586- assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left")588+ assert.NotEqual(t, domain.OmitNone, file.Omitted, "the second file of the window does not fit in the 5 lines left")
587 default:589 default:
588- assert.False(t, file.Truncated)590+ assert.Equal(t, domain.OmitNone, file.Omitted)
589 assert.Empty(t, file.Hunks)591 assert.Empty(t, file.Hunks)
590 assert.Equal(t, 10, file.Additions, "counts stay accurate")592 assert.Equal(t, 10, file.Additions, "counts stay accurate")
591 }593 }
592 }594 }
593- assert.True(t, d.Diff.Truncated, "a file of the window is truncated")595+ assert.False(t, d.Diff.Truncated, "an omitted file does not set Diff.Truncated")
594 596
595 limits.From, limits.To = 0, 0597 limits.From, limits.To = 0, 0
596 limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file598 limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file
597 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)599 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
598 require.NoError(t, err)600 require.NoError(t, err)
599- assert.True(t, d.Diff.Files[0].Truncated)601+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[0].Omitted)
600 })602 })
601 603
602 t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {604 t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {
@@ -608,14 +610,14 @@ func TestDiffLimitsWindow(t *testing.T) {
608 require.NoError(t, err)610 require.NoError(t, err)
609 require.Len(t, d.Diff.Files, 2)611 require.Len(t, d.Diff.Files, 2)
610 assert.Len(t, d.Diff.Files[0].Hunks, 1)612 assert.Len(t, d.Diff.Files[0].Hunks, 1)
611- assert.False(t, d.Diff.Files[1].Truncated)613+ assert.Equal(t, domain.OmitNone, d.Diff.Files[1].Omitted)
612 assert.Empty(t, d.Diff.Files[1].Hunks)614 assert.Empty(t, d.Diff.Files[1].Hunks)
613 assert.False(t, d.Diff.Truncated)615 assert.False(t, d.Diff.Truncated)
614 limits.From, limits.To = 1, 2616 limits.From, limits.To = 1, 2
615 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)617 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
616 require.NoError(t, err)618 require.NoError(t, err)
617- assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit")619+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "in the window, over the per-file limit")
618- assert.True(t, d.Diff.Truncated)620+ assert.False(t, d.Diff.Truncated)
619 })621 })
620 622
621 t.Run("MaxFiles 0 lists every file", func(t *testing.T) {623 t.Run("MaxFiles 0 lists every file", func(t *testing.T) {
@@ -650,7 +652,7 @@ func TestDiffLimitsWindow(t *testing.T) {
650 } else {652 } else {
651 assert.Empty(t, file.Hunks)653 assert.Empty(t, file.Hunks)
652 }654 }
653- assert.False(t, file.Truncated)655+ assert.Equal(t, domain.OmitNone, file.Omitted)
654 }656 }
655 657
656 // The default limits still cut at 300.658 // The default limits still cut at 300.
M internal/adapters/rpc/convert.go +17 -1
@@ -394,7 +394,8 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
394394 Additions: int32(f.Additions),
395395 Deletions: int32(f.Deletions),
396396 Similarity: int32(f.Similarity),
397- Truncated: f.Truncated,
397+ Truncated: f.Omitted != domain.OmitNone,
398+ Omitted: fileOmissionToPB(f.Omitted),
398399 }
399400 for _, h := range f.Hunks {
400401 out.Hunks = append(out.Hunks, hunkToPB(h))
@@ -402,6 +403,20 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
402403 return out
403404 }
404405
406+func fileOmissionToPB(o domain.FileOmission) pb.FileOmission {
407+ switch o {
408+ case domain.OmitGenerated:
409+ return pb.FileOmission_FILE_OMISSION_GENERATED
410+ case domain.OmitLarge:
411+ return pb.FileOmission_FILE_OMISSION_LARGE
412+ case domain.OmitBudget:
413+ return pb.FileOmission_FILE_OMISSION_BUDGET
414+ case domain.OmitUnavailable:
415+ return pb.FileOmission_FILE_OMISSION_UNAVAILABLE
416+ }
417+ return pb.FileOmission_FILE_OMISSION_UNSPECIFIED
418+}
419+
405420 func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
406421 return &pb.DiffStats{
407422 FilesChanged: int32(s.FilesChanged),
@@ -413,6 +428,7 @@ func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
413428 func diffToPB(d domain.Diff) *pb.Diff {
414429 out := &pb.Diff{Truncated: d.Truncated}
415430 for _, f := range d.Files {
431+ out.Truncated = out.Truncated || f.Omitted != domain.OmitNone
416432 out.Files = append(out.Files, fileDiffToPB(f))
417433 }
418434 return out
@@ -394,7 +394,8 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
394 Additions: int32(f.Additions),394 Additions: int32(f.Additions),
395 Deletions: int32(f.Deletions),395 Deletions: int32(f.Deletions),
396 Similarity: int32(f.Similarity),396 Similarity: int32(f.Similarity),
397- Truncated: f.Truncated,397+ Truncated: f.Omitted != domain.OmitNone,
398+ Omitted: fileOmissionToPB(f.Omitted),
398 }399 }
399 for _, h := range f.Hunks {400 for _, h := range f.Hunks {
400 out.Hunks = append(out.Hunks, hunkToPB(h))401 out.Hunks = append(out.Hunks, hunkToPB(h))
@@ -402,6 +403,20 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
402 return out403 return out
403 }404 }
404 405
406+func fileOmissionToPB(o domain.FileOmission) pb.FileOmission {
407+ switch o {
408+ case domain.OmitGenerated:
409+ return pb.FileOmission_FILE_OMISSION_GENERATED
410+ case domain.OmitLarge:
411+ return pb.FileOmission_FILE_OMISSION_LARGE
412+ case domain.OmitBudget:
413+ return pb.FileOmission_FILE_OMISSION_BUDGET
414+ case domain.OmitUnavailable:
415+ return pb.FileOmission_FILE_OMISSION_UNAVAILABLE
416+ }
417+ return pb.FileOmission_FILE_OMISSION_UNSPECIFIED
418+}
419+
405 func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {420 func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
406 return &pb.DiffStats{421 return &pb.DiffStats{
407 FilesChanged: int32(s.FilesChanged),422 FilesChanged: int32(s.FilesChanged),
@@ -413,6 +428,7 @@ func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
413 func diffToPB(d domain.Diff) *pb.Diff {428 func diffToPB(d domain.Diff) *pb.Diff {
414 out := &pb.Diff{Truncated: d.Truncated}429 out := &pb.Diff{Truncated: d.Truncated}
415 for _, f := range d.Files {430 for _, f := range d.Files {
431+ out.Truncated = out.Truncated || f.Omitted != domain.OmitNone
416 out.Files = append(out.Files, fileDiffToPB(f))432 out.Files = append(out.Files, fileDiffToPB(f))
417 }433 }
418 return out434 return out
M internal/adapters/rpc/convert_test.go +18 -0
@@ -398,3 +398,21 @@ func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) {
398398 assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())
399399 }
400400 }
401+
402+func TestFileDiffOmissionMapping(t *testing.T) {
403+ for o, want := range map[domain.FileOmission]pb.FileOmission{
404+ domain.OmitNone: pb.FileOmission_FILE_OMISSION_UNSPECIFIED,
405+ domain.OmitGenerated: pb.FileOmission_FILE_OMISSION_GENERATED,
406+ domain.OmitLarge: pb.FileOmission_FILE_OMISSION_LARGE,
407+ domain.OmitBudget: pb.FileOmission_FILE_OMISSION_BUDGET,
408+ domain.OmitUnavailable: pb.FileOmission_FILE_OMISSION_UNAVAILABLE,
409+ } {
410+ f := fileDiffToPB(domain.FileDiff{NewPath: "a", Omitted: o})
411+ assert.Equal(t, want, f.GetOmitted())
412+ assert.Equal(t, o != domain.OmitNone, f.GetTruncated())
413+ d := diffToPB(domain.Diff{Files: []domain.FileDiff{{Omitted: o}}})
414+ assert.Equal(t, o != domain.OmitNone, d.GetTruncated())
415+ }
416+ assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated())
417+ assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated())
418+}
@@ -398,3 +398,21 @@ func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) {
398 assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())398 assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())
399 }399 }
400 }400 }
401+
402+func TestFileDiffOmissionMapping(t *testing.T) {
403+ for o, want := range map[domain.FileOmission]pb.FileOmission{
404+ domain.OmitNone: pb.FileOmission_FILE_OMISSION_UNSPECIFIED,
405+ domain.OmitGenerated: pb.FileOmission_FILE_OMISSION_GENERATED,
406+ domain.OmitLarge: pb.FileOmission_FILE_OMISSION_LARGE,
407+ domain.OmitBudget: pb.FileOmission_FILE_OMISSION_BUDGET,
408+ domain.OmitUnavailable: pb.FileOmission_FILE_OMISSION_UNAVAILABLE,
409+ } {
410+ f := fileDiffToPB(domain.FileDiff{NewPath: "a", Omitted: o})
411+ assert.Equal(t, want, f.GetOmitted())
412+ assert.Equal(t, o != domain.OmitNone, f.GetTruncated())
413+ d := diffToPB(domain.Diff{Files: []domain.FileDiff{{Omitted: o}}})
414+ assert.Equal(t, o != domain.OmitNone, d.GetTruncated())
415+ }
416+ assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated())
417+ assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated())
418+}
M internal/adapters/rpc/git.go +1 -1
@@ -180,7 +180,7 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC
180180 if err != nil {
181181 return nil, connectError(ctx, err)
182182 }
183- detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)
183+ detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev, ports.GetCommitOptions{})
184184 if err != nil {
185185 return nil, connectError(ctx, err)
186186 }
@@ -180,7 +180,7 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC
180 if err != nil {180 if err != nil {
181 return nil, connectError(ctx, err)181 return nil, connectError(ctx, err)
182 }182 }
183- detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)183+ detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev, ports.GetCommitOptions{})
184 if err != nil {184 if err != nil {
185 return nil, connectError(ctx, err)185 return nil, connectError(ctx, err)
186 }186 }
M internal/adapters/rpc/git_test.go +1 -1
@@ -157,7 +157,7 @@ func TestGitServiceGetCommitAndResolveRevision(t *testing.T) {
157157 t.Run("GetCommit not found", func(t *testing.T) {
158158 t.Parallel()
159159 browse := mocks.NewBrowseService(t)
160- browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
160+ browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
161161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))
162162 requireCode(t, connect.CodeNotFound, err)
163163 })
@@ -157,7 +157,7 @@ func TestGitServiceGetCommitAndResolveRevision(t *testing.T) {
157 t.Run("GetCommit not found", func(t *testing.T) {157 t.Run("GetCommit not found", func(t *testing.T) {
158 t.Parallel()158 t.Parallel()
159 browse := mocks.NewBrowseService(t)159 browse := mocks.NewBrowseService(t)
160- browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()160+ browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))
162 requireCode(t, connect.CodeNotFound, err)162 requireCode(t, connect.CodeNotFound, err)
163 })163 })
M internal/adapters/web/assets.go +2 -1
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
2424 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2525 // the content, so they can be cached for ever.
2626 type Assets struct {
27- CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string
27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, Lazy, MR, IssueForm, PowWorker, Icon string
2828 // Manifest is the web app manifest (install on a phone's home screen),
2929 // AppleIcon the home screen icon iOS reads from a link instead.
3030 Manifest, AppleIcon string
@@ -64,6 +64,7 @@ var scripts = []struct {
6464 {"files.js", func(a *Assets, u string) { a.Files = u }},
6565 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
6666 {"log.js", func(a *Assets, u string) { a.Log = u }},
67+ {"lazy.js", func(a *Assets, u string) { a.Lazy = u }},
6768 {"mr.js", func(a *Assets, u string) { a.MR = u }},
6869 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
6970 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
24 // Assets are the URLs of the assets a page loads. Their names carry a hash of24 // Assets are the URLs of the assets a page loads. Their names carry a hash of
25 // the content, so they can be cached for ever.25 // the content, so they can be cached for ever.
26 type Assets struct {26 type Assets struct {
27- CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, Lazy, MR, IssueForm, PowWorker, Icon string
28 // Manifest is the web app manifest (install on a phone's home screen),28 // Manifest is the web app manifest (install on a phone's home screen),
29 // AppleIcon the home screen icon iOS reads from a link instead.29 // AppleIcon the home screen icon iOS reads from a link instead.
30 Manifest, AppleIcon string30 Manifest, AppleIcon string
@@ -64,6 +64,7 @@ var scripts = []struct {
64 {"files.js", func(a *Assets, u string) { a.Files = u }},64 {"files.js", func(a *Assets, u string) { a.Files = u }},
65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
66 {"log.js", func(a *Assets, u string) { a.Log = u }},66 {"log.js", func(a *Assets, u string) { a.Log = u }},
67+ {"lazy.js", func(a *Assets, u string) { a.Lazy = u }},
67 {"mr.js", func(a *Assets, u string) { a.MR = u }},68 {"mr.js", func(a *Assets, u string) { a.MR = u }},
68 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},69 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
69 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},70 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
M internal/adapters/web/browse.go +2 -2
@@ -45,12 +45,12 @@ func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path st
4545 }
4646
4747 // getCommit reads a commit with its diff.
48-func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
48+func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
4949 r, err := domain.ParseRevision(rev)
5050 if err != nil {
5151 return domain.CommitDetail{}, err
5252 }
53- return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r)
53+ return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r, opts)
5454 }
5555
5656 // openBlobAt opens the file path of the commit rev. The caller closes the
@@ -45,12 +45,12 @@ func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path st
45 }45 }
46 46
47 // getCommit reads a commit with its diff.47 // getCommit reads a commit with its diff.
48-func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) {48+func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
49 r, err := domain.ParseRevision(rev)49 r, err := domain.ParseRevision(rev)
50 if err != nil {50 if err != nil {
51 return domain.CommitDetail{}, err51 return domain.CommitDetail{}, err
52 }52 }
53- return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r)53+ return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r, opts)
54 }54 }
55 55
56 // openBlobAt opens the file path of the commit rev. The caller closes the56 // openBlobAt opens the file path of the commit rev. The caller closes the
M internal/adapters/web/commit.go +57 -16
@@ -12,6 +12,7 @@ import (
1212 "github.com/a-h/templ"
1313
1414 "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
1516 )
1617
1718 // bothViewsLines is the largest diff, in lines, that is sent in both the
@@ -105,7 +106,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
105106 if err != nil {
106107 return nil, 0, err
107108 }
108- res, err := s.getCommit(ctx, d.repo.Name, sha)
109+ res, err := s.commitDiff(ctx, r, d.repo.Name, sha)
109110 if err != nil {
110111 return nil, 0, err
111112 }
@@ -128,6 +129,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
128129 Verification: c.Verification,
129130 }
130131 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
132+ collapseFiles(p.Files, res.Diff.Files, commitLinks(d.name(), cid))
131133 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
132134 p.Committer = &cm
133135 }
@@ -145,7 +147,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
145147 p.Refs = append(p.Refs, "tag: "+t.ShortName)
146148 }
147149 }
148- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
150+ p.Note = diffNote(res.Diff)
149151 p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
150152
151153 lines, invisible := diffLines(p.Files)
@@ -166,6 +168,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
166168 {Label: p.Short, Current: true},
167169 }
168170 l.HasRail = true
171+ l.Lazy = true
169172 return Commit(l, p), http.StatusOK, nil
170173 }
171174
@@ -186,23 +189,61 @@ func commitBody(c domain.Commit) string {
186189 return messageText(strings.TrimSpace(body))
187190 }
188191
189-// diffNote says what is missing from an incomplete diff, "" for a complete one.
190-func diffNote(d domain.Diff, shown, total int) string {
191- if !d.Truncated {
192+// diffNote says what is missing from an incomplete diff, "" for a complete one:
193+// files too large for the browser at all. Collapsed files say so on their own
194+// line and load on their own; every changed file is listed.
195+func diffNote(d domain.Diff) string {
196+ gone := 0
197+ for _, f := range d.Files {
198+ if f.Omitted == domain.OmitUnavailable {
199+ gone++
200+ }
201+ }
202+ if gone == 0 {
192203 return ""
193204 }
194- var parts []string
195- if shown < total {
196- parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
205+ return "this diff is incomplete: " + plural(gone, "file is", "files are") + " too large to show. the totals above count everything; clone the repository to read the rest."
206+}
207+
208+// commitDiff reads a commit for its page (and the compare page): every file
209+// listed, the hunks of the first files read, generated files and files past
210+// the window collapsed (ports.GetCommitOptions.Page). With ?file=i (the link
211+// of a page without scripts) file i is read in full and replaces its entry.
212+func (s *Server) commitDiff(ctx context.Context, r *http.Request, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
213+ return s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Page: true, Load: fileParam(r)})
214+}
215+
216+// commitLinks are the addresses a commit page loads its collapsed files from.
217+func commitLinks(repo, rev string) fileLinks {
218+ return fileLinks{src: repoHref(repo, "commit", rev, "files"), repo: repo, rev: rev}
219+}
220+
221+// commitFiles is GET /<repo>/commit/<sha>/files?one=1&from=i[&view=&layouts=]:
222+// the one file i of the commit with its hunks, in full, as the one
223+// details.dfile the page swaps for its placeholder.
224+func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
225+ sha := r.PathValue("sha")
226+ i, err := parseOne(r)
227+ if err != nil || !isCommitID(sha) {
228+ return nil, 0, errNotFound
229+ }
230+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
231+ if err != nil {
232+ return nil, 0, err
197233 }
198- cut := 0
199- for _, f := range d.Files {
200- if f.Truncated {
201- cut++
202- }
234+ res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
235+ if err != nil {
236+ return nil, 0, err
203237 }
204- if cut > 0 {
205- parts = append(parts, plural(cut, "file is", "files are")+" too large to show")
238+ if i >= len(res.Diff.Files) {
239+ return nil, 0, errNotFound
206240 }
207- return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."
241+ cid := res.Commit.ID.String()
242+ raw := res.Diff.Files[i : i+1]
243+ files := s.hl.files(ctx, raw)
244+ files[0].ID = "f" + itoa(i+1)
245+ contextGaps(files, raw, repoHref(d.name(), "context", cid))
246+ files[0].Raw = rawLink(raw[0], commitLinks(d.name(), cid))
247+ lines, _ := diffLines(files)
248+ return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil
208249 }
@@ -12,6 +12,7 @@ import (
12 "github.com/a-h/templ"12 "github.com/a-h/templ"
13 13
14 "bnema.dev/ermite/internal/domain"14 "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
15 )16 )
16 17
17 // bothViewsLines is the largest diff, in lines, that is sent in both the18 // bothViewsLines is the largest diff, in lines, that is sent in both the
@@ -105,7 +106,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
105 if err != nil {106 if err != nil {
106 return nil, 0, err107 return nil, 0, err
107 }108 }
108- res, err := s.getCommit(ctx, d.repo.Name, sha)109+ res, err := s.commitDiff(ctx, r, d.repo.Name, sha)
109 if err != nil {110 if err != nil {
110 return nil, 0, err111 return nil, 0, err
111 }112 }
@@ -128,6 +129,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
128 Verification: c.Verification,129 Verification: c.Verification,
129 }130 }
130 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))131 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
132+ collapseFiles(p.Files, res.Diff.Files, commitLinks(d.name(), cid))
131 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {133 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
132 p.Committer = &cm134 p.Committer = &cm
133 }135 }
@@ -145,7 +147,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
145 p.Refs = append(p.Refs, "tag: "+t.ShortName)147 p.Refs = append(p.Refs, "tag: "+t.ShortName)
146 }148 }
147 }149 }
148- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)150+ p.Note = diffNote(res.Diff)
149 p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))151 p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
150 152
151 lines, invisible := diffLines(p.Files)153 lines, invisible := diffLines(p.Files)
@@ -166,6 +168,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
166 {Label: p.Short, Current: true},168 {Label: p.Short, Current: true},
167 }169 }
168 l.HasRail = true170 l.HasRail = true
171+ l.Lazy = true
169 return Commit(l, p), http.StatusOK, nil172 return Commit(l, p), http.StatusOK, nil
170 }173 }
171 174
@@ -186,23 +189,61 @@ func commitBody(c domain.Commit) string {
186 return messageText(strings.TrimSpace(body))189 return messageText(strings.TrimSpace(body))
187 }190 }
188 191
189-// diffNote says what is missing from an incomplete diff, "" for a complete one.192+// diffNote says what is missing from an incomplete diff, "" for a complete one:
190-func diffNote(d domain.Diff, shown, total int) string {193+// files too large for the browser at all. Collapsed files say so on their own
191- if !d.Truncated {194+// line and load on their own; every changed file is listed.
195+func diffNote(d domain.Diff) string {
196+ gone := 0
197+ for _, f := range d.Files {
198+ if f.Omitted == domain.OmitUnavailable {
199+ gone++
200+ }
201+ }
202+ if gone == 0 {
192 return ""203 return ""
193 }204 }
194- var parts []string205+ return "this diff is incomplete: " + plural(gone, "file is", "files are") + " too large to show. the totals above count everything; clone the repository to read the rest."
195- if shown < total {206+}
196- parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")207+
208+// commitDiff reads a commit for its page (and the compare page): every file
209+// listed, the hunks of the first files read, generated files and files past
210+// the window collapsed (ports.GetCommitOptions.Page). With ?file=i (the link
211+// of a page without scripts) file i is read in full and replaces its entry.
212+func (s *Server) commitDiff(ctx context.Context, r *http.Request, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
213+ return s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Page: true, Load: fileParam(r)})
214+}
215+
216+// commitLinks are the addresses a commit page loads its collapsed files from.
217+func commitLinks(repo, rev string) fileLinks {
218+ return fileLinks{src: repoHref(repo, "commit", rev, "files"), repo: repo, rev: rev}
219+}
220+
221+// commitFiles is GET /<repo>/commit/<sha>/files?one=1&from=i[&view=&layouts=]:
222+// the one file i of the commit with its hunks, in full, as the one
223+// details.dfile the page swaps for its placeholder.
224+func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
225+ sha := r.PathValue("sha")
226+ i, err := parseOne(r)
227+ if err != nil || !isCommitID(sha) {
228+ return nil, 0, errNotFound
229+ }
230+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
231+ if err != nil {
232+ return nil, 0, err
197 }233 }
198- cut := 0234+ res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
199- for _, f := range d.Files {235+ if err != nil {
200- if f.Truncated {236+ return nil, 0, err
201- cut++
202- }
203 }237 }
204- if cut > 0 {238+ if i >= len(res.Diff.Files) {
205- parts = append(parts, plural(cut, "file is", "files are")+" too large to show")239+ return nil, 0, errNotFound
206 }240 }
207- return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."241+ cid := res.Commit.ID.String()
242+ raw := res.Diff.Files[i : i+1]
243+ files := s.hl.files(ctx, raw)
244+ files[0].ID = "f" + itoa(i+1)
245+ contextGaps(files, raw, repoHref(d.name(), "context", cid))
246+ files[0].Raw = rawLink(raw[0], commitLinks(d.name(), cid))
247+ lines, _ := diffLines(files)
248+ return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil
208 }249 }
M internal/adapters/web/compare.go +4 -2
@@ -76,15 +76,16 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
7676
7777 // The exact diff: one commit on top of the tip of the target.
7878 if exact && !behind && len(ahead) == 1 {
79- res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String())
79+ res, err := s.commitDiff(ctx, r, d.repo.Name, ahead[0].ID.String())
8080 if err != nil {
8181 return nil, 0, err
8282 }
8383 p.HasDiff = true
8484 p.Files = s.hl.files(ctx, res.Diff.Files)
8585 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
86+ collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String()))
8687 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
87- if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
88+ if n := diffNote(res.Diff); n != "" {
8889 p.Note = n
8990 }
9091 lines, invisible := diffLines(p.Files)
@@ -131,6 +132,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
131132 Crumb{Label: p.Source, Current: true},
132133 )
133134 l.HasRail = true
135+ l.Lazy = true
134136 return Compare(l, p), http.StatusOK, nil
135137 }
136138
@@ -76,15 +76,16 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
76 76
77 // The exact diff: one commit on top of the tip of the target.77 // The exact diff: one commit on top of the tip of the target.
78 if exact && !behind && len(ahead) == 1 {78 if exact && !behind && len(ahead) == 1 {
79- res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String())79+ res, err := s.commitDiff(ctx, r, d.repo.Name, ahead[0].ID.String())
80 if err != nil {80 if err != nil {
81 return nil, 0, err81 return nil, 0, err
82 }82 }
83 p.HasDiff = true83 p.HasDiff = true
84 p.Files = s.hl.files(ctx, res.Diff.Files)84 p.Files = s.hl.files(ctx, res.Diff.Files)
85 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))85 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
86+ collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String()))
86 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions87 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
87- if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {88+ if n := diffNote(res.Diff); n != "" {
88 p.Note = n89 p.Note = n
89 }90 }
90 lines, invisible := diffLines(p.Files)91 lines, invisible := diffLines(p.Files)
@@ -131,6 +132,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
131 Crumb{Label: p.Source, Current: true},132 Crumb{Label: p.Source, Current: true},
132 )133 )
133 l.HasRail = true134 l.HasRail = true
135+ l.Lazy = true
134 return Compare(l, p), http.StatusOK, nil136 return Compare(l, p), http.StatusOK, nil
135 }137 }
136 138
M internal/adapters/web/diff.go +109 -5
@@ -7,8 +7,10 @@ import (
77 "fmt"
88 "html"
99 "io"
10+ "net/http"
1011 "net/url"
1112 "slices"
13+ "strconv"
1214 "strings"
1315 "unicode/utf8"
1416
@@ -123,6 +125,14 @@ type DiffFile struct {
123125 Note string
124126 Open bool
125127 Hunks []DiffHunk
128+ // Manual is true for a collapsed file (generated, large, over the page
129+ // budget): no hunks, a placeholder with a "load diff" control that reads
130+ // that one file (Src, empty when it cannot be loaded in place; Href is the
131+ // page without scripts).
132+ Manual bool
133+ Href string
134+ // Raw is the address of the raw blob of a file too large to show.
135+ Raw string
126136 // Digits is the width of the largest line number of the file, at least 2
127137 // and at most 7: the gutter is as wide as the file needs (class lnw-N).
128138 Digits int
@@ -163,7 +173,7 @@ const diffContextLines = 3
163173 // them: an added or a deleted file shows every line already.
164174 func contextGaps(out []DiffFile, files []domain.FileDiff, base string) {
165175 for i, f := range files {
166- if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
176+ if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
167177 continue
168178 }
169179 switch f.Status {
@@ -370,8 +380,9 @@ func diffFiles(files []domain.FileDiff) []DiffFile {
370380 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
371381 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
372382 // A file that is shown in full opens; a file with nothing to read
373- // (binary, truncated) stays closed under its note.
374- Open: len(f.Hunks) > 0 && !f.Truncated,
383+ // (binary, omitted) stays closed under its note.
384+ Open: len(f.Hunks) > 0 && f.Omitted == domain.OmitNone,
385+ Manual: f.Omitted.Collapsed(),
375386 }
376387 df.Key = f.NewPath
377388 switch f.Status {
@@ -406,8 +417,14 @@ func fileNote(f domain.FileDiff) string {
406417 switch {
407418 case f.Binary:
408419 parts = append(parts, "binary file: no text diff")
409- case f.Truncated:
410- parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))
420+ case f.Omitted == domain.OmitGenerated:
421+ parts = append(parts, "generated file: collapsed")
422+ case f.Omitted == domain.OmitLarge:
423+ parts = append(parts, fmt.Sprintf("large diff (%d lines changed): collapsed", f.Additions+f.Deletions))
424+ case f.Omitted == domain.OmitBudget:
425+ parts = append(parts, "collapsed to keep the page light")
426+ case f.Omitted == domain.OmitUnavailable:
427+ parts = append(parts, "too large to show in the browser")
411428 }
412429 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
413430 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))
@@ -449,3 +466,90 @@ func codeLine(ln DiffLine) templ.Component {
449466 return err
450467 })
451468 }
469+
470+// fileLinks says where the collapsed files of a diff are loaded from.
471+type fileLinks struct {
472+ // src is the address of the fragment route of the diff ("" when files
473+ // cannot be loaded in place); one file is src plus one=1&from=i.
474+ src string
475+ // from is the window of the page, kept in the links of a page without
476+ // scripts.
477+ from int
478+ // repo and rev name the raw blob of a file that cannot be shown.
479+ repo, rev string
480+}
481+
482+// oneFileSrc is the address of the fragment holding file i alone.
483+func oneFileSrc(src string, i int) string {
484+ sep := "?"
485+ if strings.Contains(src, "?") {
486+ sep = "&"
487+ }
488+ return src + sep + "one=1&from=" + itoa(i)
489+}
490+
491+// collapseFiles gives the collapsed files of a diff their load address, and
492+// the files too large to show a link to their raw blob. Nothing is read.
493+//
494+//hexcheck:ignore suspicious-business-logic-in-adapter only maps each file's domain omission (Omitted.Collapsed) to a load link, a ?file= anchor or a raw link; it decides nothing and reads nothing
495+func collapseFiles(out []DiffFile, files []domain.FileDiff, l fileLinks) {
496+ for i, f := range files {
497+ if i >= len(out) {
498+ break
499+ }
500+ switch {
501+ case f.Omitted.Collapsed():
502+ if l.src != "" {
503+ out[i].Src = oneFileSrc(l.src, i)
504+ }
505+ q := "?file=" + itoa(i)
506+ if l.from > 0 {
507+ q = "?from=" + itoa(l.from) + "&file=" + itoa(i)
508+ }
509+ out[i].Href = q + "#" + out[i].ID
510+ default:
511+ out[i].Raw = rawLink(f, l)
512+ }
513+ }
514+}
515+
516+// rawLink is the address of the raw blob of a file too large to show, "" for
517+// any other file.
518+func rawLink(f domain.FileDiff, l fileLinks) string {
519+ if f.Omitted != domain.OmitUnavailable || f.Status == domain.FileDeleted || l.rev == "" {
520+ return ""
521+ }
522+ return repoHref(l.repo, "raw", l.rev, f.NewPath)
523+}
524+
525+// fileParam reads ?file= of a page without scripts: the index of a collapsed
526+// file to read in full; nil when absent or malformed.
527+func fileParam(r *http.Request) *int {
528+ s := r.URL.Query().Get("file")
529+ i, err := strconv.Atoi(s)
530+ if err != nil || i < 0 || strconv.Itoa(i) != s {
531+ return nil
532+ }
533+ return &i
534+}
535+
536+// fragmentView lays out a one-file fragment like the page that asks for it:
537+// ?view= is the layout shown and ?layouts= says whether the page holds both.
538+func fragmentView(r *http.Request, lines int) DiffView {
539+ v := newDiffView(r, lines, "", "")
540+ v.pageLayouts(r)
541+ return v
542+}
543+
544+// pageLayouts makes a fragment send the layouts of the page that asks for
545+// it: ?layouts=both when the page holds both and switches them itself,
546+// ?layouts=one when it holds only the shown one.
547+func (v *DiffView) pageLayouts(r *http.Request) {
548+ switch r.URL.Query().Get("layouts") {
549+ case "both":
550+ v.Both = true
551+ case "one":
552+ v.Both = false
553+ }
554+ v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
555+}
@@ -7,8 +7,10 @@ import (
7 "fmt"7 "fmt"
8 "html"8 "html"
9 "io"9 "io"
10+ "net/http"
10 "net/url"11 "net/url"
11 "slices"12 "slices"
13+ "strconv"
12 "strings"14 "strings"
13 "unicode/utf8"15 "unicode/utf8"
14 16
@@ -123,6 +125,14 @@ type DiffFile struct {
123 Note string125 Note string
124 Open bool126 Open bool
125 Hunks []DiffHunk127 Hunks []DiffHunk
128+ // Manual is true for a collapsed file (generated, large, over the page
129+ // budget): no hunks, a placeholder with a "load diff" control that reads
130+ // that one file (Src, empty when it cannot be loaded in place; Href is the
131+ // page without scripts).
132+ Manual bool
133+ Href string
134+ // Raw is the address of the raw blob of a file too large to show.
135+ Raw string
126 // Digits is the width of the largest line number of the file, at least 2136 // Digits is the width of the largest line number of the file, at least 2
127 // and at most 7: the gutter is as wide as the file needs (class lnw-N).137 // and at most 7: the gutter is as wide as the file needs (class lnw-N).
128 Digits int138 Digits int
@@ -163,7 +173,7 @@ const diffContextLines = 3
163 // them: an added or a deleted file shows every line already.173 // them: an added or a deleted file shows every line already.
164 func contextGaps(out []DiffFile, files []domain.FileDiff, base string) {174 func contextGaps(out []DiffFile, files []domain.FileDiff, base string) {
165 for i, f := range files {175 for i, f := range files {
166- if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {176+ if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
167 continue177 continue
168 }178 }
169 switch f.Status {179 switch f.Status {
@@ -370,8 +380,9 @@ func diffFiles(files []domain.FileDiff) []DiffFile {
370 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),380 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
371 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,381 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
372 // A file that is shown in full opens; a file with nothing to read382 // A file that is shown in full opens; a file with nothing to read
373- // (binary, truncated) stays closed under its note.383+ // (binary, omitted) stays closed under its note.
374- Open: len(f.Hunks) > 0 && !f.Truncated,384+ Open: len(f.Hunks) > 0 && f.Omitted == domain.OmitNone,
385+ Manual: f.Omitted.Collapsed(),
375 }386 }
376 df.Key = f.NewPath387 df.Key = f.NewPath
377 switch f.Status {388 switch f.Status {
@@ -406,8 +417,14 @@ func fileNote(f domain.FileDiff) string {
406 switch {417 switch {
407 case f.Binary:418 case f.Binary:
408 parts = append(parts, "binary file: no text diff")419 parts = append(parts, "binary file: no text diff")
409- case f.Truncated:420+ case f.Omitted == domain.OmitGenerated:
410- parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))421+ parts = append(parts, "generated file: collapsed")
422+ case f.Omitted == domain.OmitLarge:
423+ parts = append(parts, fmt.Sprintf("large diff (%d lines changed): collapsed", f.Additions+f.Deletions))
424+ case f.Omitted == domain.OmitBudget:
425+ parts = append(parts, "collapsed to keep the page light")
426+ case f.Omitted == domain.OmitUnavailable:
427+ parts = append(parts, "too large to show in the browser")
411 }428 }
412 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {429 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
413 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))430 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))
@@ -449,3 +466,90 @@ func codeLine(ln DiffLine) templ.Component {
449 return err466 return err
450 })467 })
451 }468 }
469+
470+// fileLinks says where the collapsed files of a diff are loaded from.
471+type fileLinks struct {
472+ // src is the address of the fragment route of the diff ("" when files
473+ // cannot be loaded in place); one file is src plus one=1&from=i.
474+ src string
475+ // from is the window of the page, kept in the links of a page without
476+ // scripts.
477+ from int
478+ // repo and rev name the raw blob of a file that cannot be shown.
479+ repo, rev string
480+}
481+
482+// oneFileSrc is the address of the fragment holding file i alone.
483+func oneFileSrc(src string, i int) string {
484+ sep := "?"
485+ if strings.Contains(src, "?") {
486+ sep = "&"
487+ }
488+ return src + sep + "one=1&from=" + itoa(i)
489+}
490+
491+// collapseFiles gives the collapsed files of a diff their load address, and
492+// the files too large to show a link to their raw blob. Nothing is read.
493+//
494+//hexcheck:ignore suspicious-business-logic-in-adapter only maps each file's domain omission (Omitted.Collapsed) to a load link, a ?file= anchor or a raw link; it decides nothing and reads nothing
495+func collapseFiles(out []DiffFile, files []domain.FileDiff, l fileLinks) {
496+ for i, f := range files {
497+ if i >= len(out) {
498+ break
499+ }
500+ switch {
501+ case f.Omitted.Collapsed():
502+ if l.src != "" {
503+ out[i].Src = oneFileSrc(l.src, i)
504+ }
505+ q := "?file=" + itoa(i)
506+ if l.from > 0 {
507+ q = "?from=" + itoa(l.from) + "&file=" + itoa(i)
508+ }
509+ out[i].Href = q + "#" + out[i].ID
510+ default:
511+ out[i].Raw = rawLink(f, l)
512+ }
513+ }
514+}
515+
516+// rawLink is the address of the raw blob of a file too large to show, "" for
517+// any other file.
518+func rawLink(f domain.FileDiff, l fileLinks) string {
519+ if f.Omitted != domain.OmitUnavailable || f.Status == domain.FileDeleted || l.rev == "" {
520+ return ""
521+ }
522+ return repoHref(l.repo, "raw", l.rev, f.NewPath)
523+}
524+
525+// fileParam reads ?file= of a page without scripts: the index of a collapsed
526+// file to read in full; nil when absent or malformed.
527+func fileParam(r *http.Request) *int {
528+ s := r.URL.Query().Get("file")
529+ i, err := strconv.Atoi(s)
530+ if err != nil || i < 0 || strconv.Itoa(i) != s {
531+ return nil
532+ }
533+ return &i
534+}
535+
536+// fragmentView lays out a one-file fragment like the page that asks for it:
537+// ?view= is the layout shown and ?layouts= says whether the page holds both.
538+func fragmentView(r *http.Request, lines int) DiffView {
539+ v := newDiffView(r, lines, "", "")
540+ v.pageLayouts(r)
541+ return v
542+}
543+
544+// pageLayouts makes a fragment send the layouts of the page that asks for
545+// it: ?layouts=both when the page holds both and switches them itself,
546+// ?layouts=one when it holds only the shown one.
547+func (v *DiffView) pageLayouts(r *http.Request) {
548+ switch r.URL.Query().Get("layouts") {
549+ case "both":
550+ v.Both = true
551+ case "one":
552+ v.Both = false
553+ }
554+ v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
555+}
M internal/adapters/web/diff.templ +10 -1
@@ -237,12 +237,19 @@ templ DiffFileView(f DiffFile, p DiffView) {
237237 }
238238 </summary>
239239 if f.Lazy {
240- // A placeholder: mr.js fetches the window of the file when it comes
240+ // A placeholder: lazy.js fetches the window of the file when it comes
241241 // near the screen and swaps the files of the window in. Without
242242 // scripts the link opens the page of that window.
243243 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
244244 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
245245 </div>
246+ } else if f.Manual {
247+ // A collapsed file (generated, large, over the page budget): no
248+ // rows. "load diff" reads this one file and swaps it in place
249+ // (lazy.js); without scripts the link reloads the page with the file.
250+ <div class="dlazy" data-src={ f.Src } data-manual>
251+ <a href={ templ.SafeURL(f.Href) }>load diff</a>
252+ </div>
246253 } else if len(f.Hunks) > 0 {
247254 <div class="dscroll">
248255 if p.ShowUnified {
@@ -306,6 +313,8 @@ templ DiffFileView(f DiffFile, p DiffView) {
306313 </table>
307314 }
308315 </div>
316+ } else if f.Raw != "" {
317+ <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p>
309318 } else {
310319 <p class="note pad">
311320 if f.Note != "" {
@@ -237,12 +237,19 @@ templ DiffFileView(f DiffFile, p DiffView) {
237 }237 }
238 </summary>238 </summary>
239 if f.Lazy {239 if f.Lazy {
240- // A placeholder: mr.js fetches the window of the file when it comes240+ // A placeholder: lazy.js fetches the window of the file when it comes
241 // near the screen and swaps the files of the window in. Without241 // near the screen and swaps the files of the window in. Without
242 // scripts the link opens the page of that window.242 // scripts the link opens the page of that window.
243 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>243 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
244 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>244 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
245 </div>245 </div>
246+ } else if f.Manual {
247+ // A collapsed file (generated, large, over the page budget): no
248+ // rows. "load diff" reads this one file and swaps it in place
249+ // (lazy.js); without scripts the link reloads the page with the file.
250+ <div class="dlazy" data-src={ f.Src } data-manual>
251+ <a href={ templ.SafeURL(f.Href) }>load diff</a>
252+ </div>
246 } else if len(f.Hunks) > 0 {253 } else if len(f.Hunks) > 0 {
247 <div class="dscroll">254 <div class="dscroll">
248 if p.ShowUnified {255 if p.ShowUnified {
@@ -306,6 +313,8 @@ templ DiffFileView(f DiffFile, p DiffView) {
306 </table>313 </table>
307 }314 }
308 </div>315 </div>
316+ } else if f.Raw != "" {
317+ <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p>
309 } else {318 } else {
310 <p class="note pad">319 <p class="note pad">
311 if f.Note != "" {320 if f.Note != "" {
M internal/adapters/web/diff_highlight.go +2 -2
@@ -48,7 +48,7 @@ var maxDiffHLTotalBytes = 256 << 10
4848
4949 const (
5050 // maxDiffHLFileBytes is the most text of one file of a diff that is
51- // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 lines a
51+ // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 changed lines a
5252 // file; this keeps a file of very long lines out.
5353 maxDiffHLFileBytes = 128 << 10
5454 // maxDiffHLLine is the longest line that is lexed, in bytes: a minified
@@ -446,7 +446,7 @@ func (c *hlCache) files(ctx context.Context, files []domain.FileDiff) []DiffFile
446446
447447 left := maxDiffHLTotalBytes
448448 for i, f := range files {
449- if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
449+ if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
450450 continue
451451 }
452452 if ctx.Err() != nil {
@@ -48,7 +48,7 @@ var maxDiffHLTotalBytes = 256 << 10
48 48
49 const (49 const (
50 // maxDiffHLFileBytes is the most text of one file of a diff that is50 // maxDiffHLFileBytes is the most text of one file of a diff that is
51- // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 lines a51+ // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 changed lines a
52 // file; this keeps a file of very long lines out.52 // file; this keeps a file of very long lines out.
53 maxDiffHLFileBytes = 128 << 1053 maxDiffHLFileBytes = 128 << 10
54 // maxDiffHLLine is the longest line that is lexed, in bytes: a minified54 // maxDiffHLLine is the longest line that is lexed, in bytes: a minified
@@ -446,7 +446,7 @@ func (c *hlCache) files(ctx context.Context, files []domain.FileDiff) []DiffFile
446 446
447 left := maxDiffHLTotalBytes447 left := maxDiffHLTotalBytes
448 for i, f := range files {448 for i, f := range files {
449- if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {449+ if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
450 continue450 continue
451 }451 }
452 if ctx.Err() != nil {452 if ctx.Err() != nil {
M internal/adapters/web/diff_highlight_test.go +2 -2
@@ -162,7 +162,7 @@ func TestHighlightSkips(t *testing.T) {
162162 binary := hunkOf("a.go", "+package a")
163163 binary.Binary = true
164164 truncated := hunkOf("a.go", "+package a")
165- truncated.Truncated = true
165+ truncated.Omitted = domain.OmitLarge
166166 for name, fd := range map[string]domain.FileDiff{
167167 "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"),
168168 "plain text": hunkOf("notes.txt", "+package a"),
@@ -273,7 +273,7 @@ func goSource(n int) []string {
273273 }
274274
275275 // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place
276-// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a
276+// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 changed lines a
277277 // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.
278278 func largeDiff(n int) []domain.FileDiff {
279279 var files []domain.FileDiff
@@ -162,7 +162,7 @@ func TestHighlightSkips(t *testing.T) {
162 binary := hunkOf("a.go", "+package a")162 binary := hunkOf("a.go", "+package a")
163 binary.Binary = true163 binary.Binary = true
164 truncated := hunkOf("a.go", "+package a")164 truncated := hunkOf("a.go", "+package a")
165- truncated.Truncated = true165+ truncated.Omitted = domain.OmitLarge
166 for name, fd := range map[string]domain.FileDiff{166 for name, fd := range map[string]domain.FileDiff{
167 "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"),167 "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"),
168 "plain text": hunkOf("notes.txt", "+package a"),168 "plain text": hunkOf("notes.txt", "+package a"),
@@ -273,7 +273,7 @@ func goSource(n int) []string {
273 }273 }
274 274
275 // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place275 // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place
276-// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a276+// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 changed lines a
277 // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.277 // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.
278 func largeDiff(n int) []domain.FileDiff {278 func largeDiff(n int) []domain.FileDiff {
279 var files []domain.FileDiff279 var files []domain.FileDiff
M internal/adapters/web/diff_templ.go +235 -173 generated file: collapsed
M internal/adapters/web/diff_test.go +51 -2
@@ -99,7 +99,10 @@ func TestFileNotes(t *testing.T) {
9999 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
100100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
101101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
102- {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false},
102+ {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},
103+ {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true},
104+ {"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true},
105+ {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true},
103106 }
104107 for _, tt := range tests {
105108 t.Run(tt.name, func(t *testing.T) {
@@ -120,7 +123,7 @@ func TestDiffFilesPaths(t *testing.T) {
120123 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
121124 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
122125 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
123- {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000},
126+ {Status: domain.FileModified, NewPath: "big.go", Omitted: domain.OmitLarge, Additions: 3000},
124127 })
125128 assert.Equal(t, "a.go", fs[0].Path)
126129 assert.True(t, fs[0].Open)
@@ -191,3 +194,49 @@ func TestContextGaps(t *testing.T) {
191194 contextGaps(o, odd, "/r/context/c")
192195 assert.Nil(t, o[0].Hunks[0].Gap)
193196 }
197+
198+func TestDiffNote(t *testing.T) {
199+ t.Parallel()
200+ files := func(w ...domain.FileOmission) domain.Diff {
201+ var d domain.Diff
202+ for _, o := range w {
203+ d.Files = append(d.Files, domain.FileDiff{Omitted: o})
204+ }
205+ return d
206+ }
207+ assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note")
208+ assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.",
209+ diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable)))
210+}
211+
212+func TestCollapsedFileMarkup(t *testing.T) {
213+ t.Parallel()
214+ view := DiffView{ShowUnified: true}
215+ src := "/demo/commit/abc1234/files"
216+ for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} {
217+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}}
218+ fs := diffFiles(raw)
219+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
220+ out := renderToString(t, DiffFileView(fs[0], view))
221+ assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why)
222+ assert.Regexp(t, `<details `, out, "the file is a details element")
223+ assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
224+ assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
225+ assert.NotContains(t, out, "<table")
226+ }
227+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}}
228+ fs := diffFiles(raw)
229+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
230+ out := renderToString(t, DiffFileView(fs[0], view))
231+ assert.Contains(t, out, "too large to show in the browser")
232+ assert.NotContains(t, out, "load diff")
233+ assert.NotContains(t, out, "data-manual")
234+ assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`)
235+
236+ // Past the first window of a merge request the link keeps the window.
237+ raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}}
238+ fs = diffFiles(raw)
239+ collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25})
240+ assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src)
241+ assert.Equal(t, "?from=25&file=0#f1", fs[0].Href)
242+}
@@ -99,7 +99,10 @@ func TestFileNotes(t *testing.T) {
99 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},99 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
102- {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false},102+ {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},
103+ {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true},
104+ {"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true},
105+ {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true},
103 }106 }
104 for _, tt := range tests {107 for _, tt := range tests {
105 t.Run(tt.name, func(t *testing.T) {108 t.Run(tt.name, func(t *testing.T) {
@@ -120,7 +123,7 @@ func TestDiffFilesPaths(t *testing.T) {
120 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},123 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
121 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},124 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
122 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},125 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
123- {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000},126+ {Status: domain.FileModified, NewPath: "big.go", Omitted: domain.OmitLarge, Additions: 3000},
124 })127 })
125 assert.Equal(t, "a.go", fs[0].Path)128 assert.Equal(t, "a.go", fs[0].Path)
126 assert.True(t, fs[0].Open)129 assert.True(t, fs[0].Open)
@@ -191,3 +194,49 @@ func TestContextGaps(t *testing.T) {
191 contextGaps(o, odd, "/r/context/c")194 contextGaps(o, odd, "/r/context/c")
192 assert.Nil(t, o[0].Hunks[0].Gap)195 assert.Nil(t, o[0].Hunks[0].Gap)
193 }196 }
197+
198+func TestDiffNote(t *testing.T) {
199+ t.Parallel()
200+ files := func(w ...domain.FileOmission) domain.Diff {
201+ var d domain.Diff
202+ for _, o := range w {
203+ d.Files = append(d.Files, domain.FileDiff{Omitted: o})
204+ }
205+ return d
206+ }
207+ assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note")
208+ assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.",
209+ diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable)))
210+}
211+
212+func TestCollapsedFileMarkup(t *testing.T) {
213+ t.Parallel()
214+ view := DiffView{ShowUnified: true}
215+ src := "/demo/commit/abc1234/files"
216+ for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} {
217+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}}
218+ fs := diffFiles(raw)
219+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
220+ out := renderToString(t, DiffFileView(fs[0], view))
221+ assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why)
222+ assert.Regexp(t, `<details `, out, "the file is a details element")
223+ assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
224+ assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
225+ assert.NotContains(t, out, "<table")
226+ }
227+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}}
228+ fs := diffFiles(raw)
229+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
230+ out := renderToString(t, DiffFileView(fs[0], view))
231+ assert.Contains(t, out, "too large to show in the browser")
232+ assert.NotContains(t, out, "load diff")
233+ assert.NotContains(t, out, "data-manual")
234+ assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`)
235+
236+ // Past the first window of a merge request the link keeps the window.
237+ raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}}
238+ fs = diffFiles(raw)
239+ collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25})
240+ assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src)
241+ assert.Equal(t, "?from=25&file=0#f1", fs[0].Href)
242+}
A internal/adapters/web/jstest/lazy.test.mjs +248 -0
@@ -0,0 +1,248 @@
1+// Tests of the loader of the files of a diff (static/js/lazy.js). Run with
2+// node --test 'internal/adapters/web/jstest/*.test.mjs'
3+import assert from 'node:assert/strict';
4+import { test } from 'node:test';
5+import { readFileSync } from 'node:fs';
6+
7+const SRC = readFileSync(new URL('../static/js/lazy.js', import.meta.url), 'utf8');
8+
9+globalThis.window = globalThis;
10+const saved = { document: globalThis.document, fetch: globalThis.fetch, IO: globalThis.IntersectionObserver };
11+
12+// A placeholder: an element with a dataset; its link finds it back.
13+function placeholder(src, manual) {
14+ const details = { id: 'f1', querySelector: () => null };
15+ const ph = { dataset: manual ? { src, manual: '' } : { src }, setAttribute() {}, removeAttribute() {}, closest: (s) => (s === 'details' ? details : null) };
16+ const link = { closest: (s) => (s === '.dlazy' ? ph : s === '.dlazy[data-manual] > a' && manual ? link : null) };
17+ return { ph, link, details };
18+}
19+
20+async function setup(phs, io = true, opts = {}) {
21+ const observed = [];
22+ const urls = [];
23+ if (io) globalThis.IntersectionObserver = class { observe(p) { observed.push(p); } unobserve() {} disconnect() {} };
24+ else delete globalThis.IntersectionObserver;
25+ const reply = opts.reply || { status: 200, ok: true, body: '' };
26+ globalThis.fetch = async (u) => { urls.push(u); return { status: reply.status, ok: reply.ok, text: async () => reply.body }; };
27+ globalThis.document = {
28+ readyState: 'complete',
29+ querySelector: (s) => (s === '#diffs' ? { dataset: {} } : null),
30+ querySelectorAll: (s) => (s.includes(':not([data-manual])') ? phs.filter((p) => !('manual' in p.dataset)) : phs),
31+ getElementById: opts.getElementById || (() => null),
32+ createElement: () => ({ content: { querySelectorAll: () => opts.files || [] } }),
33+ addEventListener() {},
34+ };
35+ delete globalThis.ermiteLazy;
36+ (0, eval)(SRC);
37+ return { observed, urls };
38+}
39+const settle = () => new Promise((r) => setTimeout(r, 0));
40+const restore = () => {
41+ delete globalThis.ermiteLazy;
42+ for (const [k, v] of [['document', saved.document], ['fetch', saved.fetch], ['IntersectionObserver', saved.IO]]) {
43+ if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
44+ }
45+};
46+
47+test('manual placeholders are not observed', async () => {
48+ const a = placeholder('/r/commit/abc/files?one=1&from=0', true);
49+ const b = placeholder('/r/merge-requests/1/files?from=25', false);
50+ try {
51+ const { observed, urls } = await setup([a.ph, b.ph]);
52+ assert.deepEqual(observed, [b.ph]);
53+ assert.deepEqual(urls, []);
54+ } finally { await settle(); restore(); }
55+});
56+
57+test('without IntersectionObserver only the automatic placeholders load', async () => {
58+ const a = placeholder('/m?one=1&from=0', true);
59+ const b = placeholder('/w?from=25', false);
60+ try {
61+ const { urls } = await setup([a.ph, b.ph], false);
62+ assert.deepEqual(urls, ['/w?from=25']);
63+ } finally { await settle(); restore(); }
64+});
65+
66+test('a click on "load diff" loads that one file and does not navigate', async () => {
67+ const a = placeholder('/r/commit/abc/files?one=1&from=3', true);
68+ try {
69+ const { urls } = await setup([a.ph]);
70+ let prevented = false;
71+ const used = window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { prevented = true; } });
72+ assert.equal(used, true);
73+ assert.equal(prevented, true);
74+ assert.deepEqual(urls, ['/r/commit/abc/files?one=1&from=3']);
75+ } finally { await settle(); restore(); }
76+});
77+
78+test('Enter and Space on the link load it; other keys and modified clicks do not', async () => {
79+ const a = placeholder('/f?one=1&from=1', true);
80+ try {
81+ const { urls } = await setup([a.ph]);
82+ const key = (k, extra = {}) => window.ermiteLazy.onKey({ target: a.link, key: k, preventDefault() {}, ...extra });
83+ assert.equal(key('a'), false);
84+ assert.equal(key('Enter', { ctrlKey: true }), false);
85+ assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, ctrlKey: true, preventDefault() {} }), false);
86+ assert.equal(urls.length, 0);
87+ assert.equal(key('Enter'), true);
88+ assert.equal(key(' '), true);
89+ assert.equal(urls.length, 1, 'a file is fetched once');
90+ } finally { await settle(); restore(); }
91+});
92+
93+test('a manual placeholder without an address is a plain link', async () => {
94+ const a = placeholder('', true);
95+ a.ph.dataset.src = '';
96+ try {
97+ await setup([a.ph]);
98+ assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { assert.fail(); } }), false);
99+ } finally { await settle(); restore(); }
100+});
101+
102+test('onSwap runs after a window came in', async () => {
103+ const b = placeholder('/w?from=25', false);
104+ try {
105+ await setup([b.ph]);
106+ let n = 0;
107+ window.ermiteLazy.onSwap(() => n++);
108+ await window.ermiteLazy.load(b.ph);
109+ assert.equal(n, 1);
110+ } finally { await settle(); restore(); }
111+});
112+
113+test('reset drops the response of a load in flight and lets the same window load again', async () => {
114+ const b = placeholder('/w?from=25', false);
115+ try {
116+ await setup([b.ph]);
117+ let release;
118+ const gate = new Promise((r) => { release = r; });
119+ let fetches = 0;
120+ globalThis.fetch = async () => { fetches++; await gate; return { status: 200, ok: true, text: async () => '' }; };
121+ let n = 0;
122+ window.ermiteLazy.onSwap(() => n++);
123+ const first = window.ermiteLazy.load(b.ph);
124+ window.ermiteLazy.reset();
125+ release();
126+ await first;
127+ assert.equal(n, 0, 'the old generation does not swap');
128+ await window.ermiteLazy.load(b.ph);
129+ assert.equal(fetches, 2);
130+ assert.equal(n, 1);
131+ } finally { await settle(); restore(); }
132+});
133+
134+test('reset clears the stale state; watch disconnects the previous observer', async () => {
135+ const b = placeholder('/w?from=25', false);
136+ try {
137+ await setup([b.ph]);
138+ const made = [];
139+ globalThis.IntersectionObserver = class {
140+ constructor() { this.off = false; made.push(this); }
141+ observe() {} unobserve() {} disconnect() { this.off = true; }
142+ };
143+ globalThis.fetch = async () => ({ status: 409, ok: false, text: async () => 'moved' });
144+ await window.ermiteLazy.load(b.ph);
145+ const urls = [];
146+ globalThis.fetch = async (u) => { urls.push(u); return { status: 200, ok: true, text: async () => '' }; };
147+ await window.ermiteLazy.load(b.ph);
148+ assert.deepEqual(urls, [], 'stale: nothing loads');
149+ window.ermiteLazy.watch();
150+ window.ermiteLazy.watch();
151+ assert.equal(made.length, 2);
152+ assert.ok(made[0].off && !made[1].off);
153+ window.ermiteLazy.reset();
154+ assert.ok(made[1].off);
155+ await window.ermiteLazy.load(b.ph);
156+ assert.equal(urls.length, 1);
157+ } finally { await settle(); restore(); }
158+});
159+
160+
161+// A file of the page (old) or of a fragment (new), with the queries swap makes.
162+function fileEl(id, { lazy = false, manual = false, open = false } = {}) {
163+ const f = { id, open, queried: [] };
164+ f.querySelector = (sel) => {
165+ if (sel === '.dlazy[data-manual]') return manual ? {} : null;
166+ if (sel === '.dlazy') return lazy || manual ? {} : null;
167+ if (sel === '.ln a') return f.line || null;
168+ return null;
169+ };
170+ f.querySelectorAll = () => [];
171+ return f;
172+}
173+
174+test('a loaded manual file opens and takes the focus without scrolling', async () => {
175+ const a = placeholder('/f?one=1&from=0', true);
176+ const old = fileEl('f1', { manual: true });
177+ const fresh = fileEl('f1');
178+ const focused = [];
179+ fresh.line = { focus: (o) => focused.push(o) };
180+ let current = old;
181+ old.replaceWith = (n) => { current = n; };
182+ try {
183+ await setup([a.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => current });
184+ window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() {} });
185+ await settle(); await settle();
186+ assert.equal(current, fresh);
187+ assert.equal(fresh.open, true);
188+ assert.deepEqual(focused, [{ preventScroll: true }]);
189+ } finally { await settle(); restore(); }
190+});
191+
192+test('a window that still holds a collapsed file keeps it closed', async () => {
193+ const w = placeholder('/w?from=25', false);
194+ const old = fileEl('f26', { lazy: true, open: true });
195+ const fresh = fileEl('f26', { manual: true, open: true });
196+ let replaced = null;
197+ old.replaceWith = (n) => { replaced = n; };
198+ try {
199+ await setup([w.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => old });
200+ await window.ermiteLazy.load(w.ph);
201+ assert.equal(replaced, fresh);
202+ assert.equal(fresh.open, false);
203+ } finally { await settle(); restore(); }
204+});
205+
206+test('a 409 makes the page stale: no later load fetches', async () => {
207+ const a = placeholder('/f?one=1&from=0', true);
208+ const b = placeholder('/f?one=1&from=1', true);
209+ try {
210+ const { urls } = await setup([a.ph, b.ph], true, { reply: { status: 409, ok: false, body: '<p>moved</p>' } });
211+ await window.ermiteLazy.load(a.ph);
212+ assert.equal(a.ph.innerHTML, '<p>moved</p>');
213+ assert.equal(urls.length, 1);
214+ await window.ermiteLazy.load(b.ph);
215+ assert.equal(urls.length, 1, 'stale: nothing is fetched');
216+ } finally { await settle(); restore(); }
217+});
218+
219+test('a failed load clears the loading mark and can be retried', async () => {
220+ const a = placeholder('/f?one=1&from=0', true);
221+ const marks = new Set();
222+ a.ph.setAttribute = (n) => marks.add(n);
223+ a.ph.removeAttribute = (n) => marks.delete(n);
224+ try {
225+ const { urls } = await setup([a.ph], true, { reply: { status: 500, ok: false, body: 'boom' } });
226+ const p = window.ermiteLazy.load(a.ph);
227+ assert.ok(marks.has('data-loading'));
228+ await p;
229+ assert.equal(marks.has('data-loading'), false);
230+ await window.ermiteLazy.load(a.ph);
231+ assert.equal(urls.length, 2, 'a retry fetches again');
232+ } finally { await settle(); restore(); }
233+});
234+
235+test('Space is prevented on the link; a held key is ignored', async () => {
236+ const a = placeholder('/f?one=1&from=1', true);
237+ try {
238+ const { urls } = await setup([a.ph]);
239+ let prevented = 0;
240+ const ev = (extra) => ({ target: a.link, key: ' ', preventDefault() { prevented++; }, ...extra });
241+ assert.equal(window.ermiteLazy.onKey(ev({ repeat: true })), false);
242+ assert.equal(prevented, 0);
243+ assert.equal(urls.length, 0);
244+ assert.equal(window.ermiteLazy.onKey(ev({})), true);
245+ assert.equal(prevented, 1, 'Space does not scroll the page');
246+ assert.equal(urls.length, 1);
247+ } finally { await settle(); restore(); }
248+});
@@ -0,0 +1,248 @@
1+// Tests of the loader of the files of a diff (static/js/lazy.js). Run with
2+// node --test 'internal/adapters/web/jstest/*.test.mjs'
3+import assert from 'node:assert/strict';
4+import { test } from 'node:test';
5+import { readFileSync } from 'node:fs';
6+
7+const SRC = readFileSync(new URL('../static/js/lazy.js', import.meta.url), 'utf8');
8+
9+globalThis.window = globalThis;
10+const saved = { document: globalThis.document, fetch: globalThis.fetch, IO: globalThis.IntersectionObserver };
11+
12+// A placeholder: an element with a dataset; its link finds it back.
13+function placeholder(src, manual) {
14+ const details = { id: 'f1', querySelector: () => null };
15+ const ph = { dataset: manual ? { src, manual: '' } : { src }, setAttribute() {}, removeAttribute() {}, closest: (s) => (s === 'details' ? details : null) };
16+ const link = { closest: (s) => (s === '.dlazy' ? ph : s === '.dlazy[data-manual] > a' && manual ? link : null) };
17+ return { ph, link, details };
18+}
19+
20+async function setup(phs, io = true, opts = {}) {
21+ const observed = [];
22+ const urls = [];
23+ if (io) globalThis.IntersectionObserver = class { observe(p) { observed.push(p); } unobserve() {} disconnect() {} };
24+ else delete globalThis.IntersectionObserver;
25+ const reply = opts.reply || { status: 200, ok: true, body: '' };
26+ globalThis.fetch = async (u) => { urls.push(u); return { status: reply.status, ok: reply.ok, text: async () => reply.body }; };
27+ globalThis.document = {
28+ readyState: 'complete',
29+ querySelector: (s) => (s === '#diffs' ? { dataset: {} } : null),
30+ querySelectorAll: (s) => (s.includes(':not([data-manual])') ? phs.filter((p) => !('manual' in p.dataset)) : phs),
31+ getElementById: opts.getElementById || (() => null),
32+ createElement: () => ({ content: { querySelectorAll: () => opts.files || [] } }),
33+ addEventListener() {},
34+ };
35+ delete globalThis.ermiteLazy;
36+ (0, eval)(SRC);
37+ return { observed, urls };
38+}
39+const settle = () => new Promise((r) => setTimeout(r, 0));
40+const restore = () => {
41+ delete globalThis.ermiteLazy;
42+ for (const [k, v] of [['document', saved.document], ['fetch', saved.fetch], ['IntersectionObserver', saved.IO]]) {
43+ if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
44+ }
45+};
46+
47+test('manual placeholders are not observed', async () => {
48+ const a = placeholder('/r/commit/abc/files?one=1&from=0', true);
49+ const b = placeholder('/r/merge-requests/1/files?from=25', false);
50+ try {
51+ const { observed, urls } = await setup([a.ph, b.ph]);
52+ assert.deepEqual(observed, [b.ph]);
53+ assert.deepEqual(urls, []);
54+ } finally { await settle(); restore(); }
55+});
56+
57+test('without IntersectionObserver only the automatic placeholders load', async () => {
58+ const a = placeholder('/m?one=1&from=0', true);
59+ const b = placeholder('/w?from=25', false);
60+ try {
61+ const { urls } = await setup([a.ph, b.ph], false);
62+ assert.deepEqual(urls, ['/w?from=25']);
63+ } finally { await settle(); restore(); }
64+});
65+
66+test('a click on "load diff" loads that one file and does not navigate', async () => {
67+ const a = placeholder('/r/commit/abc/files?one=1&from=3', true);
68+ try {
69+ const { urls } = await setup([a.ph]);
70+ let prevented = false;
71+ const used = window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { prevented = true; } });
72+ assert.equal(used, true);
73+ assert.equal(prevented, true);
74+ assert.deepEqual(urls, ['/r/commit/abc/files?one=1&from=3']);
75+ } finally { await settle(); restore(); }
76+});
77+
78+test('Enter and Space on the link load it; other keys and modified clicks do not', async () => {
79+ const a = placeholder('/f?one=1&from=1', true);
80+ try {
81+ const { urls } = await setup([a.ph]);
82+ const key = (k, extra = {}) => window.ermiteLazy.onKey({ target: a.link, key: k, preventDefault() {}, ...extra });
83+ assert.equal(key('a'), false);
84+ assert.equal(key('Enter', { ctrlKey: true }), false);
85+ assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, ctrlKey: true, preventDefault() {} }), false);
86+ assert.equal(urls.length, 0);
87+ assert.equal(key('Enter'), true);
88+ assert.equal(key(' '), true);
89+ assert.equal(urls.length, 1, 'a file is fetched once');
90+ } finally { await settle(); restore(); }
91+});
92+
93+test('a manual placeholder without an address is a plain link', async () => {
94+ const a = placeholder('', true);
95+ a.ph.dataset.src = '';
96+ try {
97+ await setup([a.ph]);
98+ assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { assert.fail(); } }), false);
99+ } finally { await settle(); restore(); }
100+});
101+
102+test('onSwap runs after a window came in', async () => {
103+ const b = placeholder('/w?from=25', false);
104+ try {
105+ await setup([b.ph]);
106+ let n = 0;
107+ window.ermiteLazy.onSwap(() => n++);
108+ await window.ermiteLazy.load(b.ph);
109+ assert.equal(n, 1);
110+ } finally { await settle(); restore(); }
111+});
112+
113+test('reset drops the response of a load in flight and lets the same window load again', async () => {
114+ const b = placeholder('/w?from=25', false);
115+ try {
116+ await setup([b.ph]);
117+ let release;
118+ const gate = new Promise((r) => { release = r; });
119+ let fetches = 0;
120+ globalThis.fetch = async () => { fetches++; await gate; return { status: 200, ok: true, text: async () => '' }; };
121+ let n = 0;
122+ window.ermiteLazy.onSwap(() => n++);
123+ const first = window.ermiteLazy.load(b.ph);
124+ window.ermiteLazy.reset();
125+ release();
126+ await first;
127+ assert.equal(n, 0, 'the old generation does not swap');
128+ await window.ermiteLazy.load(b.ph);
129+ assert.equal(fetches, 2);
130+ assert.equal(n, 1);
131+ } finally { await settle(); restore(); }
132+});
133+
134+test('reset clears the stale state; watch disconnects the previous observer', async () => {
135+ const b = placeholder('/w?from=25', false);
136+ try {
137+ await setup([b.ph]);
138+ const made = [];
139+ globalThis.IntersectionObserver = class {
140+ constructor() { this.off = false; made.push(this); }
141+ observe() {} unobserve() {} disconnect() { this.off = true; }
142+ };
143+ globalThis.fetch = async () => ({ status: 409, ok: false, text: async () => 'moved' });
144+ await window.ermiteLazy.load(b.ph);
145+ const urls = [];
146+ globalThis.fetch = async (u) => { urls.push(u); return { status: 200, ok: true, text: async () => '' }; };
147+ await window.ermiteLazy.load(b.ph);
148+ assert.deepEqual(urls, [], 'stale: nothing loads');
149+ window.ermiteLazy.watch();
150+ window.ermiteLazy.watch();
151+ assert.equal(made.length, 2);
152+ assert.ok(made[0].off && !made[1].off);
153+ window.ermiteLazy.reset();
154+ assert.ok(made[1].off);
155+ await window.ermiteLazy.load(b.ph);
156+ assert.equal(urls.length, 1);
157+ } finally { await settle(); restore(); }
158+});
159+
160+
161+// A file of the page (old) or of a fragment (new), with the queries swap makes.
162+function fileEl(id, { lazy = false, manual = false, open = false } = {}) {
163+ const f = { id, open, queried: [] };
164+ f.querySelector = (sel) => {
165+ if (sel === '.dlazy[data-manual]') return manual ? {} : null;
166+ if (sel === '.dlazy') return lazy || manual ? {} : null;
167+ if (sel === '.ln a') return f.line || null;
168+ return null;
169+ };
170+ f.querySelectorAll = () => [];
171+ return f;
172+}
173+
174+test('a loaded manual file opens and takes the focus without scrolling', async () => {
175+ const a = placeholder('/f?one=1&from=0', true);
176+ const old = fileEl('f1', { manual: true });
177+ const fresh = fileEl('f1');
178+ const focused = [];
179+ fresh.line = { focus: (o) => focused.push(o) };
180+ let current = old;
181+ old.replaceWith = (n) => { current = n; };
182+ try {
183+ await setup([a.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => current });
184+ window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() {} });
185+ await settle(); await settle();
186+ assert.equal(current, fresh);
187+ assert.equal(fresh.open, true);
188+ assert.deepEqual(focused, [{ preventScroll: true }]);
189+ } finally { await settle(); restore(); }
190+});
191+
192+test('a window that still holds a collapsed file keeps it closed', async () => {
193+ const w = placeholder('/w?from=25', false);
194+ const old = fileEl('f26', { lazy: true, open: true });
195+ const fresh = fileEl('f26', { manual: true, open: true });
196+ let replaced = null;
197+ old.replaceWith = (n) => { replaced = n; };
198+ try {
199+ await setup([w.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => old });
200+ await window.ermiteLazy.load(w.ph);
201+ assert.equal(replaced, fresh);
202+ assert.equal(fresh.open, false);
203+ } finally { await settle(); restore(); }
204+});
205+
206+test('a 409 makes the page stale: no later load fetches', async () => {
207+ const a = placeholder('/f?one=1&from=0', true);
208+ const b = placeholder('/f?one=1&from=1', true);
209+ try {
210+ const { urls } = await setup([a.ph, b.ph], true, { reply: { status: 409, ok: false, body: '<p>moved</p>' } });
211+ await window.ermiteLazy.load(a.ph);
212+ assert.equal(a.ph.innerHTML, '<p>moved</p>');
213+ assert.equal(urls.length, 1);
214+ await window.ermiteLazy.load(b.ph);
215+ assert.equal(urls.length, 1, 'stale: nothing is fetched');
216+ } finally { await settle(); restore(); }
217+});
218+
219+test('a failed load clears the loading mark and can be retried', async () => {
220+ const a = placeholder('/f?one=1&from=0', true);
221+ const marks = new Set();
222+ a.ph.setAttribute = (n) => marks.add(n);
223+ a.ph.removeAttribute = (n) => marks.delete(n);
224+ try {
225+ const { urls } = await setup([a.ph], true, { reply: { status: 500, ok: false, body: 'boom' } });
226+ const p = window.ermiteLazy.load(a.ph);
227+ assert.ok(marks.has('data-loading'));
228+ await p;
229+ assert.equal(marks.has('data-loading'), false);
230+ await window.ermiteLazy.load(a.ph);
231+ assert.equal(urls.length, 2, 'a retry fetches again');
232+ } finally { await settle(); restore(); }
233+});
234+
235+test('Space is prevented on the link; a held key is ignored', async () => {
236+ const a = placeholder('/f?one=1&from=1', true);
237+ try {
238+ const { urls } = await setup([a.ph]);
239+ let prevented = 0;
240+ const ev = (extra) => ({ target: a.link, key: ' ', preventDefault() { prevented++; }, ...extra });
241+ assert.equal(window.ermiteLazy.onKey(ev({ repeat: true })), false);
242+ assert.equal(prevented, 0);
243+ assert.equal(urls.length, 0);
244+ assert.equal(window.ermiteLazy.onKey(ev({})), true);
245+ assert.equal(prevented, 1, 'Space does not scroll the page');
246+ assert.equal(urls.length, 1);
247+ } finally { await settle(); restore(); }
248+});
M internal/adapters/web/jstest/mr.test.mjs +1 -0
@@ -5,6 +5,7 @@ import { test } from 'node:test';
55
66 globalThis.window = globalThis;
77 globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };
8+await import('../static/js/lazy.js');
89 await import('../static/js/mr.js');
910 const C = window.ermiteMR.core;
1011
@@ -5,6 +5,7 @@ import { test } from 'node:test';
5 5
6 globalThis.window = globalThis;6 globalThis.window = globalThis;
7 globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };7 globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };
8+await import('../static/js/lazy.js');
8 await import('../static/js/mr.js');9 await import('../static/js/mr.js');
9 const C = window.ermiteMR.core;10 const C = window.ermiteMR.core;
10 11
M internal/adapters/web/mr.go +8 -2
@@ -562,6 +562,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
562562 }
563563 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
564564 SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow},
565+ Load: fileParam(r), // a page without scripts: ?file=i reads that collapsed file in full
565566 })
566567 if err != nil {
567568 return nil, 0, err
@@ -590,11 +591,16 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
590591 Files: s.hl.files(ctx, res.Diff.Files),
591592 }
592593 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
594+ src := ""
595+ if !res.DiffBaseID.IsZero() {
596+ src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
597+ }
598+ collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
593599 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
594600 return nil, 0, err
595601 }
596602
597- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
603+ p.Note = diffNote(res.Diff)
598604 countNotes(p.Files, res.Comments)
599605 if !res.DiffBaseID.IsZero() {
600606 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
@@ -649,7 +655,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
649655 p.Live = true
650656 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
651657 l.HasRail = true
652- l.MR = true
658+ l.MR, l.Lazy = true, true
653659 return MergeRequest(l, p), http.StatusOK, nil
654660 }
655661
@@ -562,6 +562,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
562 }562 }
563 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{563 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
564 SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow},564 SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow},
565+ Load: fileParam(r), // a page without scripts: ?file=i reads that collapsed file in full
565 })566 })
566 if err != nil {567 if err != nil {
567 return nil, 0, err568 return nil, 0, err
@@ -590,11 +591,16 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
590 Files: s.hl.files(ctx, res.Diff.Files),591 Files: s.hl.files(ctx, res.Diff.Files),
591 }592 }
592 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))593 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
594+ src := ""
595+ if !res.DiffBaseID.IsZero() {
596+ src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
597+ }
598+ collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
593 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {599 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
594 return nil, 0, err600 return nil, 0, err
595 }601 }
596 602
597- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)603+ p.Note = diffNote(res.Diff)
598 countNotes(p.Files, res.Comments)604 countNotes(p.Files, res.Comments)
599 if !res.DiffBaseID.IsZero() {605 if !res.DiffBaseID.IsZero() {
600 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))606 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
@@ -649,7 +655,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
649 p.Live = true655 p.Live = true
650 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))656 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
651 l.HasRail = true657 l.HasRail = true
652- l.MR = true658+ l.MR, l.Lazy = true, true
653 return MergeRequest(l, p), http.StatusOK, nil659 return MergeRequest(l, p), http.StatusOK, nil
654 }660 }
655 661
M internal/adapters/web/mr_files.go +36 -10
@@ -73,6 +73,18 @@ func parseFrom(r *http.Request) (int, error) {
7373 return n, nil
7474 }
7575
76+// parseOne reads ?one=1&from=i of a one-file fragment: the index i of the
77+// file, any non-negative number.
78+func parseOne(r *http.Request) (int, error) {
79+ q := r.URL.Query()
80+ s := q.Get("from")
81+ n, err := strconv.Atoi(s)
82+ if q.Get("one") != "1" || err != nil || n < 0 || strconv.Itoa(n) != s {
83+ return 0, errNotFound
84+ }
85+ return n, nil
86+}
87+
7688 // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows
7789 // a placeholder that loads their window. src is the URL of /files with the
7890 // base and the head of the diff; self the page, for the links of a page
@@ -167,7 +179,13 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
167179 if since && !SignedIn(ctx) {
168180 return nil, 0, errNotFound
169181 }
170- from, err := parseFrom(r)
182+ one := q.Get("one") == "1"
183+ var from int
184+ if one {
185+ from, err = parseOne(r)
186+ } else {
187+ from, err = parseFrom(r)
188+ }
171189 if err != nil {
172190 return nil, 0, err
173191 }
@@ -180,9 +198,12 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
180198 if err != nil {
181199 return nil, 0, err
182200 }
201+ win := &ports.FileWindow{From: from, To: from + mrWindow}
202+ if one {
203+ win = &ports.FileWindow{From: from, To: from + 1, Full: true}
204+ }
183205 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
184- SinceLastRead: since, DiffOnly: true, Base: base, Head: head,
185- Files: &ports.FileWindow{From: from, To: from + mrWindow},
206+ SinceLastRead: since, DiffOnly: true, Base: base, Head: head, Files: win,
186207 })
187208 if errors.Is(err, domain.ErrConflict) {
188209 return MRFilesMoved(), http.StatusConflict, nil
@@ -198,8 +219,19 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
198219 canWrite := signed && m.State == domain.MRStateOpen && !since
199220 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200221 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
222+ links := fileLinks{src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String()}
223+ if one {
224+ // A one-file read holds file from alone, at index from: the load
225+ // addresses of a window do not apply, only the raw link.
226+ p.Files[from].Raw = rawLink(res.Diff.Files[from], links)
227+ } else {
228+ collapseFiles(p.Files, res.Diff.Files, links)
229+ }
201230 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232+ if one {
233+ window = p.Files[from : from+1]
234+ }
203235 // The layouts follow the page: it sends ?view= (the layout shown) and
204236 // ?layouts=both when it holds both layouts and switches them itself, so
205237 // that every window of the page renders the same way.
@@ -209,12 +241,6 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
209241 }
210242 lines, _ := diffLines(window)
211243 v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
212- switch r.URL.Query().Get("layouts") {
213- case "both":
214- v.Both = true
215- case "one":
216- v.Both = false
217- }
218- v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
244+ v.pageLayouts(r)
219245 return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil
220246 }
@@ -73,6 +73,18 @@ func parseFrom(r *http.Request) (int, error) {
73 return n, nil73 return n, nil
74 }74 }
75 75
76+// parseOne reads ?one=1&from=i of a one-file fragment: the index i of the
77+// file, any non-negative number.
78+func parseOne(r *http.Request) (int, error) {
79+ q := r.URL.Query()
80+ s := q.Get("from")
81+ n, err := strconv.Atoi(s)
82+ if q.Get("one") != "1" || err != nil || n < 0 || strconv.Itoa(n) != s {
83+ return 0, errNotFound
84+ }
85+ return n, nil
86+}
87+
76 // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows88 // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows
77 // a placeholder that loads their window. src is the URL of /files with the89 // a placeholder that loads their window. src is the URL of /files with the
78 // base and the head of the diff; self the page, for the links of a page90 // base and the head of the diff; self the page, for the links of a page
@@ -167,7 +179,13 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
167 if since && !SignedIn(ctx) {179 if since && !SignedIn(ctx) {
168 return nil, 0, errNotFound180 return nil, 0, errNotFound
169 }181 }
170- from, err := parseFrom(r)182+ one := q.Get("one") == "1"
183+ var from int
184+ if one {
185+ from, err = parseOne(r)
186+ } else {
187+ from, err = parseFrom(r)
188+ }
171 if err != nil {189 if err != nil {
172 return nil, 0, err190 return nil, 0, err
173 }191 }
@@ -180,9 +198,12 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
180 if err != nil {198 if err != nil {
181 return nil, 0, err199 return nil, 0, err
182 }200 }
201+ win := &ports.FileWindow{From: from, To: from + mrWindow}
202+ if one {
203+ win = &ports.FileWindow{From: from, To: from + 1, Full: true}
204+ }
183 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{205 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
184- SinceLastRead: since, DiffOnly: true, Base: base, Head: head,206+ SinceLastRead: since, DiffOnly: true, Base: base, Head: head, Files: win,
185- Files: &ports.FileWindow{From: from, To: from + mrWindow},
186 })207 })
187 if errors.Is(err, domain.ErrConflict) {208 if errors.Is(err, domain.ErrConflict) {
188 return MRFilesMoved(), http.StatusConflict, nil209 return MRFilesMoved(), http.StatusConflict, nil
@@ -198,8 +219,19 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
198 canWrite := signed && m.State == domain.MRStateOpen && !since219 canWrite := signed && m.State == domain.MRStateOpen && !since
199 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}220 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))221 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
222+ links := fileLinks{src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String()}
223+ if one {
224+ // A one-file read holds file from alone, at index from: the load
225+ // addresses of a window do not apply, only the raw link.
226+ p.Files[from].Raw = rawLink(res.Diff.Files[from], links)
227+ } else {
228+ collapseFiles(p.Files, res.Diff.Files, links)
229+ }
201 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)230 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202 window := p.Files[from:min(from+mrWindow, len(p.Files))]231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232+ if one {
233+ window = p.Files[from : from+1]
234+ }
203 // The layouts follow the page: it sends ?view= (the layout shown) and235 // The layouts follow the page: it sends ?view= (the layout shown) and
204 // ?layouts=both when it holds both layouts and switches them itself, so236 // ?layouts=both when it holds both layouts and switches them itself, so
205 // that every window of the page renders the same way.237 // that every window of the page renders the same way.
@@ -209,12 +241,6 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
209 }241 }
210 lines, _ := diffLines(window)242 lines, _ := diffLines(window)
211 v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))243 v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
212- switch r.URL.Query().Get("layouts") {244+ v.pageLayouts(r)
213- case "both":
214- v.Both = true
215- case "one":
216- v.Both = false
217- }
218- v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
219 return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil245 return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil
220 }246 }
M internal/adapters/web/shell.templ +3 -0
@@ -30,6 +30,9 @@ templ Shell(l Layout, rail templ.Component) {
3030 <script src={ l.Assets.Files } defer></script>
3131 <script src={ l.Assets.Passkey } defer></script>
3232 <script src={ l.Assets.Log } defer></script>
33+ if l.Lazy {
34+ <script src={ l.Assets.Lazy } defer></script>
35+ }
3336 if l.MR {
3437 <script src={ l.Assets.MR } defer></script>
3538 }
@@ -30,6 +30,9 @@ templ Shell(l Layout, rail templ.Component) {
30 <script src={ l.Assets.Files } defer></script>30 <script src={ l.Assets.Files } defer></script>
31 <script src={ l.Assets.Passkey } defer></script>31 <script src={ l.Assets.Passkey } defer></script>
32 <script src={ l.Assets.Log } defer></script>32 <script src={ l.Assets.Log } defer></script>
33+ if l.Lazy {
34+ <script src={ l.Assets.Lazy } defer></script>
35+ }
33 if l.MR {36 if l.MR {
34 <script src={ l.Assets.MR } defer></script>37 <script src={ l.Assets.MR } defer></script>
35 }38 }
M internal/adapters/web/shell_templ.go +199 -180 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +3 -1
@@ -191,7 +191,9 @@
191191 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }
192192 /* with scripts the window comes by itself: the link of the page without
193193 scripts would only flash */
194- html.js & > a { visibility: hidden; }
194+ html.js &:not([data-manual]) > a { visibility: hidden; }
195+ &[data-manual] { height: auto; padding: var(--s3); }
196+ &[data-manual][data-loading] > a { opacity: .5; }
195197 & .notice { margin: 0; }
196198 }
197199 .dloose {
@@ -191,7 +191,9 @@
191 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }191 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }
192 /* with scripts the window comes by itself: the link of the page without192 /* with scripts the window comes by itself: the link of the page without
193 scripts would only flash */193 scripts would only flash */
194- html.js & > a { visibility: hidden; }194+ html.js &:not([data-manual]) > a { visibility: hidden; }
195+ &[data-manual] { height: auto; padding: var(--s3); }
196+ &[data-manual][data-loading] > a { opacity: .5; }
195 & .notice { margin: 0; }197 & .notice { margin: 0; }
196 }198 }
197 .dloose {199 .dloose {
A internal/adapters/web/static/js/lazy.js +170 -0
@@ -0,0 +1,170 @@
1+// lazy.js: the files of a diff that are not in the page yet (commit, compare
2+// and merge request pages).
3+//
4+// A placeholder is a .dlazy[data-src] in a details.dfile. Two kinds:
5+// - a window of a merge request: loaded by itself when it comes within two
6+// screens of the view (the files of its window replace the placeholders of
7+// the same ids);
8+// - a collapsed file (generated, large) [data-manual]: never loaded by
9+// itself. Its "load diff" link loads that one file (click, or Enter / Space
10+// on the link); without scripts the link reloads the page with the file.
11+// A fragment is fetched once. Nothing observes the DOM: the one
12+// IntersectionObserver watches the automatic placeholders, and a manual one
13+// costs no more than its markup.
14+//
15+// window.ermiteLazy.load is also the loader of mr.js (reveal).
16+window.ermiteLazy = (() => {
17+ const $ = (sel, root = document) => root.querySelector(sel);
18+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
19+ const AUTO = '.dlazy[data-src]:not([data-manual])';
20+ const loading = new Map(); // src -> promise of the swap
21+ let moved = false;
22+ let lazyIO = null; // the observer of the automatic placeholders
23+ let generation = 0; // bumped by reset: older responses are ignored
24+ let onSwap = () => {};
25+
26+ // load fetches the fragment of a placeholder and swaps its files in. The
27+ // promise resolves once they are in the page (or the page said to reload).
28+ function load(ph) {
29+ const gen = generation;
30+ const src = ph?.dataset.src;
31+ if (!src || moved) return Promise.resolve();
32+ if (loading.has(src)) return loading.get(src);
33+ for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
34+ // The fragment renders like the page: the layout shown, and both layouts
35+ // when the page switches them itself (data-diff) or one (data-view).
36+ const d = $('#diffs')?.dataset || {};
37+ const view = d.diff || d.view;
38+ const layouts = d.diff ? 'both' : d.view ? 'one' : '';
39+ const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
40+ const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
41+ .then(async (res) => {
42+ const html = await res.text();
43+ if (gen !== generation) return;
44+ if (res.status === 409) return stale(html);
45+ if (!res.ok) throw new Error('loading files: ' + res.status);
46+ // A fragment of the old diff after the page went stale stays out.
47+ if (!moved) swap(html);
48+ })
49+ .catch((err) => {
50+ if (gen !== generation) return;
51+ loading.delete(src);
52+ for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
53+ window.ermite?.flash?.(err.message, 4000);
54+ });
55+ loading.set(src, p);
56+ return p;
57+ }
58+
59+ // swap replaces each placeholder file with the file of the same id. A
60+ // file the reader folded stays folded.
61+ function swap(html) {
62+ const t = document.createElement('template');
63+ t.innerHTML = html;
64+ for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
65+ const old = document.getElementById(f.id);
66+ if (!old || !old.querySelector('.dlazy')) continue;
67+ // A file still collapsed stays closed; a collapsed file opens once
68+ // loaded; a file of a window keeps the fold.
69+ f.open = f.querySelector('.dlazy[data-manual]') ? false
70+ : old.querySelector('.dlazy[data-manual]') ? true : old.open;
71+ for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
72+ old.replaceWith(f);
73+ window.htmx?.process(f);
74+ window.ermite?.watchFile?.(f, old);
75+ }
76+ onSwap();
77+ }
78+
79+ // stale: the diff changed under the page. The placeholders say so; no file
80+ // of another diff comes in.
81+ function stale(html) {
82+ moved = true;
83+ for (const p of $$('.dlazy')) {
84+ p.removeAttribute('data-loading');
85+ p.innerHTML = html;
86+ }
87+ }
88+
89+ // Automatic placeholders load as they come within two screens of the view;
90+ // files that come in are marked in the list of files (ermite.js marks the
91+ // one in view). Manual ones are left alone.
92+ function watch() {
93+ lazyIO?.disconnect();
94+ if (!('IntersectionObserver' in window)) {
95+ for (const p of $$(AUTO)) load(p);
96+ return;
97+ }
98+ lazyIO = new IntersectionObserver((entries) => {
99+ for (const e of entries) {
100+ if (!e.isIntersecting) continue;
101+ lazyIO.unobserve(e.target);
102+ load(e.target);
103+ }
104+ }, { rootMargin: '200% 0px 200% 0px' });
105+ for (const p of $$(AUTO)) lazyIO.observe(p);
106+ }
107+
108+ // reset forgets the loads in flight and the stale state: the page shows
109+ // another diff (refresh in place). watch starts the observer again.
110+ function reset() {
111+ generation++;
112+ moved = false;
113+ loading.clear();
114+ lazyIO?.disconnect();
115+ }
116+
117+ // manualLink is the "load diff" link of an event target, when it is one that
118+ // loads in place (the placeholder has an address).
119+ const manualLink = (t) => {
120+ const a = t?.closest?.('.dlazy[data-manual] > a');
121+ return a && a.closest('.dlazy')?.dataset.src ? a : null;
122+ };
123+
124+ // loadManual loads the file of a "load diff" link and puts the focus in it:
125+ // on its first line link, else on its header. The scroll never moves for it.
126+ function loadManual(a) {
127+ const ph = a.closest('.dlazy');
128+ const id = ph.closest('details')?.id;
129+ return load(ph).then(() => {
130+ const f = id && document.getElementById(id);
131+ if (!f || f.querySelector('.dlazy[data-manual]')) return;
132+ (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true });
133+ });
134+ }
135+
136+ function onClick(e) {
137+ if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
138+ const a = manualLink(e.target);
139+ if (!a) return false;
140+ e.preventDefault();
141+ loadManual(a);
142+ return true;
143+ }
144+ // Enter makes the browser click a link by itself; Space does not. Both are
145+ // handled here, once (a fragment is fetched once).
146+ function onKey(e) {
147+ // A held key repeats: after the load, focus is on a line link.
148+ if (e.repeat) return false;
149+ if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
150+ const a = manualLink(e.target);
151+ if (!a) return false;
152+ e.preventDefault();
153+ loadManual(a);
154+ return true;
155+ }
156+
157+ let started = false;
158+ function start() {
159+ if (started || !$('#diffs')) return;
160+ started = true;
161+ watch();
162+ document.addEventListener('click', onClick);
163+ document.addEventListener('keydown', onKey);
164+ }
165+
166+ return { load, start, watch, reset, onSwap: (fn) => { onSwap = fn; }, onClick, onKey };
167+})();
168+
169+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start());
170+else window.ermiteLazy.start();
@@ -0,0 +1,170 @@
1+// lazy.js: the files of a diff that are not in the page yet (commit, compare
2+// and merge request pages).
3+//
4+// A placeholder is a .dlazy[data-src] in a details.dfile. Two kinds:
5+// - a window of a merge request: loaded by itself when it comes within two
6+// screens of the view (the files of its window replace the placeholders of
7+// the same ids);
8+// - a collapsed file (generated, large) [data-manual]: never loaded by
9+// itself. Its "load diff" link loads that one file (click, or Enter / Space
10+// on the link); without scripts the link reloads the page with the file.
11+// A fragment is fetched once. Nothing observes the DOM: the one
12+// IntersectionObserver watches the automatic placeholders, and a manual one
13+// costs no more than its markup.
14+//
15+// window.ermiteLazy.load is also the loader of mr.js (reveal).
16+window.ermiteLazy = (() => {
17+ const $ = (sel, root = document) => root.querySelector(sel);
18+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
19+ const AUTO = '.dlazy[data-src]:not([data-manual])';
20+ const loading = new Map(); // src -> promise of the swap
21+ let moved = false;
22+ let lazyIO = null; // the observer of the automatic placeholders
23+ let generation = 0; // bumped by reset: older responses are ignored
24+ let onSwap = () => {};
25+
26+ // load fetches the fragment of a placeholder and swaps its files in. The
27+ // promise resolves once they are in the page (or the page said to reload).
28+ function load(ph) {
29+ const gen = generation;
30+ const src = ph?.dataset.src;
31+ if (!src || moved) return Promise.resolve();
32+ if (loading.has(src)) return loading.get(src);
33+ for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
34+ // The fragment renders like the page: the layout shown, and both layouts
35+ // when the page switches them itself (data-diff) or one (data-view).
36+ const d = $('#diffs')?.dataset || {};
37+ const view = d.diff || d.view;
38+ const layouts = d.diff ? 'both' : d.view ? 'one' : '';
39+ const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
40+ const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
41+ .then(async (res) => {
42+ const html = await res.text();
43+ if (gen !== generation) return;
44+ if (res.status === 409) return stale(html);
45+ if (!res.ok) throw new Error('loading files: ' + res.status);
46+ // A fragment of the old diff after the page went stale stays out.
47+ if (!moved) swap(html);
48+ })
49+ .catch((err) => {
50+ if (gen !== generation) return;
51+ loading.delete(src);
52+ for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
53+ window.ermite?.flash?.(err.message, 4000);
54+ });
55+ loading.set(src, p);
56+ return p;
57+ }
58+
59+ // swap replaces each placeholder file with the file of the same id. A
60+ // file the reader folded stays folded.
61+ function swap(html) {
62+ const t = document.createElement('template');
63+ t.innerHTML = html;
64+ for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
65+ const old = document.getElementById(f.id);
66+ if (!old || !old.querySelector('.dlazy')) continue;
67+ // A file still collapsed stays closed; a collapsed file opens once
68+ // loaded; a file of a window keeps the fold.
69+ f.open = f.querySelector('.dlazy[data-manual]') ? false
70+ : old.querySelector('.dlazy[data-manual]') ? true : old.open;
71+ for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
72+ old.replaceWith(f);
73+ window.htmx?.process(f);
74+ window.ermite?.watchFile?.(f, old);
75+ }
76+ onSwap();
77+ }
78+
79+ // stale: the diff changed under the page. The placeholders say so; no file
80+ // of another diff comes in.
81+ function stale(html) {
82+ moved = true;
83+ for (const p of $$('.dlazy')) {
84+ p.removeAttribute('data-loading');
85+ p.innerHTML = html;
86+ }
87+ }
88+
89+ // Automatic placeholders load as they come within two screens of the view;
90+ // files that come in are marked in the list of files (ermite.js marks the
91+ // one in view). Manual ones are left alone.
92+ function watch() {
93+ lazyIO?.disconnect();
94+ if (!('IntersectionObserver' in window)) {
95+ for (const p of $$(AUTO)) load(p);
96+ return;
97+ }
98+ lazyIO = new IntersectionObserver((entries) => {
99+ for (const e of entries) {
100+ if (!e.isIntersecting) continue;
101+ lazyIO.unobserve(e.target);
102+ load(e.target);
103+ }
104+ }, { rootMargin: '200% 0px 200% 0px' });
105+ for (const p of $$(AUTO)) lazyIO.observe(p);
106+ }
107+
108+ // reset forgets the loads in flight and the stale state: the page shows
109+ // another diff (refresh in place). watch starts the observer again.
110+ function reset() {
111+ generation++;
112+ moved = false;
113+ loading.clear();
114+ lazyIO?.disconnect();
115+ }
116+
117+ // manualLink is the "load diff" link of an event target, when it is one that
118+ // loads in place (the placeholder has an address).
119+ const manualLink = (t) => {
120+ const a = t?.closest?.('.dlazy[data-manual] > a');
121+ return a && a.closest('.dlazy')?.dataset.src ? a : null;
122+ };
123+
124+ // loadManual loads the file of a "load diff" link and puts the focus in it:
125+ // on its first line link, else on its header. The scroll never moves for it.
126+ function loadManual(a) {
127+ const ph = a.closest('.dlazy');
128+ const id = ph.closest('details')?.id;
129+ return load(ph).then(() => {
130+ const f = id && document.getElementById(id);
131+ if (!f || f.querySelector('.dlazy[data-manual]')) return;
132+ (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true });
133+ });
134+ }
135+
136+ function onClick(e) {
137+ if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
138+ const a = manualLink(e.target);
139+ if (!a) return false;
140+ e.preventDefault();
141+ loadManual(a);
142+ return true;
143+ }
144+ // Enter makes the browser click a link by itself; Space does not. Both are
145+ // handled here, once (a fragment is fetched once).
146+ function onKey(e) {
147+ // A held key repeats: after the load, focus is on a line link.
148+ if (e.repeat) return false;
149+ if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
150+ const a = manualLink(e.target);
151+ if (!a) return false;
152+ e.preventDefault();
153+ loadManual(a);
154+ return true;
155+ }
156+
157+ let started = false;
158+ function start() {
159+ if (started || !$('#diffs')) return;
160+ started = true;
161+ watch();
162+ document.addEventListener('click', onClick);
163+ document.addEventListener('keydown', onKey);
164+ }
165+
166+ return { load, start, watch, reset, onSwap: (fn) => { onSwap = fn; }, onClick, onKey };
167+})();
168+
169+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start());
170+else window.ermiteLazy.start();
M internal/adapters/web/static/js/mr.js +8 -91
@@ -1,10 +1,9 @@
11 // mr.js: the merge request page beyond what htmx and hx-live give.
22 //
33 // 1. Windows of files. The page holds the hunks of one window of files; the
4-// other files are placeholders (.dlazy[data-src]). A placeholder that comes
5-// near the screen fetches its window (/files?from=&base=&head=), whose
6-// files replace the placeholders of the same ids. A window is fetched once;
7-// a link to a file or to a comment of a placeholder fetches it first.
4+// other files are placeholders (.dlazy[data-src]), loaded by lazy.js (which
5+// the commit and compare pages use too) when they come near the screen. A
6+// link to a file or to a comment of a placeholder loads it first (reveal).
87 // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu
98 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter
109 // submits (the browser does it: the focus is on the button), Esc closes.
@@ -80,72 +79,10 @@ window.ermiteMR = (() => {
8079
8180 const $ = (sel, root = document) => root.querySelector(sel);
8281 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
83- const loading = new Map(); // src -> promise of the swap
8482 let refreshTouchComments = () => {};
85- let moved = false;
86- let lazyIO = null; // the observer of the placeholders (watch)
87- let diffGeneration = 0;
8883 let revealGen = 0; // the last reveal wins the scroll
89-
90- // load fetches the window of a placeholder and swaps its files in. The
91- // promise resolves once they are in the page (or the page said to reload).
92- function load(ph) {
93- const generation = diffGeneration;
94- const src = ph?.dataset.src;
95- if (!src || moved) return Promise.resolve();
96- if (loading.has(src)) return loading.get(src);
97- for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
98- // The window renders like the page: the layout shown, and both layouts
99- // when the page switches them itself (data-diff) or one (data-view).
100- const d = $('#diffs')?.dataset || {};
101- const view = d.diff || d.view;
102- const layouts = d.diff ? 'both' : d.view ? 'one' : '';
103- const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
104- const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
105- .then(async (res) => {
106- const html = await res.text();
107- if (generation !== diffGeneration) return;
108- if (res.status === 409) return stale(html);
109- if (!res.ok) throw new Error('loading files: ' + res.status);
110- // A window of the old diff after the page went stale stays out.
111- if (!moved) swap(html);
112- })
113- .catch((err) => {
114- if (generation !== diffGeneration) return;
115- loading.delete(src);
116- for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
117- window.ermite?.flash?.(err.message, 4000);
118- });
119- loading.set(src, p);
120- return p;
121- }
122-
123- // swap replaces each placeholder file with the file of the same id. A
124- // file the reader folded stays folded.
125- function swap(html) {
126- const t = document.createElement('template');
127- t.innerHTML = html;
128- for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
129- const old = document.getElementById(f.id);
130- if (!old || !old.querySelector('.dlazy')) continue;
131- f.open = old.open;
132- for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
133- old.replaceWith(f);
134- window.htmx?.process(f);
135- window.ermite?.watchFile?.(f, old);
136- }
137- refreshTouchComments();
138- }
139-
140- // stale: the diff changed under the page. The placeholders say so; no file
141- // of another diff comes in.
142- function stale(html) {
143- moved = true;
144- for (const p of $$('.dlazy')) {
145- p.removeAttribute('data-loading');
146- p.innerHTML = html;
147- }
148- }
84+ const load = (ph) => window.ermiteLazy.load(ph);
85+ window.ermiteLazy.onSwap(() => refreshTouchComments());
14986
15087 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
15188 // a placeholder, then scrolls to it. It returns false when there is nothing
@@ -163,7 +100,7 @@ window.ermiteMR = (() => {
163100 }
164101 return document.getElementById(id);
165102 };
166- let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
103+ let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])');
167104 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
168105 const base = id.replace(/-s$/, '');
169106 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
@@ -195,25 +132,6 @@ window.ermiteMR = (() => {
195132 return true;
196133 }
197134
198- // Placeholders load as they come within two screens of the view; files
199- // that come in are marked in the list of files (ermite.js marks the one in
200- // view).
201- function watch() {
202- lazyIO?.disconnect();
203- if (!('IntersectionObserver' in window)) {
204- for (const p of $$('.dlazy[data-src]')) load(p);
205- return;
206- }
207- lazyIO = new IntersectionObserver((entries) => {
208- for (const e of entries) {
209- if (!e.isIntersecting) continue;
210- lazyIO.unobserve(e.target);
211- load(e.target);
212- }
213- }, { rootMargin: '200% 0px 200% 0px' });
214- for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
215- }
216-
217135 // The merge menu.
218136 const menu = () => $('#merge-menu');
219137 function openMerge() {
@@ -598,7 +516,7 @@ window.ermiteMR = (() => {
598516 const active = document.activeElement;
599517 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
600518 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
601- ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();
519+ ++revealGen; window.ermiteLazy.reset();
602520 $('#diffs', page).dataset.mrStarted = 'true';
603521 // The fresh footer comes in with the panels as they are, so that an open
604522 // panel does not fold and open again while the other regions swap.
@@ -624,7 +542,7 @@ window.ermiteMR = (() => {
624542 touchComments(false);
625543 $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);
626544 window.ermite?.refreshFiles?.();
627- watch();
545+ window.ermiteLazy.watch();
628546 if (focus && active !== document.body) {
629547 const region = focus.region ? $(focus.region) : document;
630548 const menu = focus.menu && document.getElementById(focus.menu);
@@ -700,7 +618,6 @@ window.ermiteMR = (() => {
700618 // the address of the request, so that a reload does not post again.
701619 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
702620 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
703- watch();
704621 // A link to a file or a comment that is not loaded yet.
705622 const hash = () => {
706623 const id = decodeURIComponent(location.hash.slice(1));
@@ -1,10 +1,9 @@
1 // mr.js: the merge request page beyond what htmx and hx-live give.1 // mr.js: the merge request page beyond what htmx and hx-live give.
2 //2 //
3 // 1. Windows of files. The page holds the hunks of one window of files; the3 // 1. Windows of files. The page holds the hunks of one window of files; the
4-// other files are placeholders (.dlazy[data-src]). A placeholder that comes4+// other files are placeholders (.dlazy[data-src]), loaded by lazy.js (which
5-// near the screen fetches its window (/files?from=&base=&head=), whose5+// the commit and compare pages use too) when they come near the screen. A
6-// files replace the placeholders of the same ids. A window is fetched once;6+// link to a file or to a comment of a placeholder loads it first (reveal).
7-// a link to a file or to a comment of a placeholder fetches it first.
8 // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu7 // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu
9 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter8 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter
10 // submits (the browser does it: the focus is on the button), Esc closes.9 // submits (the browser does it: the focus is on the button), Esc closes.
@@ -80,72 +79,10 @@ window.ermiteMR = (() => {
80 79
81 const $ = (sel, root = document) => root.querySelector(sel);80 const $ = (sel, root = document) => root.querySelector(sel);
82 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];81 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
83- const loading = new Map(); // src -> promise of the swap
84 let refreshTouchComments = () => {};82 let refreshTouchComments = () => {};
85- let moved = false;
86- let lazyIO = null; // the observer of the placeholders (watch)
87- let diffGeneration = 0;
88 let revealGen = 0; // the last reveal wins the scroll83 let revealGen = 0; // the last reveal wins the scroll
89-84+ const load = (ph) => window.ermiteLazy.load(ph);
90- // load fetches the window of a placeholder and swaps its files in. The85+ window.ermiteLazy.onSwap(() => refreshTouchComments());
91- // promise resolves once they are in the page (or the page said to reload).
92- function load(ph) {
93- const generation = diffGeneration;
94- const src = ph?.dataset.src;
95- if (!src || moved) return Promise.resolve();
96- if (loading.has(src)) return loading.get(src);
97- for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
98- // The window renders like the page: the layout shown, and both layouts
99- // when the page switches them itself (data-diff) or one (data-view).
100- const d = $('#diffs')?.dataset || {};
101- const view = d.diff || d.view;
102- const layouts = d.diff ? 'both' : d.view ? 'one' : '';
103- const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
104- const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
105- .then(async (res) => {
106- const html = await res.text();
107- if (generation !== diffGeneration) return;
108- if (res.status === 409) return stale(html);
109- if (!res.ok) throw new Error('loading files: ' + res.status);
110- // A window of the old diff after the page went stale stays out.
111- if (!moved) swap(html);
112- })
113- .catch((err) => {
114- if (generation !== diffGeneration) return;
115- loading.delete(src);
116- for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
117- window.ermite?.flash?.(err.message, 4000);
118- });
119- loading.set(src, p);
120- return p;
121- }
122-
123- // swap replaces each placeholder file with the file of the same id. A
124- // file the reader folded stays folded.
125- function swap(html) {
126- const t = document.createElement('template');
127- t.innerHTML = html;
128- for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
129- const old = document.getElementById(f.id);
130- if (!old || !old.querySelector('.dlazy')) continue;
131- f.open = old.open;
132- for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
133- old.replaceWith(f);
134- window.htmx?.process(f);
135- window.ermite?.watchFile?.(f, old);
136- }
137- refreshTouchComments();
138- }
139-
140- // stale: the diff changed under the page. The placeholders say so; no file
141- // of another diff comes in.
142- function stale(html) {
143- moved = true;
144- for (const p of $$('.dlazy')) {
145- p.removeAttribute('data-loading');
146- p.innerHTML = html;
147- }
148- }
149 86
150 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is87 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
151 // a placeholder, then scrolls to it. It returns false when there is nothing88 // a placeholder, then scrolls to it. It returns false when there is nothing
@@ -163,7 +100,7 @@ window.ermiteMR = (() => {
163 }100 }
164 return document.getElementById(id);101 return document.getElementById(id);
165 };102 };
166- let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');103+ let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])');
167 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {104 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
168 const base = id.replace(/-s$/, '');105 const base = id.replace(/-s$/, '');
169 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));106 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
@@ -195,25 +132,6 @@ window.ermiteMR = (() => {
195 return true;132 return true;
196 }133 }
197 134
198- // Placeholders load as they come within two screens of the view; files
199- // that come in are marked in the list of files (ermite.js marks the one in
200- // view).
201- function watch() {
202- lazyIO?.disconnect();
203- if (!('IntersectionObserver' in window)) {
204- for (const p of $$('.dlazy[data-src]')) load(p);
205- return;
206- }
207- lazyIO = new IntersectionObserver((entries) => {
208- for (const e of entries) {
209- if (!e.isIntersecting) continue;
210- lazyIO.unobserve(e.target);
211- load(e.target);
212- }
213- }, { rootMargin: '200% 0px 200% 0px' });
214- for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
215- }
216-
217 // The merge menu.135 // The merge menu.
218 const menu = () => $('#merge-menu');136 const menu = () => $('#merge-menu');
219 function openMerge() {137 function openMerge() {
@@ -598,7 +516,7 @@ window.ermiteMR = (() => {
598 const active = document.activeElement;516 const active = document.activeElement;
599 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };517 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
600 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);518 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
601- ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();519+ ++revealGen; window.ermiteLazy.reset();
602 $('#diffs', page).dataset.mrStarted = 'true';520 $('#diffs', page).dataset.mrStarted = 'true';
603 // The fresh footer comes in with the panels as they are, so that an open521 // The fresh footer comes in with the panels as they are, so that an open
604 // panel does not fold and open again while the other regions swap.522 // panel does not fold and open again while the other regions swap.
@@ -624,7 +542,7 @@ window.ermiteMR = (() => {
624 touchComments(false);542 touchComments(false);
625 $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);543 $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);
626 window.ermite?.refreshFiles?.();544 window.ermite?.refreshFiles?.();
627- watch();545+ window.ermiteLazy.watch();
628 if (focus && active !== document.body) {546 if (focus && active !== document.body) {
629 const region = focus.region ? $(focus.region) : document;547 const region = focus.region ? $(focus.region) : document;
630 const menu = focus.menu && document.getElementById(focus.menu);548 const menu = focus.menu && document.getElementById(focus.menu);
@@ -700,7 +618,6 @@ window.ermiteMR = (() => {
700 // the address of the request, so that a reload does not post again.618 // the address of the request, so that a reload does not post again.
701 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;619 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
702 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);620 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
703- watch();
704 // A link to a file or a comment that is not loaded yet.621 // A link to a file or a comment that is not loaded yet.
705 const hash = () => {622 const hash = () => {
706 const id = decodeURIComponent(location.hash.slice(1));623 const id = decodeURIComponent(location.hash.slice(1));
M internal/adapters/web/view.go +3 -0
@@ -67,6 +67,9 @@ type Layout struct {
6767 // Passkeys tells that the passkey routes exist: the header then links to
6868 // them.
6969 Passkeys bool
70+ // Lazy loads the loader of the collapsed and lazy files of a diff
71+ // (lazy.js): the commit, compare and merge request pages.
72+ Lazy bool
7073 // MR loads the script of the merge request page (mr.js).
7174 MR bool
7275 // IssueForm loads the script of the public issue form (issue-form.js).
@@ -67,6 +67,9 @@ type Layout struct {
67 // Passkeys tells that the passkey routes exist: the header then links to67 // Passkeys tells that the passkey routes exist: the header then links to
68 // them.68 // them.
69 Passkeys bool69 Passkeys bool
70+ // Lazy loads the loader of the collapsed and lazy files of a diff
71+ // (lazy.js): the commit, compare and merge request pages.
72+ Lazy bool
70 // MR loads the script of the merge request page (mr.js).73 // MR loads the script of the merge request page (mr.js).
71 MR bool74 MR bool
72 // IssueForm loads the script of the public issue form (issue-form.js).75 // IssueForm loads the script of the public issue form (issue-form.js).
M internal/adapters/web/web.go +1 -0
@@ -207,6 +207,7 @@ func (s *Server) routes() *http.ServeMux {
207207 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
208208 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
209209 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
210+ mux.HandleFunc("GET /{repo}/commit/{sha}/files", s.page(s.commitFiles))
210211 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
211212 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
212213 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
@@ -207,6 +207,7 @@ func (s *Server) routes() *http.ServeMux {
207 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))207 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
208 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))208 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
209 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))209 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
210+ mux.HandleFunc("GET /{repo}/commit/{sha}/files", s.page(s.commitFiles))
210 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))211 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
211 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))212 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
212 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))213 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
M internal/app/cli_mr.go +21 -10
@@ -470,14 +470,16 @@ type jsonHunk struct {
470470 }
471471
472472 type jsonFile struct {
473- OldPath string `json:"old_path"`
474- NewPath string `json:"new_path"`
475- Status string `json:"status"`
476- Additions int32 `json:"additions"`
477- Deletions int32 `json:"deletions"`
478- Binary bool `json:"binary"`
479- Truncated bool `json:"truncated"`
480- Hunks []jsonHunk `json:"hunks,omitempty"`
473+ OldPath string `json:"old_path"`
474+ NewPath string `json:"new_path"`
475+ Status string `json:"status"`
476+ Additions int32 `json:"additions"`
477+ Deletions int32 `json:"deletions"`
478+ Binary bool `json:"binary"`
479+ Truncated bool `json:"truncated"`
480+ // Omitted is generated, large, budget or unavailable; absent when the hunks are not omitted.
481+ Omitted string `json:"omitted,omitempty"`
482+ Hunks []jsonHunk `json:"hunks,omitempty"`
481483 }
482484
483485 type jsonCommit struct {
@@ -516,6 +518,14 @@ func fileStatusName(s ermite.FileStatus) string {
516518 return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_"))
517519 }
518520
521+// fileOmissionName is the lowercase reason of an omitted file, "" for none.
522+func fileOmissionName(o ermite.FileOmission) string {
523+ if o == ermite.FileOmission_FILE_OMISSION_UNSPECIFIED {
524+ return ""
525+ }
526+ return strings.ToLower(strings.TrimPrefix(o.String(), "FILE_OMISSION_"))
527+}
528+
519529 func lineKindName(k ermite.LineKind) string {
520530 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
521531 }
@@ -540,6 +550,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri
540550 jf := jsonFile{
541551 OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()),
542552 Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(),
553+ Omitted: fileOmissionName(f.GetOmitted()),
543554 }
544555 if withDiff {
545556 for _, h := range f.GetHunks() {
@@ -581,8 +592,8 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
581592 "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +
582593 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
583594 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
584- "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
585- "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
595+ "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated, omitted (generated, large, budget or unavailable; absent when the hunks are\n" +
596+ "not omitted) and, with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
586597 "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
587598 Args: cobra.ExactArgs(2),
588599 ValidArgsFunction: completeMRRepoThenNumber(env),
@@ -470,14 +470,16 @@ type jsonHunk struct {
470 }470 }
471 471
472 type jsonFile struct {472 type jsonFile struct {
473- OldPath string `json:"old_path"`473+ OldPath string `json:"old_path"`
474- NewPath string `json:"new_path"`474+ NewPath string `json:"new_path"`
475- Status string `json:"status"`475+ Status string `json:"status"`
476- Additions int32 `json:"additions"`476+ Additions int32 `json:"additions"`
477- Deletions int32 `json:"deletions"`477+ Deletions int32 `json:"deletions"`
478- Binary bool `json:"binary"`478+ Binary bool `json:"binary"`
479- Truncated bool `json:"truncated"`479+ Truncated bool `json:"truncated"`
480- Hunks []jsonHunk `json:"hunks,omitempty"`480+ // Omitted is generated, large, budget or unavailable; absent when the hunks are not omitted.
481+ Omitted string `json:"omitted,omitempty"`
482+ Hunks []jsonHunk `json:"hunks,omitempty"`
481 }483 }
482 484
483 type jsonCommit struct {485 type jsonCommit struct {
@@ -516,6 +518,14 @@ func fileStatusName(s ermite.FileStatus) string {
516 return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_"))518 return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_"))
517 }519 }
518 520
521+// fileOmissionName is the lowercase reason of an omitted file, "" for none.
522+func fileOmissionName(o ermite.FileOmission) string {
523+ if o == ermite.FileOmission_FILE_OMISSION_UNSPECIFIED {
524+ return ""
525+ }
526+ return strings.ToLower(strings.TrimPrefix(o.String(), "FILE_OMISSION_"))
527+}
528+
519 func lineKindName(k ermite.LineKind) string {529 func lineKindName(k ermite.LineKind) string {
520 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))530 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
521 }531 }
@@ -540,6 +550,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri
540 jf := jsonFile{550 jf := jsonFile{
541 OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()),551 OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()),
542 Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(),552 Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(),
553+ Omitted: fileOmissionName(f.GetOmitted()),
543 }554 }
544 if withDiff {555 if withDiff {
545 for _, h := range f.GetHunks() {556 for _, h := range f.GetHunks() {
@@ -581,8 +592,8 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
581 "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +592 "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +
582 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +593 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
583 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +594 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
584- "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +595+ "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated, omitted (generated, large, budget or unavailable; absent when the hunks are\n" +
585- "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +596+ "not omitted) and, with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
586 "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",597 "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
587 Args: cobra.ExactArgs(2),598 Args: cobra.ExactArgs(2),
588 ValidArgsFunction: completeMRRepoThenNumber(env),599 ValidArgsFunction: completeMRRepoThenNumber(env),
M internal/app/cli_mr_test.go +15 -0
@@ -563,3 +563,18 @@ func TestMRWebURL(t *testing.T) {
563563 t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })
564564 }
565565 }
566+
567+func TestJSONFileOmitted(t *testing.T) {
568+ d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Diff: &ermite.Diff{Files: []*ermite.FileDiff{
569+ {NewPath: "a"},
570+ {NewPath: "go.sum", Omitted: ermite.FileOmission_FILE_OMISSION_GENERATED, Truncated: true},
571+ {NewPath: "b", Omitted: ermite.FileOmission_FILE_OMISSION_UNAVAILABLE, Truncated: true},
572+ }}}
573+ got := toJSONDetail(d, false, "https://git.example.org")
574+ assert.Equal(t, []string{"", "generated", "unavailable"}, []string{got.Files[0].Omitted, got.Files[1].Omitted, got.Files[2].Omitted})
575+ raw, err := json.Marshal(got.Files)
576+ require.NoError(t, err)
577+ assert.Contains(t, string(raw), `"omitted":"generated"`)
578+ assert.Contains(t, string(raw), `"truncated":true`)
579+ assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`))
580+}
@@ -563,3 +563,18 @@ func TestMRWebURL(t *testing.T) {
563 t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })563 t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })
564 }564 }
565 }565 }
566+
567+func TestJSONFileOmitted(t *testing.T) {
568+ d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Diff: &ermite.Diff{Files: []*ermite.FileDiff{
569+ {NewPath: "a"},
570+ {NewPath: "go.sum", Omitted: ermite.FileOmission_FILE_OMISSION_GENERATED, Truncated: true},
571+ {NewPath: "b", Omitted: ermite.FileOmission_FILE_OMISSION_UNAVAILABLE, Truncated: true},
572+ }}}
573+ got := toJSONDetail(d, false, "https://git.example.org")
574+ assert.Equal(t, []string{"", "generated", "unavailable"}, []string{got.Files[0].Omitted, got.Files[1].Omitted, got.Files[2].Omitted})
575+ raw, err := json.Marshal(got.Files)
576+ require.NoError(t, err)
577+ assert.Contains(t, string(raw), `"omitted":"generated"`)
578+ assert.Contains(t, string(raw), `"truncated":true`)
579+ assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`))
580+}
M internal/app/testdata/web/commit-big.html +6 -3
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -141,7 +143,6 @@
141143 </div>
142144 </details>
143145 </div>
144-<p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>
145146 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
146147 <details class="dfile lnw-2" id="f1" data-file="big.txt">
147148 <summary>
@@ -149,9 +150,11 @@
149150 <span class="p">big.txt</span>
150151 <span class="plus">+2600</span>
151152 <span class="minus">-0</span>
152-<span class="note">diff too large to show (2600 lines changed): collapsed</span>
153+<span class="note">large diff (2600 lines changed): collapsed</span>
153154 </summary>
154-<p class="note pad">diff too large to show (2600 lines changed): collapsed</p>
155+<div class="dlazy" data-src="/diffs/commit/SHA40/files?one=1&amp;from=0" data-manual>
156+<a href="?file=0#f1">load diff</a>
157+</div>
155158 </details>
156159 </div>
157160 </main>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
@@ -141,7 +143,6 @@
141 </div>143 </div>
142 </details>144 </details>
143 </div>145 </div>
144-<p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>
145 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">146 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
146 <details class="dfile lnw-2" id="f1" data-file="big.txt">147 <details class="dfile lnw-2" id="f1" data-file="big.txt">
147 <summary>148 <summary>
@@ -149,9 +150,11 @@
149 <span class="p">big.txt</span>150 <span class="p">big.txt</span>
150 <span class="plus">+2600</span>151 <span class="plus">+2600</span>
151 <span class="minus">-0</span>152 <span class="minus">-0</span>
152-<span class="note">diff too large to show (2600 lines changed): collapsed</span>153+<span class="note">large diff (2600 lines changed): collapsed</span>
153 </summary>154 </summary>
154-<p class="note pad">diff too large to show (2600 lines changed): collapsed</p>155+<div class="dlazy" data-src="/diffs/commit/SHA40/files?one=1&amp;from=0" data-manual>
156+<a href="?file=0#f1">load diff</a>
157+</div>
155 </details>158 </details>
156 </div>159 </div>
157 </main>160 </main>
M internal/app/testdata/web/commit-binary.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-merge.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify-split.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-rename.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-root.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-trojan.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/web_commit_test.go +63 -5
@@ -41,6 +41,34 @@ func TestWebCommitGolden(t *testing.T) {
4141 }
4242 }
4343
44+// A commit page reads the hunks of its first 300 files; the others are listed
45+// collapsed and load on their own. A binary file or one without changed lines
46+// past the window has nothing to load and gets no link.
47+func TestWebCommitFileWindow(t *testing.T) {
48+ r := newMRRepo(t, domain.VisibilityPublic)
49+ b := browser{r.harness}
50+ for i := range 305 {
51+ require.NoError(t, os.MkdirAll(filepath.Join(r.wt, "many"), 0o755))
52+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, fmt.Sprintf("many/f%03d.txt", i)), []byte(fmt.Sprintf("line %d\n", i)), 0o644))
53+ }
54+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zbin.bin"), []byte("a\x00b"), 0o644))
55+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zempty.txt"), nil, 0o644))
56+ r.mustGit(r.wt, "add", ".")
57+ r.mustGit(r.wt, "commit", "-q", "-m", "many files")
58+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
59+
60+ page := b.get("/proj/commit/"+r.headOf("feat/search"), nil)
61+ require.Equal(t, 200, page.status)
62+ assert.Contains(t, page.body, "line 299</td>", "index 299 is read")
63+ assert.NotContains(t, page.body, "line 300</td>", "index 300 is collapsed")
64+ assert.Equal(t, 5, strings.Count(page.body, "collapsed to keep the page light"))
65+ assert.Equal(t, 5, strings.Count(page.body, "data-manual"), "binary and empty files past the window have nothing to load")
66+ assert.Contains(t, page.body, `<a href="?file=300#f301">load diff</a>`)
67+ assert.NotContains(t, page.body, `?file=299#`)
68+ assert.NotContains(t, page.body, `?file=305#`)
69+ assert.NotContains(t, page.body, `?file=306#`)
70+}
71+
4472 // The behaviours of the commit pages, asserted on their own.
4573 func TestWebCommit(t *testing.T) {
4674 h := newHarness(t, harnessOpts{})
@@ -112,12 +140,42 @@ func TestWebCommit(t *testing.T) {
112140 assert.Contains(t, body, "holds invisible characters")
113141 })
114142
115- t.Run("truncated", func(t *testing.T) {
143+ t.Run("large file is collapsed and loads on its own", func(t *testing.T) {
116144 body := get("/diffs/commit/"+f.big, 200)
117- assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed")
118- assert.Contains(t, body, "this diff is incomplete")
119- assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed")
120- assert.NotContains(t, body, "line 2000", "no lines of a truncated file")
145+ assert.Contains(t, body, "large diff (2600 lines changed): collapsed")
146+ assert.NotContains(t, body, "this diff is incomplete", "a collapsed file is not a global note")
147+ assert.NotRegexp(t, `<details class="dfile[^"]*" id="f1"[^>]* open`, body, "a collapsed file is closed")
148+ assert.Contains(t, body, `<div class="dlazy" data-src="/diffs/commit/`+f.big+`/files?one=1&amp;from=0" data-manual>`)
149+ assert.Contains(t, body, `<a href="?file=0#f1">load diff</a>`)
150+ assert.NotContains(t, body, "line 2000", "no rows for a collapsed file")
151+
152+ // One file, in full, over the 2,000 lines of the page.
153+ frag := get("/diffs/commit/"+f.big+"/files?one=1&from=0", 200)
154+ assert.NotContains(t, frag, "<html")
155+ assert.Equal(t, 1, strings.Count(frag, `<details class="dfile`))
156+ assert.Contains(t, frag, "line 2599")
157+ assert.NotContains(t, frag, "data-manual")
158+ assert.Contains(t, frag, `id="f1"`)
159+ both := get("/diffs/commit/"+f.big+"/files?one=1&from=0&view=split&layouts=one", 200)
160+ assert.NotContains(t, both, `class="dt uni"`)
161+ assert.Contains(t, both, `class="dt spl"`)
162+
163+ // Without scripts: the page splices the file in.
164+ page := get("/diffs/commit/"+f.big+"?file=0", 200)
165+ assert.Contains(t, page, "line 2599")
166+ assert.NotContains(t, page, "data-manual")
167+ assert.Contains(t, get("/diffs/commit/"+f.big+"?file=9", 200), "data-manual", "out of range: ignored")
168+ })
169+
170+ t.Run("one file route", func(t *testing.T) {
171+ for _, p := range []string{
172+ "/files?one=1&from=1", "/files?one=1&from=-1", "/files?one=1&from=x", "/files?from=0", "/files?one=1",
173+ } {
174+ st, _, _ := h.get("/diffs/commit/" + f.big + p)
175+ assert.Equal(t, 404, st, p)
176+ }
177+ st, _, _ := h.get("/diffs/commit/main/files?one=1&from=0")
178+ assert.Equal(t, 404, st)
121179 })
122180
123181 t.Run("not found", func(t *testing.T) {
@@ -41,6 +41,34 @@ func TestWebCommitGolden(t *testing.T) {
41 }41 }
42 }42 }
43 43
44+// A commit page reads the hunks of its first 300 files; the others are listed
45+// collapsed and load on their own. A binary file or one without changed lines
46+// past the window has nothing to load and gets no link.
47+func TestWebCommitFileWindow(t *testing.T) {
48+ r := newMRRepo(t, domain.VisibilityPublic)
49+ b := browser{r.harness}
50+ for i := range 305 {
51+ require.NoError(t, os.MkdirAll(filepath.Join(r.wt, "many"), 0o755))
52+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, fmt.Sprintf("many/f%03d.txt", i)), []byte(fmt.Sprintf("line %d\n", i)), 0o644))
53+ }
54+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zbin.bin"), []byte("a\x00b"), 0o644))
55+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zempty.txt"), nil, 0o644))
56+ r.mustGit(r.wt, "add", ".")
57+ r.mustGit(r.wt, "commit", "-q", "-m", "many files")
58+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
59+
60+ page := b.get("/proj/commit/"+r.headOf("feat/search"), nil)
61+ require.Equal(t, 200, page.status)
62+ assert.Contains(t, page.body, "line 299</td>", "index 299 is read")
63+ assert.NotContains(t, page.body, "line 300</td>", "index 300 is collapsed")
64+ assert.Equal(t, 5, strings.Count(page.body, "collapsed to keep the page light"))
65+ assert.Equal(t, 5, strings.Count(page.body, "data-manual"), "binary and empty files past the window have nothing to load")
66+ assert.Contains(t, page.body, `<a href="?file=300#f301">load diff</a>`)
67+ assert.NotContains(t, page.body, `?file=299#`)
68+ assert.NotContains(t, page.body, `?file=305#`)
69+ assert.NotContains(t, page.body, `?file=306#`)
70+}
71+
44 // The behaviours of the commit pages, asserted on their own.72 // The behaviours of the commit pages, asserted on their own.
45 func TestWebCommit(t *testing.T) {73 func TestWebCommit(t *testing.T) {
46 h := newHarness(t, harnessOpts{})74 h := newHarness(t, harnessOpts{})
@@ -112,12 +140,42 @@ func TestWebCommit(t *testing.T) {
112 assert.Contains(t, body, "holds invisible characters")140 assert.Contains(t, body, "holds invisible characters")
113 })141 })
114 142
115- t.Run("truncated", func(t *testing.T) {143+ t.Run("large file is collapsed and loads on its own", func(t *testing.T) {
116 body := get("/diffs/commit/"+f.big, 200)144 body := get("/diffs/commit/"+f.big, 200)
117- assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed")145+ assert.Contains(t, body, "large diff (2600 lines changed): collapsed")
118- assert.Contains(t, body, "this diff is incomplete")146+ assert.NotContains(t, body, "this diff is incomplete", "a collapsed file is not a global note")
119- assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed")147+ assert.NotRegexp(t, `<details class="dfile[^"]*" id="f1"[^>]* open`, body, "a collapsed file is closed")
120- assert.NotContains(t, body, "line 2000", "no lines of a truncated file")148+ assert.Contains(t, body, `<div class="dlazy" data-src="/diffs/commit/`+f.big+`/files?one=1&amp;from=0" data-manual>`)
149+ assert.Contains(t, body, `<a href="?file=0#f1">load diff</a>`)
150+ assert.NotContains(t, body, "line 2000", "no rows for a collapsed file")
151+
152+ // One file, in full, over the 2,000 lines of the page.
153+ frag := get("/diffs/commit/"+f.big+"/files?one=1&from=0", 200)
154+ assert.NotContains(t, frag, "<html")
155+ assert.Equal(t, 1, strings.Count(frag, `<details class="dfile`))
156+ assert.Contains(t, frag, "line 2599")
157+ assert.NotContains(t, frag, "data-manual")
158+ assert.Contains(t, frag, `id="f1"`)
159+ both := get("/diffs/commit/"+f.big+"/files?one=1&from=0&view=split&layouts=one", 200)
160+ assert.NotContains(t, both, `class="dt uni"`)
161+ assert.Contains(t, both, `class="dt spl"`)
162+
163+ // Without scripts: the page splices the file in.
164+ page := get("/diffs/commit/"+f.big+"?file=0", 200)
165+ assert.Contains(t, page, "line 2599")
166+ assert.NotContains(t, page, "data-manual")
167+ assert.Contains(t, get("/diffs/commit/"+f.big+"?file=9", 200), "data-manual", "out of range: ignored")
168+ })
169+
170+ t.Run("one file route", func(t *testing.T) {
171+ for _, p := range []string{
172+ "/files?one=1&from=1", "/files?one=1&from=-1", "/files?one=1&from=x", "/files?from=0", "/files?one=1",
173+ } {
174+ st, _, _ := h.get("/diffs/commit/" + f.big + p)
175+ assert.Equal(t, 404, st, p)
176+ }
177+ st, _, _ := h.get("/diffs/commit/main/files?one=1&from=0")
178+ assert.Equal(t, 404, st)
121 })179 })
122 180
123 t.Run("not found", func(t *testing.T) {181 t.Run("not found", func(t *testing.T) {
M internal/app/web_mr_test.go +77 -0
@@ -921,3 +921,80 @@ func TestWebMergeRequestMergeabilityLive(t *testing.T) {
921921 break
922922 }
923923 }
924+
925+// Collapsed files of a merge request: a generated file and a file over the
926+// line cap are listed closed, with no rows and a manual placeholder; /files
927+// with one=1 reads exactly one file in full, whatever its index; the page
928+// without scripts (?file=i) splices the file in.
929+func TestWebMergeRequestCollapsedFiles(t *testing.T) {
930+ r := newMRRepo(t, domain.VisibilityPublic)
931+ b := browser{r.harness}
932+ var big strings.Builder
933+ for i := range 2600 {
934+ fmt.Fprintf(&big, "row %d\n", i)
935+ }
936+ r.writeFile(r.wt, "zz/big.txt", []byte(big.String()))
937+ r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n"))
938+ r.writeFile(r.wt, "zz/small.txt", []byte("small line\n"))
939+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
940+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t)
941+
942+ page := b.get("/proj/merge-requests/1", nil)
943+ require.Equal(t, 200, page.status)
944+ assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
945+ assert.Contains(t, page.body, "generated file: collapsed")
946+ assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
947+ assert.NotRegexp(t, `row \d+`, page.body, "no rows for a collapsed file")
948+ assert.NotContains(t, page.body, "generated line</td>")
949+ assert.Contains(t, page.body, "small line")
950+ assert.NotContains(t, page.body, "this diff is incomplete")
951+ srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
952+ require.Len(t, srcs, 2)
953+
954+ for _, m := range srcs {
955+ one := b.get(html.UnescapeString(m[1]), nil)
956+ require.Equal(t, 200, one.status, one.body)
957+ assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file")
958+ assert.NotContains(t, one.body, "data-manual")
959+ }
960+ bigOne := b.get(html.UnescapeString(srcs[1][1]), nil)
961+ genOne := b.get(html.UnescapeString(srcs[0][1]), nil)
962+ assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599"))
963+ assert.Contains(t, bigOne.body+genOne.body, "generated line")
964+
965+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status)
966+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status)
967+
968+ for _, m := range srcs {
969+ own := b.get("/proj/merge-requests/1?file="+m[2], nil)
970+ require.Equal(t, 200, own.status)
971+ assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
972+ }
973+}
974+
975+// The page of a window without scripts: ?file=i splices that collapsed file in
976+// and the load link of a window keeps the window.
977+func TestWebMergeRequestWindowFileSplice(t *testing.T) {
978+ r := newMRRepo(t, domain.VisibilityPublic)
979+ b := browser{r.harness}
980+ for i := range 30 {
981+ r.writeFile(r.wt, fmt.Sprintf("a/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i)))
982+ }
983+ r.writeFile(r.wt, "vendor/gen.txt", []byte("generated line\n"))
984+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
985+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Window").ok(t)
986+
987+ // Files sort as a/f00..a/f29, search.go, vendor/gen.txt: the generated
988+ // file is index 31, in the window that starts at 25.
989+ win := b.get("/proj/merge-requests/1?from=25", nil)
990+ require.Equal(t, 200, win.status)
991+ m := regexp.MustCompile(`href="(\?from=25&amp;file=(\d+)#f\d+)"`).FindStringSubmatch(win.body)
992+ require.Len(t, m, 3, "the load link keeps the window")
993+ assert.Equal(t, "31", m[2])
994+ assert.NotContains(t, win.body, "generated line</td>")
995+
996+ own := b.get("/proj/merge-requests/1?from=25&file=31", nil)
997+ require.Equal(t, 200, own.status)
998+ assert.Contains(t, own.body, "generated line</td>", "the file is spliced in")
999+ assert.NotContains(t, own.body, "data-manual")
1000+}
@@ -921,3 +921,80 @@ func TestWebMergeRequestMergeabilityLive(t *testing.T) {
921 break921 break
922 }922 }
923 }923 }
924+
925+// Collapsed files of a merge request: a generated file and a file over the
926+// line cap are listed closed, with no rows and a manual placeholder; /files
927+// with one=1 reads exactly one file in full, whatever its index; the page
928+// without scripts (?file=i) splices the file in.
929+func TestWebMergeRequestCollapsedFiles(t *testing.T) {
930+ r := newMRRepo(t, domain.VisibilityPublic)
931+ b := browser{r.harness}
932+ var big strings.Builder
933+ for i := range 2600 {
934+ fmt.Fprintf(&big, "row %d\n", i)
935+ }
936+ r.writeFile(r.wt, "zz/big.txt", []byte(big.String()))
937+ r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n"))
938+ r.writeFile(r.wt, "zz/small.txt", []byte("small line\n"))
939+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
940+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t)
941+
942+ page := b.get("/proj/merge-requests/1", nil)
943+ require.Equal(t, 200, page.status)
944+ assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
945+ assert.Contains(t, page.body, "generated file: collapsed")
946+ assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
947+ assert.NotRegexp(t, `row \d+`, page.body, "no rows for a collapsed file")
948+ assert.NotContains(t, page.body, "generated line</td>")
949+ assert.Contains(t, page.body, "small line")
950+ assert.NotContains(t, page.body, "this diff is incomplete")
951+ srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
952+ require.Len(t, srcs, 2)
953+
954+ for _, m := range srcs {
955+ one := b.get(html.UnescapeString(m[1]), nil)
956+ require.Equal(t, 200, one.status, one.body)
957+ assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file")
958+ assert.NotContains(t, one.body, "data-manual")
959+ }
960+ bigOne := b.get(html.UnescapeString(srcs[1][1]), nil)
961+ genOne := b.get(html.UnescapeString(srcs[0][1]), nil)
962+ assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599"))
963+ assert.Contains(t, bigOne.body+genOne.body, "generated line")
964+
965+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status)
966+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status)
967+
968+ for _, m := range srcs {
969+ own := b.get("/proj/merge-requests/1?file="+m[2], nil)
970+ require.Equal(t, 200, own.status)
971+ assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
972+ }
973+}
974+
975+// The page of a window without scripts: ?file=i splices that collapsed file in
976+// and the load link of a window keeps the window.
977+func TestWebMergeRequestWindowFileSplice(t *testing.T) {
978+ r := newMRRepo(t, domain.VisibilityPublic)
979+ b := browser{r.harness}
980+ for i := range 30 {
981+ r.writeFile(r.wt, fmt.Sprintf("a/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i)))
982+ }
983+ r.writeFile(r.wt, "vendor/gen.txt", []byte("generated line\n"))
984+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
985+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Window").ok(t)
986+
987+ // Files sort as a/f00..a/f29, search.go, vendor/gen.txt: the generated
988+ // file is index 31, in the window that starts at 25.
989+ win := b.get("/proj/merge-requests/1?from=25", nil)
990+ require.Equal(t, 200, win.status)
991+ m := regexp.MustCompile(`href="(\?from=25&amp;file=(\d+)#f\d+)"`).FindStringSubmatch(win.body)
992+ require.Len(t, m, 3, "the load link keeps the window")
993+ assert.Equal(t, "31", m[2])
994+ assert.NotContains(t, win.body, "generated line</td>")
995+
996+ own := b.get("/proj/merge-requests/1?from=25&file=31", nil)
997+ require.Equal(t, 200, own.status)
998+ assert.Contains(t, own.body, "generated line</td>", "the file is spliced in")
999+ assert.NotContains(t, own.body, "data-manual")
1000+}
M internal/domain/budget.go +60 -11
@@ -2,7 +2,10 @@
22
33 package domain
44
5-import "fmt"
5+import (
6+ "fmt"
7+ "math"
8+)
69
710 // ValidateCommitObjects checks a request before any repository I/O. max is the
811 // caller's commit-count limit.
@@ -45,15 +48,17 @@ func (b *CommitObjectBudget) Charge(id ObjectID, size int64) {
4548 b.total += size
4649 }
4750
48-// DiffBudget owns file selection and retained hunk accounting. Truncated files
51+// DiffBudget owns file selection and retained hunk accounting. Omitted files
4952 // consume no budget; streaming readers may use a copy while attempting a file.
5053 type DiffBudget struct {
5154 limits DiffLimits
5255 linesLeft, bytesLeft int
56+ // fileBytes is bytesLeft when the current file started (StartFile).
57+ fileBytes int
5358 }
5459
5560 func NewDiffBudget(limits DiffLimits) DiffBudget {
56- return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes}
61+ return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes, fileBytes: limits.MaxTotalBytes}
5762 }
5863
5964 func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
@@ -69,17 +74,61 @@ func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
6974 return
7075 }
7176
72-func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 }
77+// Admit decides whether the hunks of a file are read. read is false with
78+// OmitNone for a binary file or one without changed lines (nothing to show).
79+// Otherwise read is false with the reason the file is omitted. path is the path
80+// a comment designates (see FileDiff.CommentPath).
81+func (b DiffBudget) Admit(binary bool, path string, changed int) (read bool, why FileOmission) {
82+ switch {
83+ case binary || changed <= 0:
84+ return false, OmitNone
85+ case b.limits.SkipGenerated && IsGeneratedPath(path):
86+ return false, OmitGenerated
87+ case b.limits.MaxAloneLines > 0 && changed > b.limits.MaxAloneLines:
88+ return false, OmitUnavailable
89+ case b.limits.MaxFileLines > 0 && changed > b.limits.MaxFileLines:
90+ return false, OmitLarge
91+ case b.limits.MaxTotalLines > 0 && changed > b.linesLeft, b.bytesLeft <= 0:
92+ return false, OmitBudget
93+ }
94+ return true, OmitNone
95+}
7396
74-func (b DiffBudget) WantsHunks(binary bool, n int) bool {
75- return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 0
97+// FileLines is the most changed lines the next file may have; math.MaxInt when
98+// neither MaxFileLines nor MaxTotalLines limits it.
99+func (b DiffBudget) FileLines() int {
100+ n := math.MaxInt
101+ if b.limits.MaxFileLines > 0 {
102+ n = b.limits.MaxFileLines
103+ }
104+ if b.limits.MaxTotalLines > 0 {
105+ n = min(n, b.linesLeft)
106+ }
107+ return n
76108 }
77109
78-func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }
110+// StartFile marks the start of a file's lines, for ChargeLine to tell a file
111+// over the byte limit on its own from one that met the bytes of earlier files.
112+func (b *DiffBudget) StartFile() { b.fileBytes = b.bytesLeft }
79113
80-// ChargeLine accounts for one decoded hunk line and reports whether it fits.
81-func (b *DiffBudget) ChargeLine(bytes int) bool {
82- b.linesLeft--
114+// ChargeLine accounts for decoded text bytes and, for additions or deletions,
115+// one changed line. Context consumes bytes only. It returns OmitNone when the
116+// line fits and OmitBudget when the changed lines ran out. When the bytes run
117+// out it returns OmitBudget if files read before this one (since StartFile)
118+// spent some of them, as this file may still fit on its own, and
119+// OmitUnavailable if this file alone is over MaxTotalBytes.
120+func (b *DiffBudget) ChargeLine(bytes int, changed bool) FileOmission {
121+ if changed && b.limits.MaxTotalLines > 0 {
122+ b.linesLeft--
123+ }
83124 b.bytesLeft -= bytes
84- return b.linesLeft >= 0 && b.bytesLeft >= 0
125+ switch {
126+ case b.limits.MaxTotalLines > 0 && b.linesLeft < 0:
127+ return OmitBudget
128+ case b.bytesLeft < 0 && b.fileBytes < b.limits.MaxTotalBytes:
129+ return OmitBudget
130+ case b.bytesLeft < 0:
131+ return OmitUnavailable
132+ }
133+ return OmitNone
85134 }
@@ -2,7 +2,10 @@
2 2
3 package domain3 package domain
4 4
5-import "fmt"5+import (
6+ "fmt"
7+ "math"
8+)
6 9
7 // ValidateCommitObjects checks a request before any repository I/O. max is the10 // ValidateCommitObjects checks a request before any repository I/O. max is the
8 // caller's commit-count limit.11 // caller's commit-count limit.
@@ -45,15 +48,17 @@ func (b *CommitObjectBudget) Charge(id ObjectID, size int64) {
45 b.total += size48 b.total += size
46 }49 }
47 50
48-// DiffBudget owns file selection and retained hunk accounting. Truncated files51+// DiffBudget owns file selection and retained hunk accounting. Omitted files
49 // consume no budget; streaming readers may use a copy while attempting a file.52 // consume no budget; streaming readers may use a copy while attempting a file.
50 type DiffBudget struct {53 type DiffBudget struct {
51 limits DiffLimits54 limits DiffLimits
52 linesLeft, bytesLeft int55 linesLeft, bytesLeft int
56+ // fileBytes is bytesLeft when the current file started (StartFile).
57+ fileBytes int
53 }58 }
54 59
55 func NewDiffBudget(limits DiffLimits) DiffBudget {60 func NewDiffBudget(limits DiffLimits) DiffBudget {
56- return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes}61+ return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes, fileBytes: limits.MaxTotalBytes}
57 }62 }
58 63
59 func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {64 func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
@@ -69,17 +74,61 @@ func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
69 return74 return
70 }75 }
71 76
72-func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 }77+// Admit decides whether the hunks of a file are read. read is false with
78+// OmitNone for a binary file or one without changed lines (nothing to show).
79+// Otherwise read is false with the reason the file is omitted. path is the path
80+// a comment designates (see FileDiff.CommentPath).
81+func (b DiffBudget) Admit(binary bool, path string, changed int) (read bool, why FileOmission) {
82+ switch {
83+ case binary || changed <= 0:
84+ return false, OmitNone
85+ case b.limits.SkipGenerated && IsGeneratedPath(path):
86+ return false, OmitGenerated
87+ case b.limits.MaxAloneLines > 0 && changed > b.limits.MaxAloneLines:
88+ return false, OmitUnavailable
89+ case b.limits.MaxFileLines > 0 && changed > b.limits.MaxFileLines:
90+ return false, OmitLarge
91+ case b.limits.MaxTotalLines > 0 && changed > b.linesLeft, b.bytesLeft <= 0:
92+ return false, OmitBudget
93+ }
94+ return true, OmitNone
95+}
73 96
74-func (b DiffBudget) WantsHunks(binary bool, n int) bool {97+// FileLines is the most changed lines the next file may have; math.MaxInt when
75- return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 098+// neither MaxFileLines nor MaxTotalLines limits it.
99+func (b DiffBudget) FileLines() int {
100+ n := math.MaxInt
101+ if b.limits.MaxFileLines > 0 {
102+ n = b.limits.MaxFileLines
103+ }
104+ if b.limits.MaxTotalLines > 0 {
105+ n = min(n, b.linesLeft)
106+ }
107+ return n
76 }108 }
77 109
78-func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }110+// StartFile marks the start of a file's lines, for ChargeLine to tell a file
111+// over the byte limit on its own from one that met the bytes of earlier files.
112+func (b *DiffBudget) StartFile() { b.fileBytes = b.bytesLeft }
79 113
80-// ChargeLine accounts for one decoded hunk line and reports whether it fits.114+// ChargeLine accounts for decoded text bytes and, for additions or deletions,
81-func (b *DiffBudget) ChargeLine(bytes int) bool {115+// one changed line. Context consumes bytes only. It returns OmitNone when the
82- b.linesLeft--116+// line fits and OmitBudget when the changed lines ran out. When the bytes run
117+// out it returns OmitBudget if files read before this one (since StartFile)
118+// spent some of them, as this file may still fit on its own, and
119+// OmitUnavailable if this file alone is over MaxTotalBytes.
120+func (b *DiffBudget) ChargeLine(bytes int, changed bool) FileOmission {
121+ if changed && b.limits.MaxTotalLines > 0 {
122+ b.linesLeft--
123+ }
83 b.bytesLeft -= bytes124 b.bytesLeft -= bytes
84- return b.linesLeft >= 0 && b.bytesLeft >= 0125+ switch {
126+ case b.limits.MaxTotalLines > 0 && b.linesLeft < 0:
127+ return OmitBudget
128+ case b.bytesLeft < 0 && b.fileBytes < b.limits.MaxTotalBytes:
129+ return OmitBudget
130+ case b.bytesLeft < 0:
131+ return OmitUnavailable
132+ }
133+ return OmitNone
85 }134 }
M internal/domain/budget_test.go +136 -17
@@ -94,21 +94,32 @@ func TestDiffBudgetWindow(t *testing.T) {
9494 }
9595 }
9696
97-func TestDiffBudgetHunks(t *testing.T) {
97+func TestDiffBudgetAdmit(t *testing.T) {
9898 for _, tt := range []struct {
99- name string
100- binary bool
101- n, lines, bytes int
102- want bool
99+ name string
100+ limits DiffLimits
101+ binary bool
102+ path string
103+ n int
104+ wantRead bool
105+ wantWhy FileOmission
103106 }{
104- {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false},
105- {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false},
106- {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false},
107+ {"fits", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3, MaxTotalBytes: 1}, false, "a.go", 3, true, OmitNone},
108+ {"binary", DiffLimits{MaxTotalBytes: 1}, true, "a.bin", 3, false, OmitNone},
109+ {"no changes", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 0, false, OmitNone},
110+ {"file limit", DiffLimits{MaxFileLines: 3, MaxTotalLines: 5, MaxTotalBytes: 1}, false, "a.go", 4, false, OmitLarge},
111+ {"total lines", DiffLimits{MaxFileLines: 3, MaxTotalLines: 2, MaxTotalBytes: 1}, false, "a.go", 3, false, OmitBudget},
112+ {"no bytes", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3}, false, "a.go", 1, false, OmitBudget},
113+ {"generated", DiffLimits{SkipGenerated: true, MaxTotalBytes: 1}, false, "go.sum", 1, false, OmitGenerated},
114+ {"generated not skipped", DiffLimits{MaxTotalBytes: 1}, false, "go.sum", 1, true, OmitNone},
115+ {"generated before large", DiffLimits{SkipGenerated: true, MaxFileLines: 1, MaxTotalBytes: 1}, false, "go.sum", 9, false, OmitGenerated},
116+ {"unlimited lines", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 1 << 20, true, OmitNone},
107117 } {
108118 t.Run(tt.name, func(t *testing.T) {
109- b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes})
110- if got := b.WantsHunks(tt.binary, tt.n); got != tt.want {
111- t.Fatalf("got %v", got)
119+ b := NewDiffBudget(tt.limits)
120+ read, why := b.Admit(tt.binary, tt.path, tt.n)
121+ if read != tt.wantRead || why != tt.wantWhy {
122+ t.Fatalf("got %v,%v", read, why)
112123 }
113124 })
114125 }
@@ -118,24 +129,132 @@ func TestDiffBudgetCharge(t *testing.T) {
118129 for _, tt := range []struct {
119130 name string
120131 bytes int
121- fits bool
122- }{{"exact", 3, true}, {"over", 4, false}} {
132+ want FileOmission
133+ }{{"exact", 3, OmitNone}, {"over", 4, OmitUnavailable}} {
123134 t.Run(tt.name, func(t *testing.T) {
124135 b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})
125136 before := b
126137 if b.FileLines() != 1 {
127138 t.Fatal("wrong file limit")
128139 }
129- if b.ChargeLine(tt.bytes) != tt.fits {
130- t.Fatal("wrong charge result")
140+ if got := b.ChargeLine(tt.bytes, true); got != tt.want {
141+ t.Fatalf("wrong charge result %v", got)
131142 }
132- if b.ChargeLine(0) {
143+ if b.ChargeLine(0, true) == OmitNone {
133144 t.Fatal("line budget exceeded")
134145 }
135146 b = before
136- if !b.WantsHunks(false, 1) {
147+ if read, _ := b.Admit(false, "a", 1); !read {
137148 t.Fatal("discarded file consumed budget")
138149 }
139150 })
140151 }
141152 }
153+
154+func TestDiffBudgetChargeReasons(t *testing.T) {
155+ b := NewDiffBudget(DiffLimits{MaxTotalLines: 1, MaxTotalBytes: 3})
156+ if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 1 {
157+ t.Fatal("context consumes bytes only")
158+ }
159+ if b.ChargeLine(1, true) != OmitNone || b.FileLines() != 0 {
160+ t.Fatal("first changed line fits")
161+ }
162+ if got := b.ChargeLine(0, true); got != OmitBudget {
163+ t.Fatalf("lines ran out: %v", got)
164+ }
165+ b = NewDiffBudget(DiffLimits{MaxTotalLines: 9, MaxTotalBytes: 1})
166+ if got := b.ChargeLine(2, false); got != OmitUnavailable {
167+ t.Fatalf("bytes ran out: %v", got)
168+ }
169+ free := NewDiffBudget(DiffLimits{MaxTotalBytes: 5})
170+ for range 100 {
171+ if free.ChargeLine(0, true) != OmitNone {
172+ t.Fatal("0 means no line limit")
173+ }
174+ }
175+ if free.FileLines() < 1<<30 {
176+ t.Fatal("unlimited FileLines")
177+ }
178+}
179+
180+func TestFileOmissionCollapsed(t *testing.T) {
181+ for o, want := range map[FileOmission]bool{OmitNone: false, OmitGenerated: true, OmitLarge: true, OmitBudget: true, OmitUnavailable: false} {
182+ if o.Collapsed() != want {
183+ t.Errorf("%d: got %v", o, !want)
184+ }
185+ }
186+}
187+
188+func TestOneFileDiffLimits(t *testing.T) {
189+ l := OneFileDiffLimits(3)
190+ if l.From != 3 || l.To != 4 || l.MaxFileLines != 0 || l.MaxAloneLines != MaxOneFileLines || l.MaxTotalLines != 0 || l.MaxFiles != 0 || l.MaxTotalBytes != DefaultDiffLimits().MaxTotalBytes {
191+ t.Fatalf("got %+v", l)
192+ }
193+ // A negative index (an unchecked IndexFunc) selects no file, not the whole diff.
194+ if _, from, to, _ := NewDiffBudget(OneFileDiffLimits(-1)).Window(10); from < to {
195+ t.Fatalf("OneFileDiffLimits(-1) selects files [%d, %d)", from, to)
196+ }
197+}
198+
199+func TestDiffBudgetBytesOfEarlierFiles(t *testing.T) {
200+ b := NewDiffBudget(DiffLimits{MaxTotalBytes: 10})
201+ b.StartFile()
202+ if b.ChargeLine(4, true) != OmitNone {
203+ t.Fatal("the first file fits")
204+ }
205+ b.StartFile()
206+ if got := b.ChargeLine(7, true); got != OmitBudget {
207+ t.Fatalf("a file meeting the bytes of earlier files: got %v, want OmitBudget", got)
208+ }
209+ alone := NewDiffBudget(DiffLimits{MaxTotalBytes: 10})
210+ alone.StartFile()
211+ if got := alone.ChargeLine(11, true); got != OmitUnavailable {
212+ t.Fatalf("a file over the bytes on its own: got %v, want OmitUnavailable", got)
213+ }
214+}
215+
216+func TestDiffBudgetContext(t *testing.T) {
217+ b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 2, MaxTotalBytes: 6})
218+ if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 2 {
219+ t.Fatal("context consumed changed-line budget")
220+ }
221+ first := b.ChargeLine(1, true)
222+ second := b.ChargeLine(1, true)
223+ if first != OmitNone || second != OmitNone || b.FileLines() != 0 {
224+ t.Fatal("additions and deletions must consume changed-line budget")
225+ }
226+ if b.ChargeLine(2, false) != OmitNone {
227+ t.Fatal("context at the byte boundary must fit even without changed lines left")
228+ }
229+ if b.ChargeLine(1, false) != OmitUnavailable {
230+ t.Fatal("context must not exceed the byte budget")
231+ }
232+}
233+
234+func TestDiffBudgetAdmitAloneLines(t *testing.T) {
235+ b := NewDiffBudget(OneFileDiffLimits(0))
236+ if read, why := b.Admit(false, "a.go", MaxOneFileLines); !read || why != OmitNone {
237+ t.Fatalf("at the cap: read=%v why=%v", read, why)
238+ }
239+ if read, why := b.Admit(false, "a.go", MaxOneFileLines+1); read || why != OmitUnavailable {
240+ t.Fatalf("over the cap: read=%v why=%v, want OmitUnavailable", read, why)
241+ }
242+}
243+
244+func TestDiffCollapseFrom(t *testing.T) {
245+ d := Diff{Files: []FileDiff{
246+ {Additions: 1},
247+ {Additions: 1},
248+ {Binary: true, Additions: 1},
249+ {Omitted: OmitLarge, Additions: 1},
250+ {},
251+ {Deletions: 2},
252+ }}
253+ d.CollapseFrom(1)
254+ want := []FileOmission{OmitNone, OmitBudget, OmitNone, OmitLarge, OmitNone, OmitBudget}
255+ for i, f := range d.Files {
256+ if f.Omitted != want[i] {
257+ t.Errorf("file %d: omitted %v, want %v", i, f.Omitted, want[i])
258+ }
259+ }
260+}
@@ -94,21 +94,32 @@ func TestDiffBudgetWindow(t *testing.T) {
94 }94 }
95 }95 }
96 96
97-func TestDiffBudgetHunks(t *testing.T) {97+func TestDiffBudgetAdmit(t *testing.T) {
98 for _, tt := range []struct {98 for _, tt := range []struct {
99- name string99+ name string
100- binary bool100+ limits DiffLimits
101- n, lines, bytes int101+ binary bool
102- want bool102+ path string
103+ n int
104+ wantRead bool
105+ wantWhy FileOmission
103 }{106 }{
104- {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false},107+ {"fits", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3, MaxTotalBytes: 1}, false, "a.go", 3, true, OmitNone},
105- {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false},108+ {"binary", DiffLimits{MaxTotalBytes: 1}, true, "a.bin", 3, false, OmitNone},
106- {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false},109+ {"no changes", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 0, false, OmitNone},
110+ {"file limit", DiffLimits{MaxFileLines: 3, MaxTotalLines: 5, MaxTotalBytes: 1}, false, "a.go", 4, false, OmitLarge},
111+ {"total lines", DiffLimits{MaxFileLines: 3, MaxTotalLines: 2, MaxTotalBytes: 1}, false, "a.go", 3, false, OmitBudget},
112+ {"no bytes", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3}, false, "a.go", 1, false, OmitBudget},
113+ {"generated", DiffLimits{SkipGenerated: true, MaxTotalBytes: 1}, false, "go.sum", 1, false, OmitGenerated},
114+ {"generated not skipped", DiffLimits{MaxTotalBytes: 1}, false, "go.sum", 1, true, OmitNone},
115+ {"generated before large", DiffLimits{SkipGenerated: true, MaxFileLines: 1, MaxTotalBytes: 1}, false, "go.sum", 9, false, OmitGenerated},
116+ {"unlimited lines", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 1 << 20, true, OmitNone},
107 } {117 } {
108 t.Run(tt.name, func(t *testing.T) {118 t.Run(tt.name, func(t *testing.T) {
109- b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes})119+ b := NewDiffBudget(tt.limits)
110- if got := b.WantsHunks(tt.binary, tt.n); got != tt.want {120+ read, why := b.Admit(tt.binary, tt.path, tt.n)
111- t.Fatalf("got %v", got)121+ if read != tt.wantRead || why != tt.wantWhy {
122+ t.Fatalf("got %v,%v", read, why)
112 }123 }
113 })124 })
114 }125 }
@@ -118,24 +129,132 @@ func TestDiffBudgetCharge(t *testing.T) {
118 for _, tt := range []struct {129 for _, tt := range []struct {
119 name string130 name string
120 bytes int131 bytes int
121- fits bool132+ want FileOmission
122- }{{"exact", 3, true}, {"over", 4, false}} {133+ }{{"exact", 3, OmitNone}, {"over", 4, OmitUnavailable}} {
123 t.Run(tt.name, func(t *testing.T) {134 t.Run(tt.name, func(t *testing.T) {
124 b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})135 b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})
125 before := b136 before := b
126 if b.FileLines() != 1 {137 if b.FileLines() != 1 {
127 t.Fatal("wrong file limit")138 t.Fatal("wrong file limit")
128 }139 }
129- if b.ChargeLine(tt.bytes) != tt.fits {140+ if got := b.ChargeLine(tt.bytes, true); got != tt.want {
130- t.Fatal("wrong charge result")141+ t.Fatalf("wrong charge result %v", got)
131 }142 }
132- if b.ChargeLine(0) {143+ if b.ChargeLine(0, true) == OmitNone {
133 t.Fatal("line budget exceeded")144 t.Fatal("line budget exceeded")
134 }145 }
135 b = before146 b = before
136- if !b.WantsHunks(false, 1) {147+ if read, _ := b.Admit(false, "a", 1); !read {
137 t.Fatal("discarded file consumed budget")148 t.Fatal("discarded file consumed budget")
138 }149 }
139 })150 })
140 }151 }
141 }152 }
153+
154+func TestDiffBudgetChargeReasons(t *testing.T) {
155+ b := NewDiffBudget(DiffLimits{MaxTotalLines: 1, MaxTotalBytes: 3})
156+ if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 1 {
157+ t.Fatal("context consumes bytes only")
158+ }
159+ if b.ChargeLine(1, true) != OmitNone || b.FileLines() != 0 {
160+ t.Fatal("first changed line fits")
161+ }
162+ if got := b.ChargeLine(0, true); got != OmitBudget {
163+ t.Fatalf("lines ran out: %v", got)
164+ }
165+ b = NewDiffBudget(DiffLimits{MaxTotalLines: 9, MaxTotalBytes: 1})
166+ if got := b.ChargeLine(2, false); got != OmitUnavailable {
167+ t.Fatalf("bytes ran out: %v", got)
168+ }
169+ free := NewDiffBudget(DiffLimits{MaxTotalBytes: 5})
170+ for range 100 {
171+ if free.ChargeLine(0, true) != OmitNone {
172+ t.Fatal("0 means no line limit")
173+ }
174+ }
175+ if free.FileLines() < 1<<30 {
176+ t.Fatal("unlimited FileLines")
177+ }
178+}
179+
180+func TestFileOmissionCollapsed(t *testing.T) {
181+ for o, want := range map[FileOmission]bool{OmitNone: false, OmitGenerated: true, OmitLarge: true, OmitBudget: true, OmitUnavailable: false} {
182+ if o.Collapsed() != want {
183+ t.Errorf("%d: got %v", o, !want)
184+ }
185+ }
186+}
187+
188+func TestOneFileDiffLimits(t *testing.T) {
189+ l := OneFileDiffLimits(3)
190+ if l.From != 3 || l.To != 4 || l.MaxFileLines != 0 || l.MaxAloneLines != MaxOneFileLines || l.MaxTotalLines != 0 || l.MaxFiles != 0 || l.MaxTotalBytes != DefaultDiffLimits().MaxTotalBytes {
191+ t.Fatalf("got %+v", l)
192+ }
193+ // A negative index (an unchecked IndexFunc) selects no file, not the whole diff.
194+ if _, from, to, _ := NewDiffBudget(OneFileDiffLimits(-1)).Window(10); from < to {
195+ t.Fatalf("OneFileDiffLimits(-1) selects files [%d, %d)", from, to)
196+ }
197+}
198+
199+func TestDiffBudgetBytesOfEarlierFiles(t *testing.T) {
200+ b := NewDiffBudget(DiffLimits{MaxTotalBytes: 10})
201+ b.StartFile()
202+ if b.ChargeLine(4, true) != OmitNone {
203+ t.Fatal("the first file fits")
204+ }
205+ b.StartFile()
206+ if got := b.ChargeLine(7, true); got != OmitBudget {
207+ t.Fatalf("a file meeting the bytes of earlier files: got %v, want OmitBudget", got)
208+ }
209+ alone := NewDiffBudget(DiffLimits{MaxTotalBytes: 10})
210+ alone.StartFile()
211+ if got := alone.ChargeLine(11, true); got != OmitUnavailable {
212+ t.Fatalf("a file over the bytes on its own: got %v, want OmitUnavailable", got)
213+ }
214+}
215+
216+func TestDiffBudgetContext(t *testing.T) {
217+ b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 2, MaxTotalBytes: 6})
218+ if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 2 {
219+ t.Fatal("context consumed changed-line budget")
220+ }
221+ first := b.ChargeLine(1, true)
222+ second := b.ChargeLine(1, true)
223+ if first != OmitNone || second != OmitNone || b.FileLines() != 0 {
224+ t.Fatal("additions and deletions must consume changed-line budget")
225+ }
226+ if b.ChargeLine(2, false) != OmitNone {
227+ t.Fatal("context at the byte boundary must fit even without changed lines left")
228+ }
229+ if b.ChargeLine(1, false) != OmitUnavailable {
230+ t.Fatal("context must not exceed the byte budget")
231+ }
232+}
233+
234+func TestDiffBudgetAdmitAloneLines(t *testing.T) {
235+ b := NewDiffBudget(OneFileDiffLimits(0))
236+ if read, why := b.Admit(false, "a.go", MaxOneFileLines); !read || why != OmitNone {
237+ t.Fatalf("at the cap: read=%v why=%v", read, why)
238+ }
239+ if read, why := b.Admit(false, "a.go", MaxOneFileLines+1); read || why != OmitUnavailable {
240+ t.Fatalf("over the cap: read=%v why=%v, want OmitUnavailable", read, why)
241+ }
242+}
243+
244+func TestDiffCollapseFrom(t *testing.T) {
245+ d := Diff{Files: []FileDiff{
246+ {Additions: 1},
247+ {Additions: 1},
248+ {Binary: true, Additions: 1},
249+ {Omitted: OmitLarge, Additions: 1},
250+ {},
251+ {Deletions: 2},
252+ }}
253+ d.CollapseFrom(1)
254+ want := []FileOmission{OmitNone, OmitBudget, OmitNone, OmitLarge, OmitNone, OmitBudget}
255+ for i, f := range d.Files {
256+ if f.Omitted != want[i] {
257+ t.Errorf("file %d: omitted %v, want %v", i, f.Omitted, want[i])
258+ }
259+ }
260+}
A internal/domain/generated.go +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "path"
7+ "strings"
8+)
9+
10+var generatedSuffixes = []string{
11+ "_templ.go", ".pb.go", "_gen.go", ".gen.go",
12+ ".min.js", ".min.css", ".js.map", ".css.map",
13+}
14+
15+var generatedNames = map[string]struct{}{
16+ "go.sum": {}, "package-lock.json": {}, "pnpm-lock.yaml": {}, "yarn.lock": {},
17+ "Cargo.lock": {}, "bun.lockb": {}, "bun.lock": {}, "composer.lock": {},
18+ "Gemfile.lock": {}, "poetry.lock": {}, "uv.lock": {}, "flake.lock": {},
19+}
20+
21+// IsGeneratedPath reports whether a changed file is generated or vendored by
22+// its path alone: its diff is collapsed by default in the web view.
23+func IsGeneratedPath(p string) bool {
24+ if p == "" {
25+ return false
26+ }
27+ base := path.Base(p)
28+ if _, ok := generatedNames[base]; ok {
29+ return true
30+ }
31+ for _, s := range generatedSuffixes {
32+ if strings.HasSuffix(base, s) {
33+ return true
34+ }
35+ }
36+ // Directory segments only: the last segment is the file name.
37+ dir := p[:len(p)-len(base)]
38+ for dir != "" {
39+ seg, rest, _ := strings.Cut(dir, "/")
40+ if seg == "vendor" || seg == "node_modules" {
41+ return true
42+ }
43+ dir = rest
44+ }
45+ return false
46+}
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "path"
7+ "strings"
8+)
9+
10+var generatedSuffixes = []string{
11+ "_templ.go", ".pb.go", "_gen.go", ".gen.go",
12+ ".min.js", ".min.css", ".js.map", ".css.map",
13+}
14+
15+var generatedNames = map[string]struct{}{
16+ "go.sum": {}, "package-lock.json": {}, "pnpm-lock.yaml": {}, "yarn.lock": {},
17+ "Cargo.lock": {}, "bun.lockb": {}, "bun.lock": {}, "composer.lock": {},
18+ "Gemfile.lock": {}, "poetry.lock": {}, "uv.lock": {}, "flake.lock": {},
19+}
20+
21+// IsGeneratedPath reports whether a changed file is generated or vendored by
22+// its path alone: its diff is collapsed by default in the web view.
23+func IsGeneratedPath(p string) bool {
24+ if p == "" {
25+ return false
26+ }
27+ base := path.Base(p)
28+ if _, ok := generatedNames[base]; ok {
29+ return true
30+ }
31+ for _, s := range generatedSuffixes {
32+ if strings.HasSuffix(base, s) {
33+ return true
34+ }
35+ }
36+ // Directory segments only: the last segment is the file name.
37+ dir := p[:len(p)-len(base)]
38+ for dir != "" {
39+ seg, rest, _ := strings.Cut(dir, "/")
40+ if seg == "vendor" || seg == "node_modules" {
41+ return true
42+ }
43+ dir = rest
44+ }
45+ return false
46+}
A internal/domain/generated_test.go +51 -0
@@ -0,0 +1,51 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "testing"
6+
7+func TestIsGeneratedPath(t *testing.T) {
8+ t.Parallel()
9+ tests := []struct {
10+ path string
11+ want bool
12+ }{
13+ {"views/a_templ.go", true},
14+ {"api/x.pb.go", true},
15+ {"x_gen.go", true},
16+ {"x.gen.go", true},
17+ {"static/app.min.js", true},
18+ {"static/app.min.css", true},
19+ {"static/app.js.map", true},
20+ {"static/app.css.map", true},
21+ {"go.sum", true},
22+ {"web/package-lock.json", true},
23+ {"pnpm-lock.yaml", true},
24+ {"yarn.lock", true},
25+ {"Cargo.lock", true},
26+ {"bun.lockb", true},
27+ {"bun.lock", true},
28+ {"composer.lock", true},
29+ {"Gemfile.lock", true},
30+ {"poetry.lock", true},
31+ {"uv.lock", true},
32+ {"flake.lock", true},
33+ {"vendor/x.go", true},
34+ {"a/vendor/b/x.go", true},
35+ {"node_modules/a.js", true},
36+ {"web/node_modules/a.js", true},
37+ {"vendor.go", false},
38+ {"templ.go", false},
39+ {"main.go", false},
40+ {"go.mod", false},
41+ {"minify.js", false},
42+ {"myvendor/x.go", false},
43+ {"x.min.jsx", false},
44+ {"", false},
45+ }
46+ for _, tt := range tests {
47+ if got := IsGeneratedPath(tt.path); got != tt.want {
48+ t.Errorf("IsGeneratedPath(%q) = %v, want %v", tt.path, got, tt.want)
49+ }
50+ }
51+}
@@ -0,0 +1,51 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "testing"
6+
7+func TestIsGeneratedPath(t *testing.T) {
8+ t.Parallel()
9+ tests := []struct {
10+ path string
11+ want bool
12+ }{
13+ {"views/a_templ.go", true},
14+ {"api/x.pb.go", true},
15+ {"x_gen.go", true},
16+ {"x.gen.go", true},
17+ {"static/app.min.js", true},
18+ {"static/app.min.css", true},
19+ {"static/app.js.map", true},
20+ {"static/app.css.map", true},
21+ {"go.sum", true},
22+ {"web/package-lock.json", true},
23+ {"pnpm-lock.yaml", true},
24+ {"yarn.lock", true},
25+ {"Cargo.lock", true},
26+ {"bun.lockb", true},
27+ {"bun.lock", true},
28+ {"composer.lock", true},
29+ {"Gemfile.lock", true},
30+ {"poetry.lock", true},
31+ {"uv.lock", true},
32+ {"flake.lock", true},
33+ {"vendor/x.go", true},
34+ {"a/vendor/b/x.go", true},
35+ {"node_modules/a.js", true},
36+ {"web/node_modules/a.js", true},
37+ {"vendor.go", false},
38+ {"templ.go", false},
39+ {"main.go", false},
40+ {"go.mod", false},
41+ {"minify.js", false},
42+ {"myvendor/x.go", false},
43+ {"x.min.jsx", false},
44+ {"", false},
45+ }
46+ for _, tt := range tests {
47+ if got := IsGeneratedPath(tt.path); got != tt.want {
48+ t.Errorf("IsGeneratedPath(%q) = %v, want %v", tt.path, got, tt.want)
49+ }
50+ }
51+}
M internal/domain/git.go +84 -19
@@ -505,11 +505,37 @@ type FileDiff struct {
505505 Additions, Deletions int
506506 // Similarity is a percentage for a renamed or copied file, 0 otherwise.
507507 Similarity int
508- // Truncated means the file exceeded a diff limit: Hunks is empty although
509- // the file changed. Additions and Deletions stay accurate. A file outside
510- // the DiffLimits window has no Hunks and is not Truncated.
511- Truncated bool
512- Hunks []Hunk
508+ // Omitted tells why the hunks were not kept: Hunks is empty although the
509+ // file changed; Additions and Deletions stay accurate. A file outside the
510+ // window is OmitNone with no Hunks.
511+ Omitted FileOmission
512+ Hunks []Hunk
513+}
514+
515+// CommentPath is the path a comment designates for f, as Diff.HasLine reads it:
516+// the old path of a deleted file, the new path otherwise.
517+func (f FileDiff) CommentPath() string {
518+ if f.Status == FileDeleted {
519+ return f.OldPath
520+ }
521+ return f.NewPath
522+}
523+
524+// FileOmission tells why a changed file has no hunks.
525+type FileOmission uint8
526+
527+// File omissions.
528+const (
529+ OmitNone FileOmission = iota
530+ OmitGenerated // SkipGenerated and IsGeneratedPath: hunks not read
531+ OmitLarge // changed lines > MaxFileLines
532+ OmitBudget // did not fit in the remaining MaxTotalLines
533+ OmitUnavailable // retained text would exceed MaxTotalBytes
534+)
535+
536+// Collapsed reports a file the web view folds and can load on its own.
537+func (o FileOmission) Collapsed() bool {
538+ return o == OmitGenerated || o == OmitLarge || o == OmitBudget
513539 }
514540
515541 // Diff is the structured difference a commit introduces against its first
@@ -518,9 +544,8 @@ type Diff struct {
518544 // Files are ordered as Git reports them (by path). Files past
519545 // DiffLimits.MaxFiles (when it is not 0) are left out.
520546 Files []FileDiff
521- // Truncated is true when files were left out or a listed file is truncated.
522- // Files listed without hunks because they are outside the DiffLimits window
523- // do not make it true.
547+ // Truncated is true when files past DiffLimits.MaxFiles were left out. It
548+ // says nothing of the listed files: see FileDiff.Omitted.
524549 Truncated bool
525550 }
526551
@@ -537,33 +562,58 @@ type CommitDetail struct {
537562 Diff Diff
538563 }
539564
540-// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.
565+// DiffLimits bound the size of a Diff. Line limits count additions plus
566+// deletions, not context. A file that does not fit is listed without hunks and
567+// with an Omitted reason.
541568 type DiffLimits struct {
542569 // MaxFiles is the number of files kept; 0 means no limit on the number of
543570 // files (every changed file is listed).
544571 MaxFiles int
545- // MaxFileLines is the most lines one file may have; a file over it is
546- // truncated.
572+ // MaxFileLines is the most changed lines one file may have; a file over it is
573+ // OmitLarge. 0 means no limit.
547574 MaxFileLines int
548- // MaxTotalLines is the most lines of all the files. A file is truncated
549- // when it does not fit in what remains; a smaller one after it may still
550- // fit.
575+ // MaxTotalLines is the most changed lines of all the files. A file is
576+ // OmitBudget when it does not fit in what remains; a smaller one after it
577+ // may still fit. 0 means no limit.
551578 MaxTotalLines int
552- // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds
553- // the memory of a diff whose lines are very long; files are truncated
554- // like for MaxTotalLines.
579+ // MaxTotalBytes is the most text, in bytes, of all the lines, including context. It bounds
580+ // the memory of a diff whose lines are very long; a file whose
581+ // text does not fit is OmitUnavailable.
555582 MaxTotalBytes int
556583 // From and To select the files whose hunks are read: those with an index in
557584 // [From, To), counted from 0 in the order of Diff.Files. The other files
558585 // are listed (paths, status, modes, ids, Binary, Additions, Deletions,
559- // Similarity) without hunks; they are not Truncated and use none of
586+ // Similarity) without hunks; they are OmitNone and use none of
560587 // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values
561588 // select every file. MaxFileLines applies to each file of the window.
562589 From, To int
590+ // MaxAloneLines is the most changed lines a file may have to be read at all;
591+ // a file over it is OmitUnavailable (it cannot be shown even alone), unlike
592+ // OmitLarge, which a read of that file alone can load. 0 means no limit.
593+ MaxAloneLines int
594+ // SkipGenerated omits the files of the window that IsGeneratedPath
595+ // recognizes (OmitGenerated), without reading their hunks.
596+ SkipGenerated bool
597+}
598+
599+// MaxOneFileLines is the most changed lines of a file read alone on the web
600+// (OneFileDiffLimits). A file over it is OmitUnavailable, so a short-lines
601+// file within the byte guard cannot produce millions of rows.
602+const MaxOneFileLines = 50000
603+
604+// OneFileDiffLimits reads the hunks of file i only, with no MaxFileLines cap;
605+// MaxAloneLines (MaxOneFileLines) and MaxTotalBytes are the guards. i must be at least 0: From -1 with To 0
606+// would mean the whole diff, so a negative i reads no file at all.
607+func OneFileDiffLimits(i int) DiffLimits {
608+ if i < 0 {
609+ return DiffLimits{From: 1, To: 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
610+ }
611+ return DiffLimits{From: i, To: i + 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
563612 }
564613
565614 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
566-// lines per file, 20,000 lines in total, plus 8 MiB of text.
615+// changed lines per file, 20,000 changed lines in total, plus 8 MiB of text
616+// including context. Generated files are not skipped.
567617 func DefaultDiffLimits() DiffLimits {
568618 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
569619 }
@@ -643,3 +693,18 @@ func (t *Tree) BoundTree() {
643693 t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true
644694 }
645695 }
696+
697+// CommitPageFiles is how many files of a commit get their hunks read with its
698+// page; the files past it are collapsed (see Diff.CollapseFrom) and load on
699+// their own.
700+const CommitPageFiles = 300
701+
702+// CollapseFrom marks the files from index n on that have lines to show (not
703+// binary, not already omitted, with changes) as OmitBudget: listed, not read.
704+func (d *Diff) CollapseFrom(n int) {
705+ for i := max(n, 0); i < len(d.Files); i++ {
706+ if f := &d.Files[i]; !f.Binary && f.Omitted == OmitNone && f.Additions+f.Deletions > 0 {
707+ f.Omitted = OmitBudget
708+ }
709+ }
710+}
@@ -505,11 +505,37 @@ type FileDiff struct {
505 Additions, Deletions int505 Additions, Deletions int
506 // Similarity is a percentage for a renamed or copied file, 0 otherwise.506 // Similarity is a percentage for a renamed or copied file, 0 otherwise.
507 Similarity int507 Similarity int
508- // Truncated means the file exceeded a diff limit: Hunks is empty although508+ // Omitted tells why the hunks were not kept: Hunks is empty although the
509- // the file changed. Additions and Deletions stay accurate. A file outside509+ // file changed; Additions and Deletions stay accurate. A file outside the
510- // the DiffLimits window has no Hunks and is not Truncated.510+ // window is OmitNone with no Hunks.
511- Truncated bool511+ Omitted FileOmission
512- Hunks []Hunk512+ Hunks []Hunk
513+}
514+
515+// CommentPath is the path a comment designates for f, as Diff.HasLine reads it:
516+// the old path of a deleted file, the new path otherwise.
517+func (f FileDiff) CommentPath() string {
518+ if f.Status == FileDeleted {
519+ return f.OldPath
520+ }
521+ return f.NewPath
522+}
523+
524+// FileOmission tells why a changed file has no hunks.
525+type FileOmission uint8
526+
527+// File omissions.
528+const (
529+ OmitNone FileOmission = iota
530+ OmitGenerated // SkipGenerated and IsGeneratedPath: hunks not read
531+ OmitLarge // changed lines > MaxFileLines
532+ OmitBudget // did not fit in the remaining MaxTotalLines
533+ OmitUnavailable // retained text would exceed MaxTotalBytes
534+)
535+
536+// Collapsed reports a file the web view folds and can load on its own.
537+func (o FileOmission) Collapsed() bool {
538+ return o == OmitGenerated || o == OmitLarge || o == OmitBudget
513 }539 }
514 540
515 // Diff is the structured difference a commit introduces against its first541 // Diff is the structured difference a commit introduces against its first
@@ -518,9 +544,8 @@ type Diff struct {
518 // Files are ordered as Git reports them (by path). Files past544 // Files are ordered as Git reports them (by path). Files past
519 // DiffLimits.MaxFiles (when it is not 0) are left out.545 // DiffLimits.MaxFiles (when it is not 0) are left out.
520 Files []FileDiff546 Files []FileDiff
521- // Truncated is true when files were left out or a listed file is truncated.547+ // Truncated is true when files past DiffLimits.MaxFiles were left out. It
522- // Files listed without hunks because they are outside the DiffLimits window548+ // says nothing of the listed files: see FileDiff.Omitted.
523- // do not make it true.
524 Truncated bool549 Truncated bool
525 }550 }
526 551
@@ -537,33 +562,58 @@ type CommitDetail struct {
537 Diff Diff562 Diff Diff
538 }563 }
539 564
540-// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.565+// DiffLimits bound the size of a Diff. Line limits count additions plus
566+// deletions, not context. A file that does not fit is listed without hunks and
567+// with an Omitted reason.
541 type DiffLimits struct {568 type DiffLimits struct {
542 // MaxFiles is the number of files kept; 0 means no limit on the number of569 // MaxFiles is the number of files kept; 0 means no limit on the number of
543 // files (every changed file is listed).570 // files (every changed file is listed).
544 MaxFiles int571 MaxFiles int
545- // MaxFileLines is the most lines one file may have; a file over it is572+ // MaxFileLines is the most changed lines one file may have; a file over it is
546- // truncated.573+ // OmitLarge. 0 means no limit.
547 MaxFileLines int574 MaxFileLines int
548- // MaxTotalLines is the most lines of all the files. A file is truncated575+ // MaxTotalLines is the most changed lines of all the files. A file is
549- // when it does not fit in what remains; a smaller one after it may still576+ // OmitBudget when it does not fit in what remains; a smaller one after it
550- // fit.577+ // may still fit. 0 means no limit.
551 MaxTotalLines int578 MaxTotalLines int
552- // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds579+ // MaxTotalBytes is the most text, in bytes, of all the lines, including context. It bounds
553- // the memory of a diff whose lines are very long; files are truncated580+ // the memory of a diff whose lines are very long; a file whose
554- // like for MaxTotalLines.581+ // text does not fit is OmitUnavailable.
555 MaxTotalBytes int582 MaxTotalBytes int
556 // From and To select the files whose hunks are read: those with an index in583 // From and To select the files whose hunks are read: those with an index in
557 // [From, To), counted from 0 in the order of Diff.Files. The other files584 // [From, To), counted from 0 in the order of Diff.Files. The other files
558 // are listed (paths, status, modes, ids, Binary, Additions, Deletions,585 // are listed (paths, status, modes, ids, Binary, Additions, Deletions,
559- // Similarity) without hunks; they are not Truncated and use none of586+ // Similarity) without hunks; they are OmitNone and use none of
560 // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values587 // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values
561 // select every file. MaxFileLines applies to each file of the window.588 // select every file. MaxFileLines applies to each file of the window.
562 From, To int589 From, To int
590+ // MaxAloneLines is the most changed lines a file may have to be read at all;
591+ // a file over it is OmitUnavailable (it cannot be shown even alone), unlike
592+ // OmitLarge, which a read of that file alone can load. 0 means no limit.
593+ MaxAloneLines int
594+ // SkipGenerated omits the files of the window that IsGeneratedPath
595+ // recognizes (OmitGenerated), without reading their hunks.
596+ SkipGenerated bool
597+}
598+
599+// MaxOneFileLines is the most changed lines of a file read alone on the web
600+// (OneFileDiffLimits). A file over it is OmitUnavailable, so a short-lines
601+// file within the byte guard cannot produce millions of rows.
602+const MaxOneFileLines = 50000
603+
604+// OneFileDiffLimits reads the hunks of file i only, with no MaxFileLines cap;
605+// MaxAloneLines (MaxOneFileLines) and MaxTotalBytes are the guards. i must be at least 0: From -1 with To 0
606+// would mean the whole diff, so a negative i reads no file at all.
607+func OneFileDiffLimits(i int) DiffLimits {
608+ if i < 0 {
609+ return DiffLimits{From: 1, To: 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
610+ }
611+ return DiffLimits{From: i, To: i + 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
563 }612 }
564 613
565 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000614 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
566-// lines per file, 20,000 lines in total, plus 8 MiB of text.615+// changed lines per file, 20,000 changed lines in total, plus 8 MiB of text
616+// including context. Generated files are not skipped.
567 func DefaultDiffLimits() DiffLimits {617 func DefaultDiffLimits() DiffLimits {
568 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}618 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
569 }619 }
@@ -643,3 +693,18 @@ func (t *Tree) BoundTree() {
643 t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true693 t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true
644 }694 }
645 }695 }
696+
697+// CommitPageFiles is how many files of a commit get their hunks read with its
698+// page; the files past it are collapsed (see Diff.CollapseFrom) and load on
699+// their own.
700+const CommitPageFiles = 300
701+
702+// CollapseFrom marks the files from index n on that have lines to show (not
703+// binary, not already omitted, with changes) as OmitBudget: listed, not read.
704+func (d *Diff) CollapseFrom(n int) {
705+ for i := max(n, 0); i < len(d.Files); i++ {
706+ if f := &d.Files[i]; !f.Binary && f.Omitted == OmitNone && f.Additions+f.Deletions > 0 {
707+ f.Omitted = OmitBudget
708+ }
709+ }
710+}
M internal/domain/mergerequest_test.go +1 -1
@@ -77,7 +77,7 @@ func hunkDiff() domain.Diff {
7777 {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{
7878 Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},
7979 }}},
80- {NewPath: "big.txt", Status: domain.FileAdded, Truncated: true},
80+ {NewPath: "big.txt", Status: domain.FileAdded, Omitted: domain.OmitLarge},
8181 {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{
8282 Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},
8383 }}},
@@ -77,7 +77,7 @@ func hunkDiff() domain.Diff {
77 {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{77 {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{
78 Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},78 Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},
79 }}},79 }}},
80- {NewPath: "big.txt", Status: domain.FileAdded, Truncated: true},80+ {NewPath: "big.txt", Status: domain.FileAdded, Omitted: domain.OmitLarge},
81 {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{81 {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{
82 Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},82 Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},
83 }}},83 }}},
M internal/ports/dto.go +33 -3
@@ -298,8 +298,10 @@ type GetMergeRequestOptions struct {
298298 SinceLastRead bool
299299 // Files, when set, lists every changed file, without the file-count
300300 // limit, and reads the hunks of the files in the window only, each within
301- // the per-file and the total limits of domain.DefaultDiffLimits. Unset,
302- // the diff is bounded by domain.DefaultDiffLimits (the API contract).
301+ // the per-file and the total limits of domain.DefaultDiffLimits; generated
302+ // files are listed collapsed (domain.OmitGenerated). With Full, the single
303+ // file is read without the line limits. Unset, the diff is bounded by
304+ // domain.DefaultDiffLimits and generated files are read (the API contract).
303305 Files *FileWindow
304306 // Base and Head, when not zero, are the diff the caller shows: Get
305307 // returns domain.ErrConflict when the request's diff base or head differ
@@ -307,10 +309,38 @@ type GetMergeRequestOptions struct {
307309 Base, Head domain.ObjectID
308310 // DiffOnly skips the commits of the request (Commits stays empty).
309311 DiffOnly bool
312+ // Load, when set with Files, is the index of a collapsed file Get reads in
313+ // full and splices into the diff in place of its collapsed entry (the page
314+ // of a client without scripts). Ignored when the index is out of range,
315+ // the file is not collapsed, or there is no diff.
316+ Load *int
310317 }
311318
312319 // FileWindow is the range [From, To) of files whose hunks are read.
313-type FileWindow struct{ From, To int }
320+//
321+// Full asks for the one file From read in full: no line limit applies, only
322+// the byte guard (domain.OneFileDiffLimits). It requires To == From+1; a
323+// window with Full and any other To is domain.ErrInvalid.
324+type FileWindow struct {
325+ From, To int
326+ Full bool
327+}
328+
329+// GetCommitOptions say which diff BrowseService.GetCommit reads.
330+type GetCommitOptions struct {
331+ // Files, when set, reads the hunks of a window of the files, as
332+ // GetMergeRequestOptions.Files does. Unset (the zero value), the diff is
333+ // bounded by domain.DefaultDiffLimits.
334+ Files *FileWindow
335+ // Page, without Files, reads the diff for a page: every file listed, the
336+ // hunks of the first domain.CommitPageFiles files read, generated files
337+ // collapsed, and the files past the window collapsed too
338+ // (domain.OmitBudget). Combined with Files it is domain.ErrInvalid.
339+ Page bool
340+ // Load is the index of a collapsed file read in full and spliced into the
341+ // diff, as GetMergeRequestOptions.Load. It requires Page.
342+ Load *int
343+}
314344
315345 // AddCommentParams describes the comment to add.
316346 type AddCommentParams struct {
@@ -298,8 +298,10 @@ type GetMergeRequestOptions struct {
298 SinceLastRead bool298 SinceLastRead bool
299 // Files, when set, lists every changed file, without the file-count299 // Files, when set, lists every changed file, without the file-count
300 // limit, and reads the hunks of the files in the window only, each within300 // limit, and reads the hunks of the files in the window only, each within
301- // the per-file and the total limits of domain.DefaultDiffLimits. Unset,301+ // the per-file and the total limits of domain.DefaultDiffLimits; generated
302- // the diff is bounded by domain.DefaultDiffLimits (the API contract).302+ // files are listed collapsed (domain.OmitGenerated). With Full, the single
303+ // file is read without the line limits. Unset, the diff is bounded by
304+ // domain.DefaultDiffLimits and generated files are read (the API contract).
303 Files *FileWindow305 Files *FileWindow
304 // Base and Head, when not zero, are the diff the caller shows: Get306 // Base and Head, when not zero, are the diff the caller shows: Get
305 // returns domain.ErrConflict when the request's diff base or head differ307 // returns domain.ErrConflict when the request's diff base or head differ
@@ -307,10 +309,38 @@ type GetMergeRequestOptions struct {
307 Base, Head domain.ObjectID309 Base, Head domain.ObjectID
308 // DiffOnly skips the commits of the request (Commits stays empty).310 // DiffOnly skips the commits of the request (Commits stays empty).
309 DiffOnly bool311 DiffOnly bool
312+ // Load, when set with Files, is the index of a collapsed file Get reads in
313+ // full and splices into the diff in place of its collapsed entry (the page
314+ // of a client without scripts). Ignored when the index is out of range,
315+ // the file is not collapsed, or there is no diff.
316+ Load *int
310 }317 }
311 318
312 // FileWindow is the range [From, To) of files whose hunks are read.319 // FileWindow is the range [From, To) of files whose hunks are read.
313-type FileWindow struct{ From, To int }320+//
321+// Full asks for the one file From read in full: no line limit applies, only
322+// the byte guard (domain.OneFileDiffLimits). It requires To == From+1; a
323+// window with Full and any other To is domain.ErrInvalid.
324+type FileWindow struct {
325+ From, To int
326+ Full bool
327+}
328+
329+// GetCommitOptions say which diff BrowseService.GetCommit reads.
330+type GetCommitOptions struct {
331+ // Files, when set, reads the hunks of a window of the files, as
332+ // GetMergeRequestOptions.Files does. Unset (the zero value), the diff is
333+ // bounded by domain.DefaultDiffLimits.
334+ Files *FileWindow
335+ // Page, without Files, reads the diff for a page: every file listed, the
336+ // hunks of the first domain.CommitPageFiles files read, generated files
337+ // collapsed, and the files past the window collapsed too
338+ // (domain.OmitBudget). Combined with Files it is domain.ErrInvalid.
339+ Page bool
340+ // Load is the index of a collapsed file read in full and spliced into the
341+ // diff, as GetMergeRequestOptions.Load. It requires Page.
342+ Load *int
343+}
314 344
315 // AddCommentParams describes the comment to add.345 // AddCommentParams describes the comment to add.
316 type AddCommentParams struct {346 type AddCommentParams struct {
M internal/ports/mocks/BrowseService.go +18 -12
@@ -51,8 +51,8 @@ func (_m *BrowseService) EXPECT() *BrowseService_Expecter {
5151 }
5252
5353 // GetCommit provides a mock function for the type BrowseService
54-func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
55- ret := _mock.Called(ctx, p, repo, rev)
54+func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
55+ ret := _mock.Called(ctx, p, repo, rev, opts)
5656
5757 if len(ret) == 0 {
5858 panic("no return value specified for GetCommit")
@@ -60,16 +60,16 @@ func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, r
6060
6161 var r0 domain.CommitDetail
6262 var r1 error
63- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.CommitDetail, error)); ok {
64- return returnFunc(ctx, p, repo, rev)
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) (domain.CommitDetail, error)); ok {
64+ return returnFunc(ctx, p, repo, rev, opts)
6565 }
66- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.CommitDetail); ok {
67- r0 = returnFunc(ctx, p, repo, rev)
66+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) domain.CommitDetail); ok {
67+ r0 = returnFunc(ctx, p, repo, rev, opts)
6868 } else {
6969 r0 = ret.Get(0).(domain.CommitDetail)
7070 }
71- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok {
72- r1 = returnFunc(ctx, p, repo, rev)
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) error); ok {
72+ r1 = returnFunc(ctx, p, repo, rev, opts)
7373 } else {
7474 r1 = ret.Error(1)
7575 }
@@ -86,11 +86,12 @@ type BrowseService_GetCommit_Call struct {
8686 // - p domain.Principal
8787 // - repo domain.RepoName
8888 // - rev domain.Revision
89-func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any) *BrowseService_GetCommit_Call {
90- return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev)}
89+// - opts ports.GetCommitOptions
90+func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any, opts any) *BrowseService_GetCommit_Call {
91+ return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev, opts)}
9192 }
9293
93-func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_GetCommit_Call {
94+func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions)) *BrowseService_GetCommit_Call {
9495 _c.Call.Run(func(args mock.Arguments) {
9596 var arg0 context.Context
9697 if args[0] != nil {
@@ -108,11 +109,16 @@ func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p doma
108109 if args[3] != nil {
109110 arg3 = args[3].(domain.Revision)
110111 }
112+ var arg4 ports.GetCommitOptions
113+ if args[4] != nil {
114+ arg4 = args[4].(ports.GetCommitOptions)
115+ }
111116 run(
112117 arg0,
113118 arg1,
114119 arg2,
115120 arg3,
121+ arg4,
116122 )
117123 })
118124 return _c
@@ -123,7 +129,7 @@ func (_c *BrowseService_GetCommit_Call) Return(commitDetail domain.CommitDetail,
123129 return _c
124130 }
125131
126-func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {
132+func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {
127133 _c.Call.Return(run)
128134 return _c
129135 }
@@ -51,8 +51,8 @@ func (_m *BrowseService) EXPECT() *BrowseService_Expecter {
51 }51 }
52 52
53 // GetCommit provides a mock function for the type BrowseService53 // GetCommit provides a mock function for the type BrowseService
54-func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {54+func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
55- ret := _mock.Called(ctx, p, repo, rev)55+ ret := _mock.Called(ctx, p, repo, rev, opts)
56 56
57 if len(ret) == 0 {57 if len(ret) == 0 {
58 panic("no return value specified for GetCommit")58 panic("no return value specified for GetCommit")
@@ -60,16 +60,16 @@ func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, r
60 60
61 var r0 domain.CommitDetail61 var r0 domain.CommitDetail
62 var r1 error62 var r1 error
63- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.CommitDetail, error)); ok {63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) (domain.CommitDetail, error)); ok {
64- return returnFunc(ctx, p, repo, rev)64+ return returnFunc(ctx, p, repo, rev, opts)
65 }65 }
66- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.CommitDetail); ok {66+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) domain.CommitDetail); ok {
67- r0 = returnFunc(ctx, p, repo, rev)67+ r0 = returnFunc(ctx, p, repo, rev, opts)
68 } else {68 } else {
69 r0 = ret.Get(0).(domain.CommitDetail)69 r0 = ret.Get(0).(domain.CommitDetail)
70 }70 }
71- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok {71+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) error); ok {
72- r1 = returnFunc(ctx, p, repo, rev)72+ r1 = returnFunc(ctx, p, repo, rev, opts)
73 } else {73 } else {
74 r1 = ret.Error(1)74 r1 = ret.Error(1)
75 }75 }
@@ -86,11 +86,12 @@ type BrowseService_GetCommit_Call struct {
86 // - p domain.Principal86 // - p domain.Principal
87 // - repo domain.RepoName87 // - repo domain.RepoName
88 // - rev domain.Revision88 // - rev domain.Revision
89-func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any) *BrowseService_GetCommit_Call {89+// - opts ports.GetCommitOptions
90- return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev)}90+func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any, opts any) *BrowseService_GetCommit_Call {
91+ return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev, opts)}
91 }92 }
92 93
93-func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_GetCommit_Call {94+func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions)) *BrowseService_GetCommit_Call {
94 _c.Call.Run(func(args mock.Arguments) {95 _c.Call.Run(func(args mock.Arguments) {
95 var arg0 context.Context96 var arg0 context.Context
96 if args[0] != nil {97 if args[0] != nil {
@@ -108,11 +109,16 @@ func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p doma
108 if args[3] != nil {109 if args[3] != nil {
109 arg3 = args[3].(domain.Revision)110 arg3 = args[3].(domain.Revision)
110 }111 }
112+ var arg4 ports.GetCommitOptions
113+ if args[4] != nil {
114+ arg4 = args[4].(ports.GetCommitOptions)
115+ }
111 run(116 run(
112 arg0,117 arg0,
113 arg1,118 arg1,
114 arg2,119 arg2,
115 arg3,120 arg3,
121+ arg4,
116 )122 )
117 })123 })
118 return _c124 return _c
@@ -123,7 +129,7 @@ func (_c *BrowseService_GetCommit_Call) Return(commitDetail domain.CommitDetail,
123 return _c129 return _c
124 }130 }
125 131
126-func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {132+func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {
127 _c.Call.Return(run)133 _c.Call.Return(run)
128 return _c134 return _c
129 }135 }
M internal/ports/services.go +1 -1
@@ -95,7 +95,7 @@ type BrowseService interface {
9595 ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)
9696 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
9797 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)
98- GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)
98+ GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts GetCommitOptions) (domain.CommitDetail, error)
9999 // RepoStats counts the commits of the branch whose head is given, and
100100 // names the latest tag.
101101 RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error)
@@ -95,7 +95,7 @@ type BrowseService interface {
95 ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)95 ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)
96 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)96 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
97 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)97 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)
98- GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)98+ GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts GetCommitOptions) (domain.CommitDetail, error)
99 // RepoStats counts the commits of the branch whose head is given, and99 // RepoStats counts the commits of the branch whose head is given, and
100 // names the latest tag.100 // names the latest tag.
101 RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error)101 RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error)
M internal/readertest/conformance.go +2 -0
@@ -31,6 +31,8 @@ func Conformance(t *testing.T, mk Maker) {
3131 name string
3232 fn func(*testing.T, Maker)
3333 }{
34+ {"DiffChangedLineLimits", DiffChangedLineLimits},
35+ {"DiffOmissions", DiffOmissions},
3436 {"ListRefs", testListRefs},
3537 {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},
3638 {"DetachedHead", testDetachedHead},
@@ -31,6 +31,8 @@ func Conformance(t *testing.T, mk Maker) {
31 name string31 name string
32 fn func(*testing.T, Maker)32 fn func(*testing.T, Maker)
33 }{33 }{
34+ {"DiffChangedLineLimits", DiffChangedLineLimits},
35+ {"DiffOmissions", DiffOmissions},
34 {"ListRefs", testListRefs},36 {"ListRefs", testListRefs},
35 {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},37 {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},
36 {"DetachedHead", testDetachedHead},38 {"DetachedHead", testDetachedHead},
A internal/readertest/diff.go +185 -0
@@ -0,0 +1,185 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package readertest
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// DiffChangedLineLimits checks that context is retained without consuming the
18+// changed-line limits, but still consumes the byte budget.
19+func DiffChangedLineLimits(t *testing.T, mk Maker) {
20+ g, data := mk(t)
21+ f := NewFixture(t, data, "diff-context-budget")
22+ var before, after strings.Builder
23+ const hunks = 96
24+ for i := range hunks * 10 {
25+ line := fmt.Sprintf("context %04d", i)
26+ fmt.Fprintln(&before, line)
27+ if i%10 == 4 {
28+ line = fmt.Sprintf("changed %04d", i)
29+ }
30+ fmt.Fprintln(&after, line)
31+ }
32+ f.Write("many", before.String()).Commit("before")
33+ f.Write("many", after.String()).Commit("after")
34+ limits := domain.DiffLimits{MaxFileLines: hunks * 2, MaxTotalLines: hunks * 2, MaxTotalBytes: 1 << 20}
35+ d, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
36+ require.NoError(t, err)
37+ require.Len(t, d.Diff.Files, 1)
38+ file := d.Diff.Files[0]
39+ require.Equal(t, domain.OmitNone, file.Omitted)
40+ assert.False(t, d.Diff.Truncated)
41+ require.Len(t, file.Hunks, hunks)
42+ var changed, patchLines, textBytes, changedBytes int
43+ for _, h := range file.Hunks {
44+ for _, l := range h.Lines {
45+ patchLines++
46+ textBytes += len(l.Text)
47+ if l.Kind != domain.LineContext {
48+ changed++
49+ changedBytes += len(l.Text)
50+ }
51+ }
52+ }
53+ assert.Equal(t, hunks*2, changed)
54+ // Every hunk is read with no line limit too.
55+ free, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1 << 20})
56+ require.NoError(t, err)
57+ require.Len(t, free.Diff.Files[0].Hunks, hunks)
58+ assert.Greater(t, patchLines, limits.MaxFileLines)
59+ assert.Equal(t, hunks, file.Additions)
60+ assert.Equal(t, hunks, file.Deletions)
61+ // The exact byte boundary fits; one fewer byte must discard the file even
62+ // though every changed line's text alone easily fits.
63+ limits.MaxTotalBytes = textBytes
64+ d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
65+ require.NoError(t, err)
66+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
67+ limits.MaxTotalBytes--
68+ require.Greater(t, limits.MaxTotalBytes, changedBytes)
69+ d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
70+ require.NoError(t, err)
71+ assert.False(t, d.Diff.Truncated, "only MaxFiles sets Diff.Truncated")
72+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[0].Omitted)
73+ assert.Empty(t, d.Diff.Files[0].Hunks)
74+}
75+
76+// DiffOmissions checks the omission reasons: generated files skipped without
77+// reading their hunks, one file read without a line cap, and the MaxFiles
78+// overflow being the only thing that sets Diff.Truncated.
79+func DiffOmissions(t *testing.T, mk Maker) {
80+ g, data := mk(t)
81+ ctx := context.Background()
82+ f := NewFixture(t, data, "diff-omissions")
83+ f.Write("base.txt", "x\n").Commit("base")
84+ f.Write("a.txt", "small\n")
85+ // The generated file (515 bytes) would overflow the 300-byte budget below
86+ // if it were read: OmitGenerated, not OmitUnavailable, proves its hunks
87+ // are not read.
88+ f.Write("go.sum", strings.Repeat("h1:"+strings.Repeat("z", 100)+"\n", 5))
89+ f.Lines("z-big.txt", "line", 2500)
90+ f.Commit("change")
91+
92+ limits := domain.DiffLimits{MaxTotalBytes: 300, MaxFileLines: 2000, SkipGenerated: true}
93+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
94+ require.NoError(t, err)
95+ require.Len(t, d.Diff.Files, 3)
96+ byPath := map[string]domain.FileDiff{}
97+ for _, file := range d.Diff.Files {
98+ byPath[file.NewPath] = file
99+ }
100+ assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted)
101+ assert.Len(t, byPath["a.txt"].Hunks, 1)
102+ assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted)
103+ assert.Empty(t, byPath["go.sum"].Hunks)
104+ assert.Equal(t, 5, byPath["go.sum"].Additions)
105+ assert.Equal(t, domain.OmitLarge, byPath["z-big.txt"].Omitted)
106+ assert.Empty(t, byPath["z-big.txt"].Hunks)
107+ assert.False(t, d.Diff.Truncated)
108+
109+ // Read, go.sum overflows the bytes that a.txt, read before it, already
110+ // spent: it may fit on its own, so it is OmitBudget, loadable alone.
111+ limits.SkipGenerated = false
112+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
113+ require.NoError(t, err)
114+ sum := -1
115+ for i, file := range d.Diff.Files {
116+ if file.NewPath == "go.sum" {
117+ sum = i
118+ assert.Equal(t, domain.OmitBudget, file.Omitted, "earlier files spent bytes")
119+ assert.Empty(t, file.Hunks)
120+ }
121+ }
122+ require.GreaterOrEqual(t, sum, 0)
123+ // Alone, it is still over the bytes: OmitUnavailable, a dead end.
124+ alone := domain.DiffLimits{From: sum, To: sum + 1, MaxTotalBytes: 300}
125+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, alone)
126+ require.NoError(t, err)
127+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[sum].Omitted, "over the bytes on its own")
128+ assert.Empty(t, d.Diff.Files[sum].Hunks)
129+
130+ // OneFileDiffLimits reads a file over 2,000 changed lines, and only it.
131+ idx := -1
132+ for i, file := range d.Diff.Files {
133+ if file.NewPath == "z-big.txt" {
134+ idx = i
135+ }
136+ }
137+ require.GreaterOrEqual(t, idx, 0)
138+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(idx))
139+ require.NoError(t, err)
140+ for i, file := range d.Diff.Files {
141+ assert.Equal(t, domain.OmitNone, file.Omitted)
142+ if i == idx {
143+ require.Len(t, file.Hunks, 1)
144+ assert.Len(t, file.Hunks[0].Lines, 2500)
145+ } else {
146+ assert.Empty(t, file.Hunks)
147+ }
148+ }
149+ assert.False(t, d.Diff.Truncated)
150+
151+ // MaxFiles alone sets Diff.Truncated.
152+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxTotalBytes: 1 << 20})
153+ require.NoError(t, err)
154+ assert.Len(t, d.Diff.Files, 2)
155+ assert.True(t, d.Diff.Truncated)
156+ for _, file := range d.Diff.Files {
157+ assert.Equal(t, domain.OmitNone, file.Omitted)
158+ }
159+
160+ // A one-file read is capped at MaxOneFileLines changed lines: the cap
161+ // itself loads, one more line is OmitUnavailable (it cannot be loaded).
162+ f.Lines("cap.txt", "c", domain.MaxOneFileLines).Lines("over.txt", "o", domain.MaxOneFileLines+1).Commit("caps")
163+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1})
164+ require.NoError(t, err)
165+ capIdx, overIdx := -1, -1
166+ for i, file := range d.Diff.Files {
167+ switch file.NewPath {
168+ case "cap.txt":
169+ capIdx = i
170+ case "over.txt":
171+ overIdx = i
172+ }
173+ }
174+ require.GreaterOrEqual(t, capIdx, 0)
175+ require.GreaterOrEqual(t, overIdx, 0)
176+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(capIdx))
177+ require.NoError(t, err)
178+ assert.Equal(t, domain.OmitNone, d.Diff.Files[capIdx].Omitted)
179+ require.Len(t, d.Diff.Files[capIdx].Hunks, 1)
180+ assert.Len(t, d.Diff.Files[capIdx].Hunks[0].Lines, domain.MaxOneFileLines)
181+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(overIdx))
182+ require.NoError(t, err)
183+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[overIdx].Omitted)
184+ assert.Empty(t, d.Diff.Files[overIdx].Hunks)
185+}
@@ -0,0 +1,185 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package readertest
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// DiffChangedLineLimits checks that context is retained without consuming the
18+// changed-line limits, but still consumes the byte budget.
19+func DiffChangedLineLimits(t *testing.T, mk Maker) {
20+ g, data := mk(t)
21+ f := NewFixture(t, data, "diff-context-budget")
22+ var before, after strings.Builder
23+ const hunks = 96
24+ for i := range hunks * 10 {
25+ line := fmt.Sprintf("context %04d", i)
26+ fmt.Fprintln(&before, line)
27+ if i%10 == 4 {
28+ line = fmt.Sprintf("changed %04d", i)
29+ }
30+ fmt.Fprintln(&after, line)
31+ }
32+ f.Write("many", before.String()).Commit("before")
33+ f.Write("many", after.String()).Commit("after")
34+ limits := domain.DiffLimits{MaxFileLines: hunks * 2, MaxTotalLines: hunks * 2, MaxTotalBytes: 1 << 20}
35+ d, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
36+ require.NoError(t, err)
37+ require.Len(t, d.Diff.Files, 1)
38+ file := d.Diff.Files[0]
39+ require.Equal(t, domain.OmitNone, file.Omitted)
40+ assert.False(t, d.Diff.Truncated)
41+ require.Len(t, file.Hunks, hunks)
42+ var changed, patchLines, textBytes, changedBytes int
43+ for _, h := range file.Hunks {
44+ for _, l := range h.Lines {
45+ patchLines++
46+ textBytes += len(l.Text)
47+ if l.Kind != domain.LineContext {
48+ changed++
49+ changedBytes += len(l.Text)
50+ }
51+ }
52+ }
53+ assert.Equal(t, hunks*2, changed)
54+ // Every hunk is read with no line limit too.
55+ free, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1 << 20})
56+ require.NoError(t, err)
57+ require.Len(t, free.Diff.Files[0].Hunks, hunks)
58+ assert.Greater(t, patchLines, limits.MaxFileLines)
59+ assert.Equal(t, hunks, file.Additions)
60+ assert.Equal(t, hunks, file.Deletions)
61+ // The exact byte boundary fits; one fewer byte must discard the file even
62+ // though every changed line's text alone easily fits.
63+ limits.MaxTotalBytes = textBytes
64+ d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
65+ require.NoError(t, err)
66+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
67+ limits.MaxTotalBytes--
68+ require.Greater(t, limits.MaxTotalBytes, changedBytes)
69+ d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
70+ require.NoError(t, err)
71+ assert.False(t, d.Diff.Truncated, "only MaxFiles sets Diff.Truncated")
72+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[0].Omitted)
73+ assert.Empty(t, d.Diff.Files[0].Hunks)
74+}
75+
76+// DiffOmissions checks the omission reasons: generated files skipped without
77+// reading their hunks, one file read without a line cap, and the MaxFiles
78+// overflow being the only thing that sets Diff.Truncated.
79+func DiffOmissions(t *testing.T, mk Maker) {
80+ g, data := mk(t)
81+ ctx := context.Background()
82+ f := NewFixture(t, data, "diff-omissions")
83+ f.Write("base.txt", "x\n").Commit("base")
84+ f.Write("a.txt", "small\n")
85+ // The generated file (515 bytes) would overflow the 300-byte budget below
86+ // if it were read: OmitGenerated, not OmitUnavailable, proves its hunks
87+ // are not read.
88+ f.Write("go.sum", strings.Repeat("h1:"+strings.Repeat("z", 100)+"\n", 5))
89+ f.Lines("z-big.txt", "line", 2500)
90+ f.Commit("change")
91+
92+ limits := domain.DiffLimits{MaxTotalBytes: 300, MaxFileLines: 2000, SkipGenerated: true}
93+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
94+ require.NoError(t, err)
95+ require.Len(t, d.Diff.Files, 3)
96+ byPath := map[string]domain.FileDiff{}
97+ for _, file := range d.Diff.Files {
98+ byPath[file.NewPath] = file
99+ }
100+ assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted)
101+ assert.Len(t, byPath["a.txt"].Hunks, 1)
102+ assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted)
103+ assert.Empty(t, byPath["go.sum"].Hunks)
104+ assert.Equal(t, 5, byPath["go.sum"].Additions)
105+ assert.Equal(t, domain.OmitLarge, byPath["z-big.txt"].Omitted)
106+ assert.Empty(t, byPath["z-big.txt"].Hunks)
107+ assert.False(t, d.Diff.Truncated)
108+
109+ // Read, go.sum overflows the bytes that a.txt, read before it, already
110+ // spent: it may fit on its own, so it is OmitBudget, loadable alone.
111+ limits.SkipGenerated = false
112+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
113+ require.NoError(t, err)
114+ sum := -1
115+ for i, file := range d.Diff.Files {
116+ if file.NewPath == "go.sum" {
117+ sum = i
118+ assert.Equal(t, domain.OmitBudget, file.Omitted, "earlier files spent bytes")
119+ assert.Empty(t, file.Hunks)
120+ }
121+ }
122+ require.GreaterOrEqual(t, sum, 0)
123+ // Alone, it is still over the bytes: OmitUnavailable, a dead end.
124+ alone := domain.DiffLimits{From: sum, To: sum + 1, MaxTotalBytes: 300}
125+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, alone)
126+ require.NoError(t, err)
127+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[sum].Omitted, "over the bytes on its own")
128+ assert.Empty(t, d.Diff.Files[sum].Hunks)
129+
130+ // OneFileDiffLimits reads a file over 2,000 changed lines, and only it.
131+ idx := -1
132+ for i, file := range d.Diff.Files {
133+ if file.NewPath == "z-big.txt" {
134+ idx = i
135+ }
136+ }
137+ require.GreaterOrEqual(t, idx, 0)
138+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(idx))
139+ require.NoError(t, err)
140+ for i, file := range d.Diff.Files {
141+ assert.Equal(t, domain.OmitNone, file.Omitted)
142+ if i == idx {
143+ require.Len(t, file.Hunks, 1)
144+ assert.Len(t, file.Hunks[0].Lines, 2500)
145+ } else {
146+ assert.Empty(t, file.Hunks)
147+ }
148+ }
149+ assert.False(t, d.Diff.Truncated)
150+
151+ // MaxFiles alone sets Diff.Truncated.
152+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxTotalBytes: 1 << 20})
153+ require.NoError(t, err)
154+ assert.Len(t, d.Diff.Files, 2)
155+ assert.True(t, d.Diff.Truncated)
156+ for _, file := range d.Diff.Files {
157+ assert.Equal(t, domain.OmitNone, file.Omitted)
158+ }
159+
160+ // A one-file read is capped at MaxOneFileLines changed lines: the cap
161+ // itself loads, one more line is OmitUnavailable (it cannot be loaded).
162+ f.Lines("cap.txt", "c", domain.MaxOneFileLines).Lines("over.txt", "o", domain.MaxOneFileLines+1).Commit("caps")
163+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1})
164+ require.NoError(t, err)
165+ capIdx, overIdx := -1, -1
166+ for i, file := range d.Diff.Files {
167+ switch file.NewPath {
168+ case "cap.txt":
169+ capIdx = i
170+ case "over.txt":
171+ overIdx = i
172+ }
173+ }
174+ require.GreaterOrEqual(t, capIdx, 0)
175+ require.GreaterOrEqual(t, overIdx, 0)
176+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(capIdx))
177+ require.NoError(t, err)
178+ assert.Equal(t, domain.OmitNone, d.Diff.Files[capIdx].Omitted)
179+ require.Len(t, d.Diff.Files[capIdx].Hunks, 1)
180+ assert.Len(t, d.Diff.Files[capIdx].Hunks[0].Lines, domain.MaxOneFileLines)
181+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(overIdx))
182+ require.NoError(t, err)
183+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[overIdx].Omitted)
184+ assert.Empty(t, d.Diff.Files[overIdx].Hunks)
185+}
M internal/usecase/browse.go +27 -3
@@ -336,15 +336,39 @@ func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int,
336336 }
337337
338338 // GetCommit returns a commit with its statistics and its diff against the
339-// first parent, within the default diff limits.
340-func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
339+// first parent, within the default diff limits or, with opts.Files, the limits
340+// of that file window. An invalid window gives domain.ErrInvalid.
341+func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
342+ if (opts.Page && opts.Files != nil) || (opts.Load != nil && !opts.Page) {
343+ return domain.CommitDetail{}, fmt.Errorf("%w: commit page options", domain.ErrInvalid)
344+ }
345+ win := opts.Files
346+ if opts.Page {
347+ win = &ports.FileWindow{From: 0, To: domain.CommitPageFiles}
348+ }
349+ limits, err := windowLimits(win)
350+ if err != nil {
351+ return domain.CommitDetail{}, err
352+ }
341353 if _, err := b.authorize(ctx, p, repo); err != nil {
342354 return domain.CommitDetail{}, err
343355 }
344- d, err := b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())
356+ d, err := b.reader.GetCommit(ctx, repo, rev, limits)
345357 if err != nil {
346358 return domain.CommitDetail{}, err
347359 }
360+ if opts.Page {
361+ d.Diff.CollapseFrom(domain.CommitPageFiles)
362+ if i := opts.Load; i != nil && *i >= 0 && *i < len(d.Diff.Files) && d.Diff.Files[*i].Omitted.Collapsed() {
363+ one, err := b.reader.GetCommit(ctx, repo, rev, domain.OneFileDiffLimits(*i))
364+ if err != nil {
365+ return domain.CommitDetail{}, err
366+ }
367+ if *i < len(one.Diff.Files) {
368+ d.Diff.Files[*i] = one.Diff.Files[*i]
369+ }
370+ }
371+ }
348372 if b.verifier != nil {
349373 one := []domain.Commit{d.Commit}
350374 b.verify(ctx, repo, one)
@@ -336,15 +336,39 @@ func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int,
336 }336 }
337 337
338 // GetCommit returns a commit with its statistics and its diff against the338 // GetCommit returns a commit with its statistics and its diff against the
339-// first parent, within the default diff limits.339+// first parent, within the default diff limits or, with opts.Files, the limits
340-func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {340+// of that file window. An invalid window gives domain.ErrInvalid.
341+func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
342+ if (opts.Page && opts.Files != nil) || (opts.Load != nil && !opts.Page) {
343+ return domain.CommitDetail{}, fmt.Errorf("%w: commit page options", domain.ErrInvalid)
344+ }
345+ win := opts.Files
346+ if opts.Page {
347+ win = &ports.FileWindow{From: 0, To: domain.CommitPageFiles}
348+ }
349+ limits, err := windowLimits(win)
350+ if err != nil {
351+ return domain.CommitDetail{}, err
352+ }
341 if _, err := b.authorize(ctx, p, repo); err != nil {353 if _, err := b.authorize(ctx, p, repo); err != nil {
342 return domain.CommitDetail{}, err354 return domain.CommitDetail{}, err
343 }355 }
344- d, err := b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())356+ d, err := b.reader.GetCommit(ctx, repo, rev, limits)
345 if err != nil {357 if err != nil {
346 return domain.CommitDetail{}, err358 return domain.CommitDetail{}, err
347 }359 }
360+ if opts.Page {
361+ d.Diff.CollapseFrom(domain.CommitPageFiles)
362+ if i := opts.Load; i != nil && *i >= 0 && *i < len(d.Diff.Files) && d.Diff.Files[*i].Omitted.Collapsed() {
363+ one, err := b.reader.GetCommit(ctx, repo, rev, domain.OneFileDiffLimits(*i))
364+ if err != nil {
365+ return domain.CommitDetail{}, err
366+ }
367+ if *i < len(one.Diff.Files) {
368+ d.Diff.Files[*i] = one.Diff.Files[*i]
369+ }
370+ }
371+ }
348 if b.verifier != nil {372 if b.verifier != nil {
349 one := []domain.Commit{d.Commit}373 one := []domain.Commit{d.Commit}
350 b.verify(ctx, repo, one)374 b.verify(ctx, repo, one)
M internal/usecase/browse_test.go +37 -4
@@ -9,6 +9,7 @@ import (
99 "fmt"
1010 "io"
1111 "runtime"
12+ "slices"
1213 "strconv"
1314 "strings"
1415 "sync"
@@ -104,7 +105,7 @@ func browseOps() []browseOp {
104105 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
105106 }},
106107 {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
107- _, err := b.GetCommit(ctx, p, repo, domain.Revision{})
108+ _, err := b.GetCommit(ctx, p, repo, domain.Revision{}, ports.GetCommitOptions{})
108109 return err
109110 }, func(r *mocks.RepoReader) {
110111 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
@@ -629,12 +630,44 @@ func TestBrowseVerifiesCommits(t *testing.T) {
629630 Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil)
630631 v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}).
631632 Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified})
632- d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})
633+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
633634 require.NoError(t, err)
634635 assert.Equal(t, verified, d.Commit.Verification)
635636 assert.Equal(t, 2, d.Stats.FilesChanged)
636637 })
637638
639+ t.Run("GetCommit Page collapses past the window and loads one file", func(t *testing.T) {
640+ t.Parallel()
641+ f, v := newFixture(t)
642+ v.EXPECT().VerifyCommits(ctx, pub.Name, mock.Anything).Return(nil).Maybe()
643+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe()
644+ files := make([]domain.FileDiff, domain.CommitPageFiles+2)
645+ for i := range files {
646+ files[i].Additions = 1
647+ }
648+ win := domain.DefaultDiffLimits()
649+ win.MaxFiles, win.From, win.To, win.SkipGenerated = 0, 0, domain.CommitPageFiles, true
650+ f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, win).
651+ Return(domain.CommitDetail{Diff: domain.Diff{Files: slices.Clone(files)}}, nil)
652+ i := domain.CommitPageFiles + 1
653+ loaded := slices.Clone(files)
654+ loaded[i].Hunks = []domain.Hunk{{}}
655+ f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, domain.OneFileDiffLimits(i)).
656+ Return(domain.CommitDetail{Diff: domain.Diff{Files: loaded}}, nil)
657+
658+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{Page: true, Load: &i})
659+ require.NoError(t, err)
660+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
661+ assert.Equal(t, domain.OmitBudget, d.Diff.Files[domain.CommitPageFiles].Omitted)
662+ assert.Equal(t, domain.OmitNone, d.Diff.Files[i].Omitted)
663+ assert.NotEmpty(t, d.Diff.Files[i].Hunks)
664+
665+ _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{Load: &i})
666+ assert.ErrorIs(t, err, domain.ErrInvalid)
667+ _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{Page: true, Files: &ports.FileWindow{To: 1}})
668+ assert.ErrorIs(t, err, domain.ErrInvalid)
669+ })
670+
638671 t.Run("nothing is verified for an empty page or a failed read", func(t *testing.T) {
639672 t.Parallel()
640673 f, _ := newFixture(t) // the verifier mock fails the test if called
@@ -642,7 +675,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
642675 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
643676 require.NoError(t, err)
644677 f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound)
645- _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})
678+ _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
646679 assert.ErrorIs(t, err, domain.ErrNotFound)
647680 })
648681
@@ -657,7 +690,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
657690 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
658691 require.NoError(t, err)
659692 assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification)
660- d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})
693+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
661694 require.NoError(t, err)
662695 assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification)
663696 })
@@ -9,6 +9,7 @@ import (
9 "fmt"9 "fmt"
10 "io"10 "io"
11 "runtime"11 "runtime"
12+ "slices"
12 "strconv"13 "strconv"
13 "strings"14 "strings"
14 "sync"15 "sync"
@@ -104,7 +105,7 @@ func browseOps() []browseOp {
104 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)105 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
105 }},106 }},
106 {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {107 {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
107- _, err := b.GetCommit(ctx, p, repo, domain.Revision{})108+ _, err := b.GetCommit(ctx, p, repo, domain.Revision{}, ports.GetCommitOptions{})
108 return err109 return err
109 }, func(r *mocks.RepoReader) {110 }, func(r *mocks.RepoReader) {
110 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)111 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
@@ -629,12 +630,44 @@ func TestBrowseVerifiesCommits(t *testing.T) {
629 Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil)630 Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil)
630 v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}).631 v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}).
631 Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified})632 Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified})
632- d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})633+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
633 require.NoError(t, err)634 require.NoError(t, err)
634 assert.Equal(t, verified, d.Commit.Verification)635 assert.Equal(t, verified, d.Commit.Verification)
635 assert.Equal(t, 2, d.Stats.FilesChanged)636 assert.Equal(t, 2, d.Stats.FilesChanged)
636 })637 })
637 638
639+ t.Run("GetCommit Page collapses past the window and loads one file", func(t *testing.T) {
640+ t.Parallel()
641+ f, v := newFixture(t)
642+ v.EXPECT().VerifyCommits(ctx, pub.Name, mock.Anything).Return(nil).Maybe()
643+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe()
644+ files := make([]domain.FileDiff, domain.CommitPageFiles+2)
645+ for i := range files {
646+ files[i].Additions = 1
647+ }
648+ win := domain.DefaultDiffLimits()
649+ win.MaxFiles, win.From, win.To, win.SkipGenerated = 0, 0, domain.CommitPageFiles, true
650+ f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, win).
651+ Return(domain.CommitDetail{Diff: domain.Diff{Files: slices.Clone(files)}}, nil)
652+ i := domain.CommitPageFiles + 1
653+ loaded := slices.Clone(files)
654+ loaded[i].Hunks = []domain.Hunk{{}}
655+ f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, domain.OneFileDiffLimits(i)).
656+ Return(domain.CommitDetail{Diff: domain.Diff{Files: loaded}}, nil)
657+
658+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{Page: true, Load: &i})
659+ require.NoError(t, err)
660+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
661+ assert.Equal(t, domain.OmitBudget, d.Diff.Files[domain.CommitPageFiles].Omitted)
662+ assert.Equal(t, domain.OmitNone, d.Diff.Files[i].Omitted)
663+ assert.NotEmpty(t, d.Diff.Files[i].Hunks)
664+
665+ _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{Load: &i})
666+ assert.ErrorIs(t, err, domain.ErrInvalid)
667+ _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{Page: true, Files: &ports.FileWindow{To: 1}})
668+ assert.ErrorIs(t, err, domain.ErrInvalid)
669+ })
670+
638 t.Run("nothing is verified for an empty page or a failed read", func(t *testing.T) {671 t.Run("nothing is verified for an empty page or a failed read", func(t *testing.T) {
639 t.Parallel()672 t.Parallel()
640 f, _ := newFixture(t) // the verifier mock fails the test if called673 f, _ := newFixture(t) // the verifier mock fails the test if called
@@ -642,7 +675,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
642 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})675 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
643 require.NoError(t, err)676 require.NoError(t, err)
644 f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound)677 f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound)
645- _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})678+ _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
646 assert.ErrorIs(t, err, domain.ErrNotFound)679 assert.ErrorIs(t, err, domain.ErrNotFound)
647 })680 })
648 681
@@ -657,7 +690,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
657 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})690 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
658 require.NoError(t, err)691 require.NoError(t, err)
659 assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification)692 assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification)
660- d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})693+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
661 require.NoError(t, err)694 require.NoError(t, err)
662 assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification)695 assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification)
663 })696 })
M internal/usecase/ci_pull_test.go +1 -1
@@ -107,7 +107,7 @@ func TestFilePatch(t *testing.T) {
107107 assert.Equal(t, "@@ -1,2 +1,2 @@ func x()\n a\n-b\n+c\n\\ No newline at end of file\n@@ -9 +9 @@\n+z", got)
108108 _, ok = usecase.FilePatch(domain.FileDiff{Binary: true, Hunks: f.Hunks})
109109 assert.False(t, ok, "binary")
110- _, ok = usecase.FilePatch(domain.FileDiff{Truncated: true})
110+ _, ok = usecase.FilePatch(domain.FileDiff{Omitted: domain.OmitLarge})
111111 assert.False(t, ok, "truncated")
112112 _, ok = usecase.FilePatch(domain.FileDiff{Status: domain.FileRenamed})
113113 assert.False(t, ok, "a pure rename has no hunk")
@@ -107,7 +107,7 @@ func TestFilePatch(t *testing.T) {
107 assert.Equal(t, "@@ -1,2 +1,2 @@ func x()\n a\n-b\n+c\n\\ No newline at end of file\n@@ -9 +9 @@\n+z", got)107 assert.Equal(t, "@@ -1,2 +1,2 @@ func x()\n a\n-b\n+c\n\\ No newline at end of file\n@@ -9 +9 @@\n+z", got)
108 _, ok = usecase.FilePatch(domain.FileDiff{Binary: true, Hunks: f.Hunks})108 _, ok = usecase.FilePatch(domain.FileDiff{Binary: true, Hunks: f.Hunks})
109 assert.False(t, ok, "binary")109 assert.False(t, ok, "binary")
110- _, ok = usecase.FilePatch(domain.FileDiff{Truncated: true})110+ _, ok = usecase.FilePatch(domain.FileDiff{Omitted: domain.OmitLarge})
111 assert.False(t, ok, "truncated")111 assert.False(t, ok, "truncated")
112 _, ok = usecase.FilePatch(domain.FileDiff{Status: domain.FileRenamed})112 _, ok = usecase.FilePatch(domain.FileDiff{Status: domain.FileRenamed})
113 assert.False(t, ok, "a pure rename has no hunk")113 assert.False(t, ok, "a pure rename has no hunk")
M internal/usecase/ci_pullfiles.go +1 -1
@@ -212,7 +212,7 @@ func escapeURLPath(p string) string {
212212 // (hunk headers and lines, no file headers). Absent for a binary file, a pure
213213 // rename and a file too large for the diff limits, as on GitHub.
214214 func filePatch(f domain.FileDiff) (string, bool) {
215- if f.Binary || f.Truncated || len(f.Hunks) == 0 {
215+ if f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 {
216216 return "", false
217217 }
218218 var b strings.Builder
@@ -212,7 +212,7 @@ func escapeURLPath(p string) string {
212 // (hunk headers and lines, no file headers). Absent for a binary file, a pure212 // (hunk headers and lines, no file headers). Absent for a binary file, a pure
213 // rename and a file too large for the diff limits, as on GitHub.213 // rename and a file too large for the diff limits, as on GitHub.
214 func filePatch(f domain.FileDiff) (string, bool) {214 func filePatch(f domain.FileDiff) (string, bool) {
215- if f.Binary || f.Truncated || len(f.Hunks) == 0 {215+ if f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 {
216 return "", false216 return "", false
217 }217 }
218 var b strings.Builder218 var b strings.Builder
M internal/usecase/export_test.go +3 -0
@@ -217,3 +217,6 @@ func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) {
217217 func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {
218218 return s.popDue(retryItem{key: repo, due: due})
219219 }
220+
221+// WindowLimits exposes windowLimits to the tests.
222+var WindowLimits = windowLimits
@@ -217,3 +217,6 @@ func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) {
217 func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {217 func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {
218 return s.popDue(retryItem{key: repo, due: due})218 return s.popDue(retryItem{key: repo, due: due})
219 }219 }
220+
221+// WindowLimits exposes windowLimits to the tests.
222+var WindowLimits = windowLimits
M internal/usecase/mergerequests.go +42 -19
@@ -493,6 +493,26 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain
493493 return repo, mr, nil
494494 }
495495
496+// windowLimits is the diff limits of a read: nil gives
497+// domain.DefaultDiffLimits (the API contract, generated files read); a window
498+// lists every file (MaxFiles 0), reads From..To and skips generated files; a
499+// Full window reads the one file From with domain.OneFileDiffLimits. An invalid
500+// window (negative, To < From, Full without To == From+1) is domain.ErrInvalid.
501+func windowLimits(w *ports.FileWindow) (domain.DiffLimits, error) {
502+ if w == nil {
503+ return domain.DefaultDiffLimits(), nil
504+ }
505+ if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) || (w.Full && w.To != w.From+1) {
506+ return domain.DiffLimits{}, fmt.Errorf("%w: file window [%d, %d) full=%t", domain.ErrInvalid, w.From, w.To, w.Full)
507+ }
508+ if w.Full {
509+ return domain.OneFileDiffLimits(w.From), nil
510+ }
511+ l := domain.DefaultDiffLimits()
512+ l.MaxFiles, l.From, l.To, l.SkipGenerated = 0, w.From, w.To, true
513+ return l, nil
514+}
515+
496516 // Get returns a merge request with its commits, its diff, its comments and the
497517 // read position. The diff compares the merge base of the target and the head
498518 // with the head ("target...head"), within domain.DefaultDiffLimits. With
@@ -507,12 +527,9 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain
507527 // diff is no longer the one the caller shows (before any diff is read);
508528 // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid.
509529 func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
510- limits := domain.DefaultDiffLimits()
511- if w := opts.Files; w != nil {
512- if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) {
513- return ports.MergeRequestDetail{}, fmt.Errorf("%w: file window [%d, %d)", domain.ErrInvalid, w.From, w.To)
514- }
515- limits.MaxFiles, limits.From, limits.To = 0, w.From, w.To
530+ limits, err := windowLimits(opts.Files)
531+ if err != nil {
532+ return ports.MergeRequestDetail{}, err
516533 }
517534 _, mr, err := m.get(ctx, p, name, number)
518535 if err != nil {
@@ -604,6 +621,16 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
604621 if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, limits); err != nil {
605622 return ports.MergeRequestDetail{}, err
606623 }
624+ // The page of a client without scripts reads one collapsed file in full.
625+ if i := opts.Load; i != nil && *i >= 0 && *i < len(d.Diff.Files) && d.Diff.Files[*i].Omitted.Collapsed() {
626+ _, one, err := m.git.DiffCommits(ctx, name, base, head, domain.OneFileDiffLimits(*i))
627+ if err != nil {
628+ return ports.MergeRequestDetail{}, err
629+ }
630+ if *i < len(one.Files) {
631+ d.Diff.Files[*i] = one.Files[*i]
632+ }
633+ }
607634 return d, nil
608635 }
609636
@@ -763,18 +790,22 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
763790 // file's hunks within the default total limits would reject a comment on a
764791 // file the earlier ones left no budget for. So a first read lists the files
765792 // with no hunks (From past the end: cheap, git is stopped at once), and a
766- // second reads the hunks of the one file within the default per-file limit
767- // and total limits, as the web view does for a window holding it.
793+ // second loads the one file in full (a Full window: the byte guard and
794+ // domain.MaxOneFileLines only, generated files included), as the web view
795+ // does when it loads a collapsed file: any line the page can show can be
796+ // commented on, and a file over those limits takes no line comment.
768797 list := domain.DefaultDiffLimits()
769798 list.MaxFiles, list.From = 0, math.MaxInt
770799 _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list)
771800 if err != nil {
772801 return err
773802 }
774- idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return diffFileName(f) == params.Path })
803+ idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return f.CommentPath() == params.Path })
775804 if idx >= 0 {
776- one := domain.DefaultDiffLimits()
777- one.MaxFiles, one.From, one.To = 0, idx, idx+1
805+ one, err := windowLimits(&ports.FileWindow{From: idx, To: idx + 1, Full: true})
806+ if err != nil {
807+ return err
808+ }
778809 if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil {
779810 return err
780811 }
@@ -788,14 +819,6 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
788819 return v.Err()
789820 }
790821
791-// diffFileName is the path a comment designates for f, as Diff.HasLine reads it.
792-func diffFileName(f domain.FileDiff) string {
793- if f.Status == domain.FileDeleted {
794- return f.OldPath
795- }
796- return f.NewPath
797-}
798-
799822 // ResolveComment marks a comment resolved or unresolved. The principal needs
800823 // mr:write. An unknown comment gives domain.ErrNotFound and a request that is
801824 // not open domain.ErrPreconditionFailed. Setting the state the comment already
@@ -493,6 +493,26 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain
493 return repo, mr, nil493 return repo, mr, nil
494 }494 }
495 495
496+// windowLimits is the diff limits of a read: nil gives
497+// domain.DefaultDiffLimits (the API contract, generated files read); a window
498+// lists every file (MaxFiles 0), reads From..To and skips generated files; a
499+// Full window reads the one file From with domain.OneFileDiffLimits. An invalid
500+// window (negative, To < From, Full without To == From+1) is domain.ErrInvalid.
501+func windowLimits(w *ports.FileWindow) (domain.DiffLimits, error) {
502+ if w == nil {
503+ return domain.DefaultDiffLimits(), nil
504+ }
505+ if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) || (w.Full && w.To != w.From+1) {
506+ return domain.DiffLimits{}, fmt.Errorf("%w: file window [%d, %d) full=%t", domain.ErrInvalid, w.From, w.To, w.Full)
507+ }
508+ if w.Full {
509+ return domain.OneFileDiffLimits(w.From), nil
510+ }
511+ l := domain.DefaultDiffLimits()
512+ l.MaxFiles, l.From, l.To, l.SkipGenerated = 0, w.From, w.To, true
513+ return l, nil
514+}
515+
496 // Get returns a merge request with its commits, its diff, its comments and the516 // Get returns a merge request with its commits, its diff, its comments and the
497 // read position. The diff compares the merge base of the target and the head517 // read position. The diff compares the merge base of the target and the head
498 // with the head ("target...head"), within domain.DefaultDiffLimits. With518 // with the head ("target...head"), within domain.DefaultDiffLimits. With
@@ -507,12 +527,9 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain
507 // diff is no longer the one the caller shows (before any diff is read);527 // diff is no longer the one the caller shows (before any diff is read);
508 // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid.528 // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid.
509 func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {529 func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
510- limits := domain.DefaultDiffLimits()530+ limits, err := windowLimits(opts.Files)
511- if w := opts.Files; w != nil {531+ if err != nil {
512- if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) {532+ return ports.MergeRequestDetail{}, err
513- return ports.MergeRequestDetail{}, fmt.Errorf("%w: file window [%d, %d)", domain.ErrInvalid, w.From, w.To)
514- }
515- limits.MaxFiles, limits.From, limits.To = 0, w.From, w.To
516 }533 }
517 _, mr, err := m.get(ctx, p, name, number)534 _, mr, err := m.get(ctx, p, name, number)
518 if err != nil {535 if err != nil {
@@ -604,6 +621,16 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
604 if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, limits); err != nil {621 if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, limits); err != nil {
605 return ports.MergeRequestDetail{}, err622 return ports.MergeRequestDetail{}, err
606 }623 }
624+ // The page of a client without scripts reads one collapsed file in full.
625+ if i := opts.Load; i != nil && *i >= 0 && *i < len(d.Diff.Files) && d.Diff.Files[*i].Omitted.Collapsed() {
626+ _, one, err := m.git.DiffCommits(ctx, name, base, head, domain.OneFileDiffLimits(*i))
627+ if err != nil {
628+ return ports.MergeRequestDetail{}, err
629+ }
630+ if *i < len(one.Files) {
631+ d.Diff.Files[*i] = one.Files[*i]
632+ }
633+ }
607 return d, nil634 return d, nil
608 }635 }
609 636
@@ -763,18 +790,22 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
763 // file's hunks within the default total limits would reject a comment on a790 // file's hunks within the default total limits would reject a comment on a
764 // file the earlier ones left no budget for. So a first read lists the files791 // file the earlier ones left no budget for. So a first read lists the files
765 // with no hunks (From past the end: cheap, git is stopped at once), and a792 // with no hunks (From past the end: cheap, git is stopped at once), and a
766- // second reads the hunks of the one file within the default per-file limit793+ // second loads the one file in full (a Full window: the byte guard and
767- // and total limits, as the web view does for a window holding it.794+ // domain.MaxOneFileLines only, generated files included), as the web view
795+ // does when it loads a collapsed file: any line the page can show can be
796+ // commented on, and a file over those limits takes no line comment.
768 list := domain.DefaultDiffLimits()797 list := domain.DefaultDiffLimits()
769 list.MaxFiles, list.From = 0, math.MaxInt798 list.MaxFiles, list.From = 0, math.MaxInt
770 _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list)799 _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list)
771 if err != nil {800 if err != nil {
772 return err801 return err
773 }802 }
774- idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return diffFileName(f) == params.Path })803+ idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return f.CommentPath() == params.Path })
775 if idx >= 0 {804 if idx >= 0 {
776- one := domain.DefaultDiffLimits()805+ one, err := windowLimits(&ports.FileWindow{From: idx, To: idx + 1, Full: true})
777- one.MaxFiles, one.From, one.To = 0, idx, idx+1806+ if err != nil {
807+ return err
808+ }
778 if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil {809 if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil {
779 return err810 return err
780 }811 }
@@ -788,14 +819,6 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
788 return v.Err()819 return v.Err()
789 }820 }
790 821
791-// diffFileName is the path a comment designates for f, as Diff.HasLine reads it.
792-func diffFileName(f domain.FileDiff) string {
793- if f.Status == domain.FileDeleted {
794- return f.OldPath
795- }
796- return f.NewPath
797-}
798-
799 // ResolveComment marks a comment resolved or unresolved. The principal needs822 // ResolveComment marks a comment resolved or unresolved. The principal needs
800 // mr:write. An unknown comment gives domain.ErrNotFound and a request that is823 // mr:write. An unknown comment gives domain.ErrNotFound and a request that is
801 // not open domain.ErrPreconditionFailed. Setting the state the comment already824 // not open domain.ErrPreconditionFailed. Setting the state the comment already
M internal/usecase/mergerequests_files_test.go +1 -1
@@ -63,7 +63,7 @@ func TestGetMergeRequestFilesWindow(t *testing.T) {
6363 for i, f := range d.Diff.Files {
6464 assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath)
6565 assert.Equal(t, 1, f.Additions)
66- assert.False(t, f.Truncated)
66+ assert.Equal(t, domain.OmitNone, f.Omitted)
6767 if i >= 305 && i < 310 {
6868 require.Len(t, f.Hunks, 1, f.NewPath)
6969 assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text)
@@ -63,7 +63,7 @@ func TestGetMergeRequestFilesWindow(t *testing.T) {
63 for i, f := range d.Diff.Files {63 for i, f := range d.Diff.Files {
64 assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath)64 assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath)
65 assert.Equal(t, 1, f.Additions)65 assert.Equal(t, 1, f.Additions)
66- assert.False(t, f.Truncated)66+ assert.Equal(t, domain.OmitNone, f.Omitted)
67 if i >= 305 && i < 310 {67 if i >= 305 && i < 310 {
68 require.Len(t, f.Hunks, 1, f.NewPath)68 require.Len(t, f.Hunks, 1, f.NewPath)
69 assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text)69 assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text)
A internal/usecase/window_test.go +166 -0
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/usecase"
16+)
17+
18+func TestWindowLimits(t *testing.T) {
19+ def := domain.DefaultDiffLimits()
20+ win := def
21+ win.MaxFiles, win.From, win.To, win.SkipGenerated = 0, 2, 5, true
22+ open := win
23+ open.From, open.To = 7, 0
24+
25+ tests := []struct {
26+ name string
27+ w *ports.FileWindow
28+ want domain.DiffLimits
29+ bad bool
30+ }{
31+ {name: "nil is the default", want: def},
32+ {name: "a window", w: &ports.FileWindow{From: 2, To: 5}, want: win},
33+ {name: "an open window", w: &ports.FileWindow{From: 7}, want: open},
34+ {name: "full", w: &ports.FileWindow{From: 3, To: 4, Full: true}, want: domain.OneFileDiffLimits(3)},
35+ {name: "negative from", w: &ports.FileWindow{From: -1}, bad: true},
36+ {name: "negative to", w: &ports.FileWindow{To: -1}, bad: true},
37+ {name: "to before from", w: &ports.FileWindow{From: 5, To: 3}, bad: true},
38+ {name: "full without to", w: &ports.FileWindow{From: 3, Full: true}, bad: true},
39+ {name: "full of two files", w: &ports.FileWindow{From: 3, To: 5, Full: true}, bad: true},
40+ {name: "full of no file", w: &ports.FileWindow{From: 3, To: 3, Full: true}, bad: true},
41+ }
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ got, err := usecase.WindowLimits(tt.w)
45+ if tt.bad {
46+ require.ErrorIs(t, err, domain.ErrInvalid)
47+ return
48+ }
49+ require.NoError(t, err)
50+ assert.Equal(t, tt.want, got)
51+ })
52+ }
53+}
54+
55+func TestGetMergeRequestFullAndGenerated(t *testing.T) {
56+ r := newMRRig(t)
57+ r.seed()
58+ r.sh("checkout", "-q", "-B", "feat", "main")
59+ r.commit(author, "a.txt", "one\n", "a")
60+ r.commit(author, "go.sum", "x y z\n", "sum")
61+ r.push("feat")
62+ n := r.open("feat").Number
63+
64+ t.Run("full with a wider window is invalid", func(t *testing.T) {
65+ _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}})
66+ require.ErrorIs(t, err, domain.ErrInvalid)
67+ })
68+
69+ t.Run("a windowed read collapses a generated file, a nil read does not", func(t *testing.T) {
70+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 10}})
71+ require.NoError(t, err)
72+ byPath := map[string]domain.FileDiff{}
73+ for _, f := range d.Diff.Files {
74+ byPath[f.NewPath] = f
75+ }
76+ assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted)
77+ assert.Empty(t, byPath["go.sum"].Hunks)
78+ assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted)
79+ assert.NotEmpty(t, byPath["a.txt"].Hunks)
80+
81+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{})
82+ require.NoError(t, err)
83+ for _, f := range d.Diff.Files {
84+ assert.Equal(t, domain.OmitNone, f.Omitted, f.NewPath)
85+ assert.NotEmpty(t, f.Hunks, f.NewPath)
86+ }
87+ })
88+}
89+
90+func bigContent(n int) string {
91+ var b strings.Builder
92+ for i := range n {
93+ fmt.Fprintf(&b, "line %d\n", i)
94+ }
95+ return b.String()
96+}
97+
98+func TestCheckLineBeyondFileLimit(t *testing.T) {
99+ r := newMRRig(t)
100+ r.seed()
101+ r.sh("checkout", "-q", "-B", "feat", "main")
102+ r.commit(author, "big.txt", bigContent(3000), "big")
103+ r.commit(author, "go.sum", "h1:x\nh1:y\n", "sum")
104+ r.push("feat")
105+ n := r.open("feat").Number
106+
107+ add := func(path string, line int) error {
108+ _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: path, Line: line, Side: domain.SideNew, Text: "c"})
109+ return err
110+ }
111+ require.NoError(t, add("big.txt", 1))
112+ require.NoError(t, add("big.txt", 2500))
113+ require.NoError(t, add("big.txt", 3000))
114+ require.ErrorIs(t, add("big.txt", 3001), domain.ErrInvalid)
115+ // A generated file is collapsed in the web windows, but loadable: its
116+ // lines can be commented on.
117+ require.NoError(t, add("go.sum", 2))
118+}
119+
120+func TestBrowseGetCommitFullWindow(t *testing.T) {
121+ r := newMRRig(t)
122+ r.seed()
123+ r.commit(author, "a-big.txt", bigContent(3000), "big")
124+ id := r.head()
125+ r.push("main")
126+ b := usecase.NewBrowse(r.store, r.git)
127+ rev := domain.RevisionOf(id)
128+
129+ d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{})
130+ require.NoError(t, err)
131+ require.Len(t, d.Diff.Files, 1)
132+ assert.Equal(t, domain.OmitLarge, d.Diff.Files[0].Omitted)
133+ assert.Empty(t, d.Diff.Files[0].Hunks)
134+
135+ d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 1, Full: true}})
136+ require.NoError(t, err)
137+ require.Len(t, d.Diff.Files, 1)
138+ f := d.Diff.Files[0]
139+ assert.Equal(t, domain.OmitNone, f.Omitted)
140+ require.NotEmpty(t, f.Hunks)
141+ assert.Equal(t, 3000, f.Additions)
142+
143+ _, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}})
144+ require.ErrorIs(t, err, domain.ErrInvalid)
145+}
146+
147+func TestBrowseGetCommitWindowCollapsesGenerated(t *testing.T) {
148+ r := newMRRig(t)
149+ r.seed()
150+ r.commit(author, "go.sum", "h1:x\n", "sum")
151+ id := r.head()
152+ r.push("main")
153+ b := usecase.NewBrowse(r.store, r.git)
154+ rev := domain.RevisionOf(id)
155+
156+ d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{})
157+ require.NoError(t, err)
158+ require.Len(t, d.Diff.Files, 1)
159+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "API reads keep generated files")
160+
161+ d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 25}})
162+ require.NoError(t, err)
163+ require.Len(t, d.Diff.Files, 1)
164+ assert.Equal(t, domain.OmitGenerated, d.Diff.Files[0].Omitted, "web windows collapse them")
165+ assert.Empty(t, d.Diff.Files[0].Hunks)
166+}
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/usecase"
16+)
17+
18+func TestWindowLimits(t *testing.T) {
19+ def := domain.DefaultDiffLimits()
20+ win := def
21+ win.MaxFiles, win.From, win.To, win.SkipGenerated = 0, 2, 5, true
22+ open := win
23+ open.From, open.To = 7, 0
24+
25+ tests := []struct {
26+ name string
27+ w *ports.FileWindow
28+ want domain.DiffLimits
29+ bad bool
30+ }{
31+ {name: "nil is the default", want: def},
32+ {name: "a window", w: &ports.FileWindow{From: 2, To: 5}, want: win},
33+ {name: "an open window", w: &ports.FileWindow{From: 7}, want: open},
34+ {name: "full", w: &ports.FileWindow{From: 3, To: 4, Full: true}, want: domain.OneFileDiffLimits(3)},
35+ {name: "negative from", w: &ports.FileWindow{From: -1}, bad: true},
36+ {name: "negative to", w: &ports.FileWindow{To: -1}, bad: true},
37+ {name: "to before from", w: &ports.FileWindow{From: 5, To: 3}, bad: true},
38+ {name: "full without to", w: &ports.FileWindow{From: 3, Full: true}, bad: true},
39+ {name: "full of two files", w: &ports.FileWindow{From: 3, To: 5, Full: true}, bad: true},
40+ {name: "full of no file", w: &ports.FileWindow{From: 3, To: 3, Full: true}, bad: true},
41+ }
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ got, err := usecase.WindowLimits(tt.w)
45+ if tt.bad {
46+ require.ErrorIs(t, err, domain.ErrInvalid)
47+ return
48+ }
49+ require.NoError(t, err)
50+ assert.Equal(t, tt.want, got)
51+ })
52+ }
53+}
54+
55+func TestGetMergeRequestFullAndGenerated(t *testing.T) {
56+ r := newMRRig(t)
57+ r.seed()
58+ r.sh("checkout", "-q", "-B", "feat", "main")
59+ r.commit(author, "a.txt", "one\n", "a")
60+ r.commit(author, "go.sum", "x y z\n", "sum")
61+ r.push("feat")
62+ n := r.open("feat").Number
63+
64+ t.Run("full with a wider window is invalid", func(t *testing.T) {
65+ _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}})
66+ require.ErrorIs(t, err, domain.ErrInvalid)
67+ })
68+
69+ t.Run("a windowed read collapses a generated file, a nil read does not", func(t *testing.T) {
70+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 10}})
71+ require.NoError(t, err)
72+ byPath := map[string]domain.FileDiff{}
73+ for _, f := range d.Diff.Files {
74+ byPath[f.NewPath] = f
75+ }
76+ assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted)
77+ assert.Empty(t, byPath["go.sum"].Hunks)
78+ assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted)
79+ assert.NotEmpty(t, byPath["a.txt"].Hunks)
80+
81+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{})
82+ require.NoError(t, err)
83+ for _, f := range d.Diff.Files {
84+ assert.Equal(t, domain.OmitNone, f.Omitted, f.NewPath)
85+ assert.NotEmpty(t, f.Hunks, f.NewPath)
86+ }
87+ })
88+}
89+
90+func bigContent(n int) string {
91+ var b strings.Builder
92+ for i := range n {
93+ fmt.Fprintf(&b, "line %d\n", i)
94+ }
95+ return b.String()
96+}
97+
98+func TestCheckLineBeyondFileLimit(t *testing.T) {
99+ r := newMRRig(t)
100+ r.seed()
101+ r.sh("checkout", "-q", "-B", "feat", "main")
102+ r.commit(author, "big.txt", bigContent(3000), "big")
103+ r.commit(author, "go.sum", "h1:x\nh1:y\n", "sum")
104+ r.push("feat")
105+ n := r.open("feat").Number
106+
107+ add := func(path string, line int) error {
108+ _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: path, Line: line, Side: domain.SideNew, Text: "c"})
109+ return err
110+ }
111+ require.NoError(t, add("big.txt", 1))
112+ require.NoError(t, add("big.txt", 2500))
113+ require.NoError(t, add("big.txt", 3000))
114+ require.ErrorIs(t, add("big.txt", 3001), domain.ErrInvalid)
115+ // A generated file is collapsed in the web windows, but loadable: its
116+ // lines can be commented on.
117+ require.NoError(t, add("go.sum", 2))
118+}
119+
120+func TestBrowseGetCommitFullWindow(t *testing.T) {
121+ r := newMRRig(t)
122+ r.seed()
123+ r.commit(author, "a-big.txt", bigContent(3000), "big")
124+ id := r.head()
125+ r.push("main")
126+ b := usecase.NewBrowse(r.store, r.git)
127+ rev := domain.RevisionOf(id)
128+
129+ d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{})
130+ require.NoError(t, err)
131+ require.Len(t, d.Diff.Files, 1)
132+ assert.Equal(t, domain.OmitLarge, d.Diff.Files[0].Omitted)
133+ assert.Empty(t, d.Diff.Files[0].Hunks)
134+
135+ d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 1, Full: true}})
136+ require.NoError(t, err)
137+ require.Len(t, d.Diff.Files, 1)
138+ f := d.Diff.Files[0]
139+ assert.Equal(t, domain.OmitNone, f.Omitted)
140+ require.NotEmpty(t, f.Hunks)
141+ assert.Equal(t, 3000, f.Additions)
142+
143+ _, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}})
144+ require.ErrorIs(t, err, domain.ErrInvalid)
145+}
146+
147+func TestBrowseGetCommitWindowCollapsesGenerated(t *testing.T) {
148+ r := newMRRig(t)
149+ r.seed()
150+ r.commit(author, "go.sum", "h1:x\n", "sum")
151+ id := r.head()
152+ r.push("main")
153+ b := usecase.NewBrowse(r.store, r.git)
154+ rev := domain.RevisionOf(id)
155+
156+ d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{})
157+ require.NoError(t, err)
158+ require.Len(t, d.Diff.Files, 1)
159+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "API reads keep generated files")
160+
161+ d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 25}})
162+ require.NoError(t, err)
163+ require.Len(t, d.Diff.Files, 1)
164+ assert.Equal(t, domain.OmitGenerated, d.Diff.Files[0].Omitted, "web windows collapse them")
165+ assert.Empty(t, d.Diff.Files[0].Hunks)
166+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close