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.
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
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 }
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.
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
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+}
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 }
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 })
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 }},
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
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 }
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
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+}
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 != "" {
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 {
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
M internal/adapters/web/diff_templ.go +235 -173
@@ -1423,31 +1423,62 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14231423 if templ_7745c5c3_Err != nil {
14241424 return templ_7745c5c3_Err
14251425 }
1426+ } else if f.Manual {
1427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, " <div class=\"dlazy\" data-src=\"")
1428+ if templ_7745c5c3_Err != nil {
1429+ return templ_7745c5c3_Err
1430+ }
1431+ var templ_7745c5c3_Var75 string
1432+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src)
1433+ if templ_7745c5c3_Err != nil {
1434+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 250, Col: 38}
1435+ }
1436+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1437+ if templ_7745c5c3_Err != nil {
1438+ return templ_7745c5c3_Err
1439+ }
1440+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" data-manual><a href=\"")
1441+ if templ_7745c5c3_Err != nil {
1442+ return templ_7745c5c3_Err
1443+ }
1444+ var templ_7745c5c3_Var76 templ.SafeURL
1445+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Href))
1446+ if templ_7745c5c3_Err != nil {
1447+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 251, Col: 35}
1448+ }
1449+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1450+ if templ_7745c5c3_Err != nil {
1451+ return templ_7745c5c3_Err
1452+ }
1453+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\">load diff</a></div>")
1454+ if templ_7745c5c3_Err != nil {
1455+ return templ_7745c5c3_Err
1456+ }
14261457 } else if len(f.Hunks) > 0 {
1427- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<div class=\"dscroll\">")
1458+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<div class=\"dscroll\">")
14281459 if templ_7745c5c3_Err != nil {
14291460 return templ_7745c5c3_Err
14301461 }
14311462 if p.ShowUnified {
1432- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<table class=\"dt uni\" aria-label=\"")
1463+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<table class=\"dt uni\" aria-label=\"")
14331464 if templ_7745c5c3_Err != nil {
14341465 return templ_7745c5c3_Err
14351466 }
1436- var templ_7745c5c3_Var75 string
1437- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
1467+ var templ_7745c5c3_Var77 string
1468+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
14381469 if templ_7745c5c3_Err != nil {
1439- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 249, Col: 60}
1470+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 256, Col: 60}
14401471 }
1441- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1472+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
14421473 if templ_7745c5c3_Err != nil {
14431474 return templ_7745c5c3_Err
14441475 }
1445- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\">")
1476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "\">")
14461477 if templ_7745c5c3_Err != nil {
14471478 return templ_7745c5c3_Err
14481479 }
14491480 for _, h := range f.Hunks {
1450- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<tbody>")
1481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tbody>")
14511482 if templ_7745c5c3_Err != nil {
14521483 return templ_7745c5c3_Err
14531484 }
@@ -1457,43 +1488,43 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14571488 return templ_7745c5c3_Err
14581489 }
14591490 }
1460- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<tr class=\"hunk\"><td colspan=\"4\">")
1491+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "<tr class=\"hunk\"><td colspan=\"4\">")
14611492 if templ_7745c5c3_Err != nil {
14621493 return templ_7745c5c3_Err
14631494 }
1464- var templ_7745c5c3_Var76 string
1465- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1495+ var templ_7745c5c3_Var78 string
1496+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
14661497 if templ_7745c5c3_Err != nil {
1467- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 255, Col: 51}
1498+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 262, Col: 51}
14681499 }
1469- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1500+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
14701501 if templ_7745c5c3_Err != nil {
14711502 return templ_7745c5c3_Err
14721503 }
1473- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</td></tr>")
1504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td></tr>")
14741505 if templ_7745c5c3_Err != nil {
14751506 return templ_7745c5c3_Err
14761507 }
14771508 for _, ln := range h.Lines {
1478- var templ_7745c5c3_Var77 = []any{ln.Kind, templ.KV("sel", ln.Selected)}
1479- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var77...)
1509+ var templ_7745c5c3_Var79 = []any{ln.Kind, templ.KV("sel", ln.Selected)}
1510+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...)
14801511 if templ_7745c5c3_Err != nil {
14811512 return templ_7745c5c3_Err
14821513 }
1483- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tr class=\"")
1514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "<tr class=\"")
14841515 if templ_7745c5c3_Err != nil {
14851516 return templ_7745c5c3_Err
14861517 }
1487- var templ_7745c5c3_Var78 string
1488- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var77).String())
1518+ var templ_7745c5c3_Var80 string
1519+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var79).String())
14891520 if templ_7745c5c3_Err != nil {
14901521 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
14911522 }
1492- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
1523+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
14931524 if templ_7745c5c3_Err != nil {
14941525 return templ_7745c5c3_Err
14951526 }
1496- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\"><td class=\"ln\">")
1527+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\"><td class=\"ln\">")
14971528 if templ_7745c5c3_Err != nil {
14981529 return templ_7745c5c3_Err
14991530 }
@@ -1501,7 +1532,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15011532 if templ_7745c5c3_Err != nil {
15021533 return templ_7745c5c3_Err
15031534 }
1504- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"ln\">")
1535+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td><td class=\"ln\">")
15051536 if templ_7745c5c3_Err != nil {
15061537 return templ_7745c5c3_Err
15071538 }
@@ -1509,20 +1540,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15091540 if templ_7745c5c3_Err != nil {
15101541 return templ_7745c5c3_Err
15111542 }
1512- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"m\">")
1543+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td><td class=\"m\">")
15131544 if templ_7745c5c3_Err != nil {
15141545 return templ_7745c5c3_Err
15151546 }
1516- var templ_7745c5c3_Var79 string
1517- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1547+ var templ_7745c5c3_Var81 string
1548+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
15181549 if templ_7745c5c3_Err != nil {
1519- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 260, Col: 33}
1550+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 267, Col: 33}
15201551 }
1521- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))
1552+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
15221553 if templ_7745c5c3_Err != nil {
15231554 return templ_7745c5c3_Err
15241555 }
1525- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td><td class=\"tx chroma\">")
1556+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</td><td class=\"tx chroma\">")
15261557 if templ_7745c5c3_Err != nil {
15271558 return templ_7745c5c3_Err
15281559 }
@@ -1530,12 +1561,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15301561 if templ_7745c5c3_Err != nil {
15311562 return templ_7745c5c3_Err
15321563 }
1533- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td></tr>")
1564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "</td></tr>")
15341565 if templ_7745c5c3_Err != nil {
15351566 return templ_7745c5c3_Err
15361567 }
15371568 if len(ln.Notes) > 0 || ln.Form != nil {
1538- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
1569+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
15391570 if templ_7745c5c3_Err != nil {
15401571 return templ_7745c5c3_Err
15411572 }
@@ -1543,19 +1574,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15431574 if templ_7745c5c3_Err != nil {
15441575 return templ_7745c5c3_Err
15451576 }
1546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</td></tr>")
1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</td></tr>")
15471578 if templ_7745c5c3_Err != nil {
15481579 return templ_7745c5c3_Err
15491580 }
15501581 }
15511582 }
1552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "</tbody> ")
1583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</tbody> ")
15531584 if templ_7745c5c3_Err != nil {
15541585 return templ_7745c5c3_Err
15551586 }
15561587 }
15571588 if f.Tail != nil {
1558- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "<tbody>")
1589+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<tbody>")
15591590 if templ_7745c5c3_Err != nil {
15601591 return templ_7745c5c3_Err
15611592 }
@@ -1563,36 +1594,36 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15631594 if templ_7745c5c3_Err != nil {
15641595 return templ_7745c5c3_Err
15651596 }
1566- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</tbody>")
1597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</tbody>")
15671598 if templ_7745c5c3_Err != nil {
15681599 return templ_7745c5c3_Err
15691600 }
15701601 }
1571- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</table>")
1602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "</table>")
15721603 if templ_7745c5c3_Err != nil {
15731604 return templ_7745c5c3_Err
15741605 }
15751606 }
15761607 if p.ShowSplit {
1577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<table class=\"dt spl\" aria-label=\"")
1608+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<table class=\"dt spl\" aria-label=\"")
15781609 if templ_7745c5c3_Err != nil {
15791610 return templ_7745c5c3_Err
15801611 }
1581- var templ_7745c5c3_Var80 string
1582- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
1612+ var templ_7745c5c3_Var82 string
1613+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
15831614 if templ_7745c5c3_Err != nil {
1584- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 280, Col: 58}
1615+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 287, Col: 58}
15851616 }
1586- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1617+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
15871618 if templ_7745c5c3_Err != nil {
15881619 return templ_7745c5c3_Err
15891620 }
1590- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
1621+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
15911622 if templ_7745c5c3_Err != nil {
15921623 return templ_7745c5c3_Err
15931624 }
15941625 for _, h := range f.Hunks {
1595- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<tbody>")
1626+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<tbody>")
15961627 if templ_7745c5c3_Err != nil {
15971628 return templ_7745c5c3_Err
15981629 }
@@ -1602,25 +1633,25 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16021633 return templ_7745c5c3_Err
16031634 }
16041635 }
1605- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<tr class=\"hunk\"><td colspan=\"6\">")
1636+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<tr class=\"hunk\"><td colspan=\"6\">")
16061637 if templ_7745c5c3_Err != nil {
16071638 return templ_7745c5c3_Err
16081639 }
1609- var templ_7745c5c3_Var81 string
1610- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1640+ var templ_7745c5c3_Var83 string
1641+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
16111642 if templ_7745c5c3_Err != nil {
1612- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 287, Col: 51}
1643+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 294, Col: 51}
16131644 }
1614- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1645+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
16151646 if templ_7745c5c3_Err != nil {
16161647 return templ_7745c5c3_Err
16171648 }
1618- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</td></tr>")
1649+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</td></tr>")
16191650 if templ_7745c5c3_Err != nil {
16201651 return templ_7745c5c3_Err
16211652 }
16221653 for _, row := range h.Split {
1623- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<tr>")
1654+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<tr>")
16241655 if templ_7745c5c3_Err != nil {
16251656 return templ_7745c5c3_Err
16261657 }
@@ -1632,12 +1663,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16321663 if templ_7745c5c3_Err != nil {
16331664 return templ_7745c5c3_Err
16341665 }
1635- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</tr>")
1666+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</tr>")
16361667 if templ_7745c5c3_Err != nil {
16371668 return templ_7745c5c3_Err
16381669 }
16391670 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
1640- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<tr class=\"fnote\"><td colspan=\"6\">")
1671+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<tr class=\"fnote\"><td colspan=\"6\">")
16411672 if templ_7745c5c3_Err != nil {
16421673 return templ_7745c5c3_Err
16431674 }
@@ -1645,19 +1676,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16451676 if templ_7745c5c3_Err != nil {
16461677 return templ_7745c5c3_Err
16471678 }
1648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</td></tr>")
1679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</td></tr>")
16491680 if templ_7745c5c3_Err != nil {
16501681 return templ_7745c5c3_Err
16511682 }
16521683 }
16531684 }
1654- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</tbody> ")
1685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</tbody> ")
16551686 if templ_7745c5c3_Err != nil {
16561687 return templ_7745c5c3_Err
16571688 }
16581689 }
16591690 if f.Tail != nil {
1660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<tbody>")
1691+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<tbody>")
16611692 if templ_7745c5c3_Err != nil {
16621693 return templ_7745c5c3_Err
16631694 }
@@ -1665,48 +1696,79 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16651696 if templ_7745c5c3_Err != nil {
16661697 return templ_7745c5c3_Err
16671698 }
1668- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</tbody>")
1699+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</tbody>")
16691700 if templ_7745c5c3_Err != nil {
16701701 return templ_7745c5c3_Err
16711702 }
16721703 }
1673- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</table>")
1704+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</table>")
16741705 if templ_7745c5c3_Err != nil {
16751706 return templ_7745c5c3_Err
16761707 }
16771708 }
1678- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</div>")
1709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "</div>")
1710+ if templ_7745c5c3_Err != nil {
1711+ return templ_7745c5c3_Err
1712+ }
1713+ } else if f.Raw != "" {
1714+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "<p class=\"note pad\">")
1715+ if templ_7745c5c3_Err != nil {
1716+ return templ_7745c5c3_Err
1717+ }
1718+ var templ_7745c5c3_Var84 string
1719+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1720+ if templ_7745c5c3_Err != nil {
1721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 317, Col: 31}
1722+ }
1723+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
1724+ if templ_7745c5c3_Err != nil {
1725+ return templ_7745c5c3_Err
1726+ }
1727+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, " <a href=\"")
1728+ if templ_7745c5c3_Err != nil {
1729+ return templ_7745c5c3_Err
1730+ }
1731+ var templ_7745c5c3_Var85 templ.SafeURL
1732+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Raw))
1733+ if templ_7745c5c3_Err != nil {
1734+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 317, Col: 64}
1735+ }
1736+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
1737+ if templ_7745c5c3_Err != nil {
1738+ return templ_7745c5c3_Err
1739+ }
1740+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "\">view raw</a></p>")
16791741 if templ_7745c5c3_Err != nil {
16801742 return templ_7745c5c3_Err
16811743 }
16821744 } else {
1683- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<p class=\"note pad\">")
1745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<p class=\"note pad\">")
16841746 if templ_7745c5c3_Err != nil {
16851747 return templ_7745c5c3_Err
16861748 }
16871749 if f.Note != "" {
1688- var templ_7745c5c3_Var82 string
1689- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1750+ var templ_7745c5c3_Var86 string
1751+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
16901752 if templ_7745c5c3_Err != nil {
1691- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 312, Col: 13}
1753+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 321, Col: 13}
16921754 }
1693- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82))
1755+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
16941756 if templ_7745c5c3_Err != nil {
16951757 return templ_7745c5c3_Err
16961758 }
16971759 } else {
1698- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "no textual change.")
1760+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "no textual change.")
16991761 if templ_7745c5c3_Err != nil {
17001762 return templ_7745c5c3_Err
17011763 }
17021764 }
1703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "</p>")
1765+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</p>")
17041766 if templ_7745c5c3_Err != nil {
17051767 return templ_7745c5c3_Err
17061768 }
17071769 }
17081770 if len(f.Loose) > 0 {
1709- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "<div class=\"dloose\">")
1771+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "<div class=\"dloose\">")
17101772 if templ_7745c5c3_Err != nil {
17111773 return templ_7745c5c3_Err
17121774 }
@@ -1716,12 +1778,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
17161778 return templ_7745c5c3_Err
17171779 }
17181780 }
1719- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</div>")
1781+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</div>")
17201782 if templ_7745c5c3_Err != nil {
17211783 return templ_7745c5c3_Err
17221784 }
17231785 }
1724- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</details>")
1786+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</details>")
17251787 if templ_7745c5c3_Err != nil {
17261788 return templ_7745c5c3_Err
17271789 }
@@ -1747,9 +1809,9 @@ func MRFiles(p MRFilesPage) templ.Component {
17471809 }()
17481810 }
17491811 ctx = templ.InitializeContext(ctx)
1750- templ_7745c5c3_Var83 := templ.GetChildren(ctx)
1751- if templ_7745c5c3_Var83 == nil {
1752- templ_7745c5c3_Var83 = templ.NopComponent
1812+ templ_7745c5c3_Var87 := templ.GetChildren(ctx)
1813+ if templ_7745c5c3_Var87 == nil {
1814+ templ_7745c5c3_Var87 = templ.NopComponent
17531815 }
17541816 ctx = templ.ClearChildren(ctx)
17551817 for _, f := range p.Files {
@@ -1780,12 +1842,12 @@ func MRFilesMoved() templ.Component {
17801842 }()
17811843 }
17821844 ctx = templ.InitializeContext(ctx)
1783- templ_7745c5c3_Var84 := templ.GetChildren(ctx)
1784- if templ_7745c5c3_Var84 == nil {
1785- templ_7745c5c3_Var84 = templ.NopComponent
1845+ templ_7745c5c3_Var88 := templ.GetChildren(ctx)
1846+ if templ_7745c5c3_Var88 == nil {
1847+ templ_7745c5c3_Var88 = templ.NopComponent
17861848 }
17871849 ctx = templ.ClearChildren(ctx)
1788- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
1850+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
17891851 if templ_7745c5c3_Err != nil {
17901852 return templ_7745c5c3_Err
17911853 }
@@ -1812,64 +1874,64 @@ func gapRow(g DiffGap, cols int) templ.Component {
18121874 }()
18131875 }
18141876 ctx = templ.InitializeContext(ctx)
1815- templ_7745c5c3_Var85 := templ.GetChildren(ctx)
1816- if templ_7745c5c3_Var85 == nil {
1817- templ_7745c5c3_Var85 = templ.NopComponent
1877+ templ_7745c5c3_Var89 := templ.GetChildren(ctx)
1878+ if templ_7745c5c3_Var89 == nil {
1879+ templ_7745c5c3_Var89 = templ.NopComponent
18181880 }
18191881 ctx = templ.ClearChildren(ctx)
1820- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<tr class=\"gap\" data-js data-gap=\"")
1882+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<tr class=\"gap\" data-js data-gap=\"")
18211883 if templ_7745c5c3_Err != nil {
18221884 return templ_7745c5c3_Err
18231885 }
1824- var templ_7745c5c3_Var86 string
1825- templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
1886+ var templ_7745c5c3_Var90 string
1887+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
18261888 if templ_7745c5c3_Err != nil {
1827- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 346, Col: 41}
1889+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 355, Col: 41}
18281890 }
1829- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)
1891+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
18301892 if templ_7745c5c3_Err != nil {
18311893 return templ_7745c5c3_Err
18321894 }
1833- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"><td colspan=\"")
1895+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "\"><td colspan=\"")
18341896 if templ_7745c5c3_Err != nil {
18351897 return templ_7745c5c3_Err
18361898 }
1837- var templ_7745c5c3_Var87 string
1838- templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
1899+ var templ_7745c5c3_Var91 string
1900+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
18391901 if templ_7745c5c3_Err != nil {
1840- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 347, Col: 26}
1902+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 356, Col: 26}
18411903 }
1842- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)
1904+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
18431905 if templ_7745c5c3_Err != nil {
18441906 return templ_7745c5c3_Err
18451907 }
1846- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"><button type=\"button\" data-src=\"")
1908+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\"><button type=\"button\" data-src=\"")
18471909 if templ_7745c5c3_Err != nil {
18481910 return templ_7745c5c3_Err
18491911 }
1850- var templ_7745c5c3_Var88 string
1851- templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
1912+ var templ_7745c5c3_Var92 string
1913+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
18521914 if templ_7745c5c3_Err != nil {
1853- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 348, Col: 41}
1915+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 357, Col: 41}
18541916 }
1855- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
1917+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
18561918 if templ_7745c5c3_Err != nil {
18571919 return templ_7745c5c3_Err
18581920 }
1859- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "\"><span aria-hidden=\"true\">⋯</span> ")
1921+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "\"><span aria-hidden=\"true\">⋯</span> ")
18601922 if templ_7745c5c3_Err != nil {
18611923 return templ_7745c5c3_Err
18621924 }
1863- var templ_7745c5c3_Var89 string
1864- templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
1925+ var templ_7745c5c3_Var93 string
1926+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
18651927 if templ_7745c5c3_Err != nil {
1866- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 349, Col: 51}
1928+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 358, Col: 51}
18671929 }
1868- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
1930+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
18691931 if templ_7745c5c3_Err != nil {
18701932 return templ_7745c5c3_Err
18711933 }
1872- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</button></td></tr>")
1934+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</button></td></tr>")
18731935 if templ_7745c5c3_Err != nil {
18741936 return templ_7745c5c3_Err
18751937 }
@@ -1895,56 +1957,56 @@ func DiffContext(c ContextRows) templ.Component {
18951957 }()
18961958 }
18971959 ctx = templ.InitializeContext(ctx)
1898- templ_7745c5c3_Var90 := templ.GetChildren(ctx)
1899- if templ_7745c5c3_Var90 == nil {
1900- templ_7745c5c3_Var90 = templ.NopComponent
1960+ templ_7745c5c3_Var94 := templ.GetChildren(ctx)
1961+ if templ_7745c5c3_Var94 == nil {
1962+ templ_7745c5c3_Var94 = templ.NopComponent
19011963 }
19021964 ctx = templ.ClearChildren(ctx)
1903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<table><tbody data-for=\"uni\">")
1965+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<table><tbody data-for=\"uni\">")
19041966 if templ_7745c5c3_Err != nil {
19051967 return templ_7745c5c3_Err
19061968 }
19071969 for _, ln := range c.Lines {
1908- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<tr class=\"ctx\"><td class=\"ln\">")
1970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<tr class=\"ctx\"><td class=\"ln\">")
19091971 if templ_7745c5c3_Err != nil {
19101972 return templ_7745c5c3_Err
19111973 }
1912- var templ_7745c5c3_Var91 string
1913- templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
1974+ var templ_7745c5c3_Var95 string
1975+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
19141976 if templ_7745c5c3_Err != nil {
1915- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 362, Col: 28}
1977+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 371, Col: 28}
19161978 }
1917- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
1979+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
19181980 if templ_7745c5c3_Err != nil {
19191981 return templ_7745c5c3_Err
19201982 }
1921- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "</td><td class=\"ln\">")
1983+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</td><td class=\"ln\">")
19221984 if templ_7745c5c3_Err != nil {
19231985 return templ_7745c5c3_Err
19241986 }
1925- var templ_7745c5c3_Var92 string
1926- templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
1987+ var templ_7745c5c3_Var96 string
1988+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
19271989 if templ_7745c5c3_Err != nil {
1928- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 363, Col: 28}
1990+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 372, Col: 28}
19291991 }
1930- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
1992+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
19311993 if templ_7745c5c3_Err != nil {
19321994 return templ_7745c5c3_Err
19331995 }
1934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</td><td class=\"m\">")
1996+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</td><td class=\"m\">")
19351997 if templ_7745c5c3_Err != nil {
19361998 return templ_7745c5c3_Err
19371999 }
1938- var templ_7745c5c3_Var93 string
1939- templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
2000+ var templ_7745c5c3_Var97 string
2001+ templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
19402002 if templ_7745c5c3_Err != nil {
1941- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 364, Col: 28}
2003+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 373, Col: 28}
19422004 }
1943- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
2005+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
19442006 if templ_7745c5c3_Err != nil {
19452007 return templ_7745c5c3_Err
19462008 }
1947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td><td class=\"tx chroma\">")
2009+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</td><td class=\"tx chroma\">")
19482010 if templ_7745c5c3_Err != nil {
19492011 return templ_7745c5c3_Err
19502012 }
@@ -1952,7 +2014,7 @@ func DiffContext(c ContextRows) templ.Component {
19522014 if templ_7745c5c3_Err != nil {
19532015 return templ_7745c5c3_Err
19542016 }
1955- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</td></tr>")
2017+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</td></tr>")
19562018 if templ_7745c5c3_Err != nil {
19572019 return templ_7745c5c3_Err
19582020 }
@@ -1963,12 +2025,12 @@ func DiffContext(c ContextRows) templ.Component {
19632025 return templ_7745c5c3_Err
19642026 }
19652027 }
1966- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "</tbody> <tbody data-for=\"spl\">")
2028+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "</tbody> <tbody data-for=\"spl\">")
19672029 if templ_7745c5c3_Err != nil {
19682030 return templ_7745c5c3_Err
19692031 }
19702032 for i := range c.Lines {
1971- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<tr>")
2033+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<tr>")
19722034 if templ_7745c5c3_Err != nil {
19732035 return templ_7745c5c3_Err
19742036 }
@@ -1980,7 +2042,7 @@ func DiffContext(c ContextRows) templ.Component {
19802042 if templ_7745c5c3_Err != nil {
19812043 return templ_7745c5c3_Err
19822044 }
1983- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</tr>")
2045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</tr>")
19842046 if templ_7745c5c3_Err != nil {
19852047 return templ_7745c5c3_Err
19862048 }
@@ -1991,7 +2053,7 @@ func DiffContext(c ContextRows) templ.Component {
19912053 return templ_7745c5c3_Err
19922054 }
19932055 }
1994- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</tbody></table>")
2056+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</tbody></table>")
19952057 if templ_7745c5c3_Err != nil {
19962058 return templ_7745c5c3_Err
19972059 }
@@ -2017,36 +2079,36 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20172079 }()
20182080 }
20192081 ctx = templ.InitializeContext(ctx)
2020- templ_7745c5c3_Var94 := templ.GetChildren(ctx)
2021- if templ_7745c5c3_Var94 == nil {
2022- templ_7745c5c3_Var94 = templ.NopComponent
2082+ templ_7745c5c3_Var98 := templ.GetChildren(ctx)
2083+ if templ_7745c5c3_Var98 == nil {
2084+ templ_7745c5c3_Var98 = templ.NopComponent
20232085 }
20242086 ctx = templ.ClearChildren(ctx)
20252087 if ln == nil {
2026- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
2088+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
20272089 if templ_7745c5c3_Err != nil {
20282090 return templ_7745c5c3_Err
20292091 }
20302092 } else {
2031- var templ_7745c5c3_Var95 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}
2032- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var95...)
2093+ var templ_7745c5c3_Var99 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}
2094+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var99...)
20332095 if templ_7745c5c3_Err != nil {
20342096 return templ_7745c5c3_Err
20352097 }
2036- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "<td class=\"")
2098+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<td class=\"")
20372099 if templ_7745c5c3_Err != nil {
20382100 return templ_7745c5c3_Err
20392101 }
2040- var templ_7745c5c3_Var96 string
2041- templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var95).String())
2102+ var templ_7745c5c3_Var100 string
2103+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var99).String())
20422104 if templ_7745c5c3_Err != nil {
20432105 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
20442106 }
2045- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var96)
2107+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var100)
20462108 if templ_7745c5c3_Err != nil {
20472109 return templ_7745c5c3_Err
20482110 }
2049- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "\">")
2111+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\">")
20502112 if templ_7745c5c3_Err != nil {
20512113 return templ_7745c5c3_Err
20522114 }
@@ -2061,64 +2123,64 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20612123 return templ_7745c5c3_Err
20622124 }
20632125 }
2064- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</td>")
2126+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "</td>")
20652127 if templ_7745c5c3_Err != nil {
20662128 return templ_7745c5c3_Err
20672129 }
2068- var templ_7745c5c3_Var97 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}
2069- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var97...)
2130+ var templ_7745c5c3_Var101 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}
2131+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var101...)
20702132 if templ_7745c5c3_Err != nil {
20712133 return templ_7745c5c3_Err
20722134 }
2073- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "<td class=\"")
2135+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "<td class=\"")
20742136 if templ_7745c5c3_Err != nil {
20752137 return templ_7745c5c3_Err
20762138 }
2077- var templ_7745c5c3_Var98 string
2078- templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var97).String())
2139+ var templ_7745c5c3_Var102 string
2140+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var101).String())
20792141 if templ_7745c5c3_Err != nil {
20802142 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
20812143 }
2082- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var98)
2144+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var102)
20832145 if templ_7745c5c3_Err != nil {
20842146 return templ_7745c5c3_Err
20852147 }
2086- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "\">")
2148+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\">")
20872149 if templ_7745c5c3_Err != nil {
20882150 return templ_7745c5c3_Err
20892151 }
2090- var templ_7745c5c3_Var99 string
2091- templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
2152+ var templ_7745c5c3_Var103 string
2153+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
20922154 if templ_7745c5c3_Err != nil {
2093- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 401, Col: 68}
2155+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 68}
20942156 }
2095- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
2157+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
20962158 if templ_7745c5c3_Err != nil {
20972159 return templ_7745c5c3_Err
20982160 }
2099- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "</td>")
2161+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</td>")
21002162 if templ_7745c5c3_Err != nil {
21012163 return templ_7745c5c3_Err
21022164 }
2103- var templ_7745c5c3_Var100 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}
2104- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var100...)
2165+ var templ_7745c5c3_Var104 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}
2166+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var104...)
21052167 if templ_7745c5c3_Err != nil {
21062168 return templ_7745c5c3_Err
21072169 }
2108- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<td class=\"")
2170+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<td class=\"")
21092171 if templ_7745c5c3_Err != nil {
21102172 return templ_7745c5c3_Err
21112173 }
2112- var templ_7745c5c3_Var101 string
2113- templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var100).String())
2174+ var templ_7745c5c3_Var105 string
2175+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var104).String())
21142176 if templ_7745c5c3_Err != nil {
21152177 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
21162178 }
2117- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var101)
2179+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
21182180 if templ_7745c5c3_Err != nil {
21192181 return templ_7745c5c3_Err
21202182 }
2121- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\">")
2183+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "\">")
21222184 if templ_7745c5c3_Err != nil {
21232185 return templ_7745c5c3_Err
21242186 }
@@ -2126,7 +2188,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
21262188 if templ_7745c5c3_Err != nil {
21272189 return templ_7745c5c3_Err
21282190 }
2129- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "</td>")
2191+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "</td>")
21302192 if templ_7745c5c3_Err != nil {
21312193 return templ_7745c5c3_Err
21322194 }
@@ -2153,49 +2215,49 @@ func gutter(n, href string) templ.Component {
21532215 }()
21542216 }
21552217 ctx = templ.InitializeContext(ctx)
2156- templ_7745c5c3_Var102 := templ.GetChildren(ctx)
2157- if templ_7745c5c3_Var102 == nil {
2158- templ_7745c5c3_Var102 = templ.NopComponent
2218+ templ_7745c5c3_Var106 := templ.GetChildren(ctx)
2219+ if templ_7745c5c3_Var106 == nil {
2220+ templ_7745c5c3_Var106 = templ.NopComponent
21592221 }
21602222 ctx = templ.ClearChildren(ctx)
21612223 if href != "" {
2162- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "<a href=\"")
2224+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "<a href=\"")
21632225 if templ_7745c5c3_Err != nil {
21642226 return templ_7745c5c3_Err
21652227 }
2166- var templ_7745c5c3_Var103 templ.SafeURL
2167- templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
2228+ var templ_7745c5c3_Var107 templ.SafeURL
2229+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
21682230 if templ_7745c5c3_Err != nil {
2169- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 31}
2231+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 419, Col: 31}
21702232 }
2171- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
2233+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
21722234 if templ_7745c5c3_Err != nil {
21732235 return templ_7745c5c3_Err
21742236 }
2175- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\" title=\"comment on this line\">")
2237+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "\" title=\"comment on this line\">")
21762238 if templ_7745c5c3_Err != nil {
21772239 return templ_7745c5c3_Err
21782240 }
2179- var templ_7745c5c3_Var104 string
2180- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2241+ var templ_7745c5c3_Var108 string
2242+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(n)
21812243 if templ_7745c5c3_Err != nil {
2182- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 66}
2244+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 419, Col: 66}
21832245 }
2184- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
2246+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
21852247 if templ_7745c5c3_Err != nil {
21862248 return templ_7745c5c3_Err
21872249 }
2188- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</a>")
2250+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "</a>")
21892251 if templ_7745c5c3_Err != nil {
21902252 return templ_7745c5c3_Err
21912253 }
21922254 } else {
2193- var templ_7745c5c3_Var105 string
2194- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2255+ var templ_7745c5c3_Var109 string
2256+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(n)
21952257 if templ_7745c5c3_Err != nil {
2196- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 412, Col: 5}
2258+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 421, Col: 5}
21972259 }
2198- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2260+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
21992261 if templ_7745c5c3_Err != nil {
22002262 return templ_7745c5c3_Err
22012263 }
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+}
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+});
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
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
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 }
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 }
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 {
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();
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));
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).
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))
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),
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+}
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>
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>
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>
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>
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>
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>
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>
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>
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) {
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+}
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 }
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+}
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+}
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+}
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+}
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 }}},
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 {
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 }
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)
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},
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+}
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)
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 })
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")
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
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
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
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)
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+}

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