detached 973e682

Merge 6ad86d639f8695132aa03137abafc9c8b6bc378d into cc7ba64ee52412f41c3ce5207bac7e47b9f01f30

commit
973e682cc746c67a975414cd8c08a3f3f43f7f08
author
ermite · · 2026-10-20 08:25 UTC
merge of
cc7ba64 + 6ad86d6 · diff against the first parent
signature
unsigned
61 files · +2219 -664
M docs/merge-requests.md +8 -0
@@ -63,6 +63,14 @@ In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `c
6363
6464 In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.
6565
66+## Large diffs on the web
67+
68+The merge request, commit and compare pages list every changed file. Generated files (lockfiles, `vendor/`, `node_modules/`, minified or generated code) and large files (over 2,000 changed lines) are collapsed: the header and the counts show, the lines do not. "load diff" fetches that one file in place, in full, and you can comment on its lines. Without scripts the link reloads the page with that file loaded. After the first 25 files of a merge request, the other files load as you scroll.
69+
70+A commit page reads the hunks of its first 300 files with the page; the other files are listed collapsed and load one at a time. A file that cannot be shown in the browser, over 8 MiB of text or over 50,000 changed lines when loaded alone, has no "load diff": the page links to its raw blob instead, and its lines take no comment.
71+
72+The API and `mr show --diff --json` keep their limits (300 files, 2,000 changed lines per file, 20,000 in total, 8 MiB). A file read without its hunks has `truncated` set and `omitted` giving the reason: `large`, `budget` (the earlier files used the limits) or `unavailable` (over 8 MiB of text, or a line over 64 KiB, even alone). Files past the 300th are not listed and `diff_truncated` is set.
73+
6674 ## Notes
6775
6876 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
@@ -63,6 +63,14 @@ In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `c
63 63
64 In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.64 In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.
65 65
66+## Large diffs on the web
67+
68+The merge request, commit and compare pages list every changed file. Generated files (lockfiles, `vendor/`, `node_modules/`, minified or generated code) and large files (over 2,000 changed lines) are collapsed: the header and the counts show, the lines do not. "load diff" fetches that one file in place, in full, and you can comment on its lines. Without scripts the link reloads the page with that file loaded. After the first 25 files of a merge request, the other files load as you scroll.
69+
70+A commit page reads the hunks of its first 300 files with the page; the other files are listed collapsed and load one at a time. A file that cannot be shown in the browser, over 8 MiB of text or over 50,000 changed lines when loaded alone, has no "load diff": the page links to its raw blob instead, and its lines take no comment.
71+
72+The API and `mr show --diff --json` keep their limits (300 files, 2,000 changed lines per file, 20,000 in total, 8 MiB). A file read without its hunks has `truncated` set and `omitted` giving the reason: `large`, `budget` (the earlier files used the limits) or `unavailable` (over 8 MiB of text, or a line over 64 KiB, even alone). Files past the 300th are not listed and `diff_truncated` is set.
73+
66 ## Notes74 ## Notes
67 75
68 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.76 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.10.0
16+ bnema.dev/ermite-api v0.11.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.10.016+ bnema.dev/ermite-api v0.11.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/gitcli/diff.go +38 -28
@@ -115,7 +115,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
115115 // rename, mode change) have a section without hunks.
116116 lastRead := -1
117117 for i := from; i < to; i++ {
118- if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) {
118+ if read, _ := budget.Admit(entries[i].file.Binary, entries[i].file.CommentPath(), entries[i].added+entries[i].del); read {
119119 lastRead = i
120120 }
121121 }
@@ -134,19 +134,24 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
134134 sections = 2
135135 }
136136 inWindow := i >= from && i < to
137+ read, why := false, domain.OmitNone
138+ if inWindow {
139+ read, why = pr.budget.Admit(e.file.Binary, e.file.CommentPath(), e.added+e.del)
140+ }
137141 switch {
138- case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del):
142+ case !inWindow:
139143 // Nothing to show.
140- case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del):
141- e.file.Truncated = true
144+ case !read:
145+ e.file.Omitted = why
142146 default:
143147 before := pr.budget
144- hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines())
148+ pr.budget.StartFile()
149+ hunks, _, why, err := pr.file(sections, pr.budget.FileLines())
145150 if err != nil {
146151 return domain.Diff{}, domain.DiffStats{}, err
147152 }
148- e.file.Hunks, e.file.Truncated = hunks, truncated
149- if truncated {
153+ e.file.Hunks, e.file.Omitted = hunks, why
154+ if why != domain.OmitNone {
150155 pr.budget = before // nothing of this file is kept
151156 }
152157 sections = 0
@@ -156,7 +161,6 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
156161 return domain.Diff{}, domain.DiffStats{}, err
157162 }
158163 }
159- diff.Truncated = diff.Truncated || e.file.Truncated
160164 diff.Files = append(diff.Files, e.file)
161165 }
162166 if lastRead < len(entries)-1 {
@@ -379,55 +383,56 @@ func (p *patchReader) skip(n int) error {
379383 }
380384
381385 // 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) {
386+// changed lines, and why it was omitted: it exceeded max changed lines or the
387+// budget, or had a line over maxDiffLine. When omitted, the rest of the file is
388+// skipped.
389+func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
385390 for done := range sections {
386391 ok, err := p.enterSection()
387392 if err != nil {
388- return nil, 0, false, err
393+ return nil, 0, domain.OmitNone, err
389394 }
390395 if !ok {
391396 break
392397 }
393- h, n, trunc, err := p.section(max - lines)
398+ h, n, why, err := p.section(max - lines)
394399 if err != nil {
395- return nil, 0, false, err
400+ return nil, 0, domain.OmitNone, err
396401 }
397402 hunks = append(hunks, h...)
398403 lines += n
399- if trunc {
404+ if why != domain.OmitNone {
400405 // Leave the stream at the start of the next file.
401- return nil, lines, true, p.skip(sections - done - 1)
406+ return nil, lines, why, p.skip(sections - done - 1)
402407 }
403408 }
404- return hunks, lines, false, nil
409+ return hunks, lines, domain.OmitNone, nil
405410 }
406411
407412 // 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) {
413+// stays pending. It gives up (with a reason) after more than max changed lines.
414+func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
410415 var cur *domain.Hunk
411416 var oldN, newN int
412417 for {
413418 line, ok, err := p.line()
414419 if err != nil {
415- return nil, 0, false, err
420+ return nil, 0, domain.OmitNone, err
416421 }
417422 if !ok {
418- return hunks, lines, false, nil
423+ return hunks, lines, domain.OmitNone, nil
419424 }
420425 if bytes.HasPrefix(line, []byte(sectionMarker)) {
421426 p.unread(line)
422- return hunks, lines, false, nil
427+ return hunks, lines, domain.OmitNone, nil
423428 }
424429 if p.cut {
425- return nil, lines, true, nil
430+ return nil, lines, domain.OmitUnavailable, nil
426431 }
427432 if bytes.HasPrefix(line, []byte("@@ ")) {
428433 h, err := parseHunkHeader(string(line))
429434 if err != nil {
430- return nil, 0, false, err
435+ return nil, 0, domain.OmitNone, err
431436 }
432437 hunks = append(hunks, h)
433438 cur = &hunks[len(hunks)-1]
@@ -459,10 +464,15 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
459464 default:
460465 continue
461466 }
462- lines++
463- fits := p.budget.ChargeLine(len(text))
464- if lines > max || !fits {
465- return nil, lines, true, nil
467+ changed := l.Kind == domain.LineAddition || l.Kind == domain.LineDeletion
468+ if changed {
469+ lines++
470+ }
471+ if why := p.budget.ChargeLine(len(text), changed); why != domain.OmitNone {
472+ return nil, lines, why, nil
473+ }
474+ if lines > max {
475+ return nil, lines, domain.OmitLarge, nil
466476 }
467477 cur.Lines = append(cur.Lines, l)
468478 }
@@ -115,7 +115,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
115 // rename, mode change) have a section without hunks.115 // rename, mode change) have a section without hunks.
116 lastRead := -1116 lastRead := -1
117 for i := from; i < to; i++ {117 for i := from; i < to; i++ {
118- if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) {118+ if read, _ := budget.Admit(entries[i].file.Binary, entries[i].file.CommentPath(), entries[i].added+entries[i].del); read {
119 lastRead = i119 lastRead = i
120 }120 }
121 }121 }
@@ -134,19 +134,24 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
134 sections = 2134 sections = 2
135 }135 }
136 inWindow := i >= from && i < to136 inWindow := i >= from && i < to
137+ read, why := false, domain.OmitNone
138+ if inWindow {
139+ read, why = pr.budget.Admit(e.file.Binary, e.file.CommentPath(), e.added+e.del)
140+ }
137 switch {141 switch {
138- case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del):142+ case !inWindow:
139 // Nothing to show.143 // Nothing to show.
140- case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del):144+ case !read:
141- e.file.Truncated = true145+ e.file.Omitted = why
142 default:146 default:
143 before := pr.budget147 before := pr.budget
144- hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines())148+ pr.budget.StartFile()
149+ hunks, _, why, err := pr.file(sections, pr.budget.FileLines())
145 if err != nil {150 if err != nil {
146 return domain.Diff{}, domain.DiffStats{}, err151 return domain.Diff{}, domain.DiffStats{}, err
147 }152 }
148- e.file.Hunks, e.file.Truncated = hunks, truncated153+ e.file.Hunks, e.file.Omitted = hunks, why
149- if truncated {154+ if why != domain.OmitNone {
150 pr.budget = before // nothing of this file is kept155 pr.budget = before // nothing of this file is kept
151 }156 }
152 sections = 0157 sections = 0
@@ -156,7 +161,6 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
156 return domain.Diff{}, domain.DiffStats{}, err161 return domain.Diff{}, domain.DiffStats{}, err
157 }162 }
158 }163 }
159- diff.Truncated = diff.Truncated || e.file.Truncated
160 diff.Files = append(diff.Files, e.file)164 diff.Files = append(diff.Files, e.file)
161 }165 }
162 if lastRead < len(entries)-1 {166 if lastRead < len(entries)-1 {
@@ -379,55 +383,56 @@ func (p *patchReader) skip(n int) error {
379 }383 }
380 384
381 // file reads the sections of one file and returns its hunks, the number of385 // 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 over386+// changed lines, and why it was omitted: it exceeded max changed lines or the
383-// maxDiffLine. When truncated, the rest of the file is skipped.387+// budget, or had a line over maxDiffLine. When omitted, the rest of the file is
384-func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {388+// skipped.
389+func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
385 for done := range sections {390 for done := range sections {
386 ok, err := p.enterSection()391 ok, err := p.enterSection()
387 if err != nil {392 if err != nil {
388- return nil, 0, false, err393+ return nil, 0, domain.OmitNone, err
389 }394 }
390 if !ok {395 if !ok {
391 break396 break
392 }397 }
393- h, n, trunc, err := p.section(max - lines)398+ h, n, why, err := p.section(max - lines)
394 if err != nil {399 if err != nil {
395- return nil, 0, false, err400+ return nil, 0, domain.OmitNone, err
396 }401 }
397 hunks = append(hunks, h...)402 hunks = append(hunks, h...)
398 lines += n403 lines += n
399- if trunc {404+ if why != domain.OmitNone {
400 // Leave the stream at the start of the next file.405 // Leave the stream at the start of the next file.
401- return nil, lines, true, p.skip(sections - done - 1)406+ return nil, lines, why, p.skip(sections - done - 1)
402 }407 }
403 }408 }
404- return hunks, lines, false, nil409+ return hunks, lines, domain.OmitNone, nil
405 }410 }
406 411
407 // section reads the hunks of the current section up to the next marker, which412 // 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.413+// stays pending. It gives up (with a reason) after more than max changed lines.
409-func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {414+func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
410 var cur *domain.Hunk415 var cur *domain.Hunk
411 var oldN, newN int416 var oldN, newN int
412 for {417 for {
413 line, ok, err := p.line()418 line, ok, err := p.line()
414 if err != nil {419 if err != nil {
415- return nil, 0, false, err420+ return nil, 0, domain.OmitNone, err
416 }421 }
417 if !ok {422 if !ok {
418- return hunks, lines, false, nil423+ return hunks, lines, domain.OmitNone, nil
419 }424 }
420 if bytes.HasPrefix(line, []byte(sectionMarker)) {425 if bytes.HasPrefix(line, []byte(sectionMarker)) {
421 p.unread(line)426 p.unread(line)
422- return hunks, lines, false, nil427+ return hunks, lines, domain.OmitNone, nil
423 }428 }
424 if p.cut {429 if p.cut {
425- return nil, lines, true, nil430+ return nil, lines, domain.OmitUnavailable, nil
426 }431 }
427 if bytes.HasPrefix(line, []byte("@@ ")) {432 if bytes.HasPrefix(line, []byte("@@ ")) {
428 h, err := parseHunkHeader(string(line))433 h, err := parseHunkHeader(string(line))
429 if err != nil {434 if err != nil {
430- return nil, 0, false, err435+ return nil, 0, domain.OmitNone, err
431 }436 }
432 hunks = append(hunks, h)437 hunks = append(hunks, h)
433 cur = &hunks[len(hunks)-1]438 cur = &hunks[len(hunks)-1]
@@ -459,10 +464,15 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
459 default:464 default:
460 continue465 continue
461 }466 }
462- lines++467+ changed := l.Kind == domain.LineAddition || l.Kind == domain.LineDeletion
463- fits := p.budget.ChargeLine(len(text))468+ if changed {
464- if lines > max || !fits {469+ lines++
465- return nil, lines, true, nil470+ }
471+ if why := p.budget.ChargeLine(len(text), changed); why != domain.OmitNone {
472+ return nil, lines, why, nil
473+ }
474+ if lines > max {
475+ return nil, lines, domain.OmitLarge, nil
466 }476 }
467 cur.Lines = append(cur.Lines, l)477 cur.Lines = append(cur.Lines, l)
468 }478 }
M internal/adapters/gitcli/diff_test.go +40 -38
@@ -177,7 +177,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
177177 assert.Equal(t, domain.FileModified, bin.Status)
178178 assert.True(t, bin.Binary)
179179 assert.Empty(t, bin.Hunks)
180- assert.False(t, bin.Truncated)
180+ assert.Equal(t, domain.OmitNone, bin.Omitted)
181181
182182 mode := files["d/f>d/f"]
183183 assert.Equal(t, domain.FileModified, mode.Status)
@@ -185,7 +185,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
185185 assert.Equal(t, uint32(0o100755), mode.NewMode)
186186 assert.Empty(t, mode.Hunks)
187187 assert.False(t, mode.Binary)
188- assert.False(t, mode.Truncated)
188+ assert.Equal(t, domain.OmitNone, mode.Omitted)
189189
190190 del := files["README.md>"]
191191 assert.Equal(t, domain.FileDeleted, del.Status)
@@ -315,22 +315,22 @@ func TestDiffLimits(t *testing.T) {
315315 require.NoError(t, err)
316316 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")
317317
318- assert.False(t, exact.Truncated)
318+ assert.Equal(t, domain.OmitNone, exact.Omitted)
319319 require.Len(t, exact.Hunks, 1)
320320 assert.Len(t, exact.Hunks[0].Lines, 2000)
321- assert.True(t, over.Truncated)
321+ assert.NotEqual(t, domain.OmitNone, over.Omitted)
322322 assert.Empty(t, over.Hunks)
323323 assert.Equal(t, 2001, over.Additions, "counts stay accurate")
324- assert.False(t, small.Truncated)
324+ assert.Equal(t, domain.OmitNone, small.Omitted)
325325 assert.Len(t, small.Hunks, 1)
326- assert.True(t, d.Diff.Truncated)
326+ assert.False(t, d.Diff.Truncated)
327327 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)
328328 })
329329
330- t.Run("context counts towards the file limit", func(t *testing.T) {
330+ t.Run("context does not count towards the file limit", func(t *testing.T) {
331331 f := readertest.NewFixture(t, data, "limit-context")
332332 f.Lines("big", "l", 3000).Commit("v1")
333- // Every 7th line changes: ~428 changed lines, but the hunks merge
333+ // Every 7th line changes: 429 additions plus 429 deletions, but the hunks merge
334334 // into one that spans the file with its context.
335335 var b strings.Builder
336336 for i := range 3000 {
@@ -346,11 +346,13 @@ func TestDiffLimits(t *testing.T) {
346346 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
347347 require.NoError(t, err)
348348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
349- assert.True(t, big.Truncated)
350- assert.Empty(t, big.Hunks)
349+ assert.Equal(t, domain.OmitNone, big.Omitted)
350+ require.Len(t, big.Hunks, 1)
351+ assert.Len(t, big.Hunks[0].Lines, 3000+429)
352+ assert.False(t, d.Diff.Truncated)
351353 assert.Equal(t, 429, big.Additions)
352354 assert.Equal(t, 429, big.Deletions)
353- assert.False(t, tiny.Truncated, "the file after a truncated one is still read")
355+ assert.Equal(t, domain.OmitNone, tiny.Omitted, "the following file still fits the changed-line budget")
354356 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))
355357 assert.Equal(t, id, d.Commit.ID.String())
356358 })
@@ -376,13 +378,13 @@ func TestDiffLimits(t *testing.T) {
376378 }
377379 assert.LessOrEqual(t, total, 20000)
378380 for i := range 10 {
379- assert.False(t, find(d.Diff, fmt.Sprintf("f%02d", i)).Truncated)
381+ assert.Equal(t, domain.OmitNone, find(d.Diff, fmt.Sprintf("f%02d", i)).Omitted)
380382 }
381- assert.True(t, find(d.Diff, "f10").Truncated, "1,900 lines do not fit in the 1,000 left")
382- assert.False(t, find(d.Diff, "g-small").Truncated)
383- assert.True(t, find(d.Diff, "h-big").Truncated)
384- assert.False(t, find(d.Diff, "i-small").Truncated)
385- assert.True(t, d.Diff.Truncated)
383+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "f10").Omitted, "1,900 lines do not fit in the 1,000 left")
384+ assert.Equal(t, domain.OmitNone, find(d.Diff, "g-small").Omitted)
385+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "h-big").Omitted)
386+ assert.Equal(t, domain.OmitNone, find(d.Diff, "i-small").Omitted)
387+ assert.False(t, d.Diff.Truncated)
386388 assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)
387389 assert.Equal(t, 14, d.Stats.FilesChanged)
388390 })
@@ -401,7 +403,7 @@ func TestDiffLimits(t *testing.T) {
401403 assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)
402404 assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)
403405 for _, file := range d.Diff.Files {
404- assert.False(t, file.Truncated)
406+ assert.Equal(t, domain.OmitNone, file.Omitted)
405407 assert.Len(t, file.Hunks, 1)
406408 }
407409 })
@@ -424,16 +426,16 @@ func TestDiffLimits(t *testing.T) {
424426 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})
425427 require.NoError(t, err)
426428 require.Len(t, d.Diff.Files, 2)
427- assert.False(t, d.Diff.Files[0].Truncated)
428- assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left")
429+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
430+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "10 lines do not fit in the 5 left")
429431 assert.Equal(t, 3, d.Stats.FilesChanged)
430432
431433 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})
432434 require.NoError(t, err)
433435 require.Len(t, d.Diff.Files, 3)
434- assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50")
436+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "30 bytes of text fit in 50")
435437 for _, file := range d.Diff.Files[1:] {
436- assert.True(t, file.Truncated, "the byte budget has 20 bytes left for 30")
438+ assert.NotEqual(t, domain.OmitNone, file.Omitted, "the byte budget has 20 bytes left for 30")
437439 assert.Empty(t, file.Hunks)
438440 }
439441 })
@@ -444,9 +446,9 @@ func TestDiffLimits(t *testing.T) {
444446 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
445447 require.NoError(t, err)
446448 long, short := find(d.Diff, "long"), find(d.Diff, "short")
447- assert.True(t, long.Truncated)
449+ assert.NotEqual(t, domain.OmitNone, long.Omitted)
448450 assert.Empty(t, long.Hunks)
449- assert.False(t, short.Truncated)
451+ assert.Equal(t, domain.OmitNone, short.Omitted)
450452 assert.Len(t, short.Hunks, 1)
451453 })
452454
@@ -458,10 +460,10 @@ func TestDiffLimits(t *testing.T) {
458460 id := f.Commit("v2")
459461 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
460462 require.NoError(t, err)
461- assert.True(t, find(d.Diff, "a-big").Truncated)
463+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "a-big").Omitted)
462464 assert.True(t, find(d.Diff, "b-bin").Binary)
463- assert.False(t, find(d.Diff, "b-bin").Truncated)
464- assert.False(t, find(d.Diff, "c-mode").Truncated)
465+ assert.Equal(t, domain.OmitNone, find(d.Diff, "b-bin").Omitted)
466+ assert.Equal(t, domain.OmitNone, find(d.Diff, "c-mode").Omitted)
465467 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)
466468 // What is kept matches git.
467469 sections := showSections(f, id)
@@ -480,8 +482,8 @@ func TestGetCommitStopsGitEarly(t *testing.T) {
480482 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
481483 require.NoError(t, err)
482484 require.Len(t, d.Diff.Files, 2)
483- assert.False(t, d.Diff.Files[0].Truncated)
484- assert.True(t, d.Diff.Files[1].Truncated)
485+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
486+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted)
485487 assert.Equal(t, 100005, d.Stats.Additions)
486488 }
487489
@@ -580,23 +582,23 @@ func TestDiffLimitsWindow(t *testing.T) {
580582 for i, file := range d.Diff.Files {
581583 switch i {
582584 case 3:
583- assert.False(t, file.Truncated)
585+ assert.Equal(t, domain.OmitNone, file.Omitted)
584586 assert.Len(t, file.Hunks, 1, "10 lines fit in 15")
585587 case 4:
586- assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left")
588+ assert.NotEqual(t, domain.OmitNone, file.Omitted, "the second file of the window does not fit in the 5 lines left")
587589 default:
588- assert.False(t, file.Truncated)
590+ assert.Equal(t, domain.OmitNone, file.Omitted)
589591 assert.Empty(t, file.Hunks)
590592 assert.Equal(t, 10, file.Additions, "counts stay accurate")
591593 }
592594 }
593- assert.True(t, d.Diff.Truncated, "a file of the window is truncated")
595+ assert.False(t, d.Diff.Truncated, "an omitted file does not set Diff.Truncated")
594596
595597 limits.From, limits.To = 0, 0
596598 limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file
597599 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
598600 require.NoError(t, err)
599- assert.True(t, d.Diff.Files[0].Truncated)
601+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[0].Omitted)
600602 })
601603
602604 t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {
@@ -608,14 +610,14 @@ func TestDiffLimitsWindow(t *testing.T) {
608610 require.NoError(t, err)
609611 require.Len(t, d.Diff.Files, 2)
610612 assert.Len(t, d.Diff.Files[0].Hunks, 1)
611- assert.False(t, d.Diff.Files[1].Truncated)
613+ assert.Equal(t, domain.OmitNone, d.Diff.Files[1].Omitted)
612614 assert.Empty(t, d.Diff.Files[1].Hunks)
613615 assert.False(t, d.Diff.Truncated)
614616 limits.From, limits.To = 1, 2
615617 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
616618 require.NoError(t, err)
617- assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit")
618- assert.True(t, d.Diff.Truncated)
619+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "in the window, over the per-file limit")
620+ assert.False(t, d.Diff.Truncated)
619621 })
620622
621623 t.Run("MaxFiles 0 lists every file", func(t *testing.T) {
@@ -650,7 +652,7 @@ func TestDiffLimitsWindow(t *testing.T) {
650652 } else {
651653 assert.Empty(t, file.Hunks)
652654 }
653- assert.False(t, file.Truncated)
655+ assert.Equal(t, domain.OmitNone, file.Omitted)
654656 }
655657
656658 // The default limits still cut at 300.
@@ -177,7 +177,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
177 assert.Equal(t, domain.FileModified, bin.Status)177 assert.Equal(t, domain.FileModified, bin.Status)
178 assert.True(t, bin.Binary)178 assert.True(t, bin.Binary)
179 assert.Empty(t, bin.Hunks)179 assert.Empty(t, bin.Hunks)
180- assert.False(t, bin.Truncated)180+ assert.Equal(t, domain.OmitNone, bin.Omitted)
181 181
182 mode := files["d/f>d/f"]182 mode := files["d/f>d/f"]
183 assert.Equal(t, domain.FileModified, mode.Status)183 assert.Equal(t, domain.FileModified, mode.Status)
@@ -185,7 +185,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
185 assert.Equal(t, uint32(0o100755), mode.NewMode)185 assert.Equal(t, uint32(0o100755), mode.NewMode)
186 assert.Empty(t, mode.Hunks)186 assert.Empty(t, mode.Hunks)
187 assert.False(t, mode.Binary)187 assert.False(t, mode.Binary)
188- assert.False(t, mode.Truncated)188+ assert.Equal(t, domain.OmitNone, mode.Omitted)
189 189
190 del := files["README.md>"]190 del := files["README.md>"]
191 assert.Equal(t, domain.FileDeleted, del.Status)191 assert.Equal(t, domain.FileDeleted, del.Status)
@@ -315,22 +315,22 @@ func TestDiffLimits(t *testing.T) {
315 require.NoError(t, err)315 require.NoError(t, err)
316 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")316 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")
317 317
318- assert.False(t, exact.Truncated)318+ assert.Equal(t, domain.OmitNone, exact.Omitted)
319 require.Len(t, exact.Hunks, 1)319 require.Len(t, exact.Hunks, 1)
320 assert.Len(t, exact.Hunks[0].Lines, 2000)320 assert.Len(t, exact.Hunks[0].Lines, 2000)
321- assert.True(t, over.Truncated)321+ assert.NotEqual(t, domain.OmitNone, over.Omitted)
322 assert.Empty(t, over.Hunks)322 assert.Empty(t, over.Hunks)
323 assert.Equal(t, 2001, over.Additions, "counts stay accurate")323 assert.Equal(t, 2001, over.Additions, "counts stay accurate")
324- assert.False(t, small.Truncated)324+ assert.Equal(t, domain.OmitNone, small.Omitted)
325 assert.Len(t, small.Hunks, 1)325 assert.Len(t, small.Hunks, 1)
326- assert.True(t, d.Diff.Truncated)326+ assert.False(t, d.Diff.Truncated)
327 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)327 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)
328 })328 })
329 329
330- t.Run("context counts towards the file limit", func(t *testing.T) {330+ t.Run("context does not count towards the file limit", func(t *testing.T) {
331 f := readertest.NewFixture(t, data, "limit-context")331 f := readertest.NewFixture(t, data, "limit-context")
332 f.Lines("big", "l", 3000).Commit("v1")332 f.Lines("big", "l", 3000).Commit("v1")
333- // Every 7th line changes: ~428 changed lines, but the hunks merge333+ // Every 7th line changes: 429 additions plus 429 deletions, but the hunks merge
334 // into one that spans the file with its context.334 // into one that spans the file with its context.
335 var b strings.Builder335 var b strings.Builder
336 for i := range 3000 {336 for i := range 3000 {
@@ -346,11 +346,13 @@ func TestDiffLimits(t *testing.T) {
346 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)346 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
347 require.NoError(t, err)347 require.NoError(t, err)
348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
349- assert.True(t, big.Truncated)349+ assert.Equal(t, domain.OmitNone, big.Omitted)
350- assert.Empty(t, big.Hunks)350+ require.Len(t, big.Hunks, 1)
351+ assert.Len(t, big.Hunks[0].Lines, 3000+429)
352+ assert.False(t, d.Diff.Truncated)
351 assert.Equal(t, 429, big.Additions)353 assert.Equal(t, 429, big.Additions)
352 assert.Equal(t, 429, big.Deletions)354 assert.Equal(t, 429, big.Deletions)
353- assert.False(t, tiny.Truncated, "the file after a truncated one is still read")355+ assert.Equal(t, domain.OmitNone, tiny.Omitted, "the following file still fits the changed-line budget")
354 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))356 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))
355 assert.Equal(t, id, d.Commit.ID.String())357 assert.Equal(t, id, d.Commit.ID.String())
356 })358 })
@@ -376,13 +378,13 @@ func TestDiffLimits(t *testing.T) {
376 }378 }
377 assert.LessOrEqual(t, total, 20000)379 assert.LessOrEqual(t, total, 20000)
378 for i := range 10 {380 for i := range 10 {
379- assert.False(t, find(d.Diff, fmt.Sprintf("f%02d", i)).Truncated)381+ assert.Equal(t, domain.OmitNone, find(d.Diff, fmt.Sprintf("f%02d", i)).Omitted)
380 }382 }
381- assert.True(t, find(d.Diff, "f10").Truncated, "1,900 lines do not fit in the 1,000 left")383+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "f10").Omitted, "1,900 lines do not fit in the 1,000 left")
382- assert.False(t, find(d.Diff, "g-small").Truncated)384+ assert.Equal(t, domain.OmitNone, find(d.Diff, "g-small").Omitted)
383- assert.True(t, find(d.Diff, "h-big").Truncated)385+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "h-big").Omitted)
384- assert.False(t, find(d.Diff, "i-small").Truncated)386+ assert.Equal(t, domain.OmitNone, find(d.Diff, "i-small").Omitted)
385- assert.True(t, d.Diff.Truncated)387+ assert.False(t, d.Diff.Truncated)
386 assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)388 assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)
387 assert.Equal(t, 14, d.Stats.FilesChanged)389 assert.Equal(t, 14, d.Stats.FilesChanged)
388 })390 })
@@ -401,7 +403,7 @@ func TestDiffLimits(t *testing.T) {
401 assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)403 assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)
402 assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)404 assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)
403 for _, file := range d.Diff.Files {405 for _, file := range d.Diff.Files {
404- assert.False(t, file.Truncated)406+ assert.Equal(t, domain.OmitNone, file.Omitted)
405 assert.Len(t, file.Hunks, 1)407 assert.Len(t, file.Hunks, 1)
406 }408 }
407 })409 })
@@ -424,16 +426,16 @@ func TestDiffLimits(t *testing.T) {
424 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})426 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})
425 require.NoError(t, err)427 require.NoError(t, err)
426 require.Len(t, d.Diff.Files, 2)428 require.Len(t, d.Diff.Files, 2)
427- assert.False(t, d.Diff.Files[0].Truncated)429+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
428- assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left")430+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "10 lines do not fit in the 5 left")
429 assert.Equal(t, 3, d.Stats.FilesChanged)431 assert.Equal(t, 3, d.Stats.FilesChanged)
430 432
431 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})433 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})
432 require.NoError(t, err)434 require.NoError(t, err)
433 require.Len(t, d.Diff.Files, 3)435 require.Len(t, d.Diff.Files, 3)
434- assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50")436+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "30 bytes of text fit in 50")
435 for _, file := range d.Diff.Files[1:] {437 for _, file := range d.Diff.Files[1:] {
436- assert.True(t, file.Truncated, "the byte budget has 20 bytes left for 30")438+ assert.NotEqual(t, domain.OmitNone, file.Omitted, "the byte budget has 20 bytes left for 30")
437 assert.Empty(t, file.Hunks)439 assert.Empty(t, file.Hunks)
438 }440 }
439 })441 })
@@ -444,9 +446,9 @@ func TestDiffLimits(t *testing.T) {
444 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)446 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
445 require.NoError(t, err)447 require.NoError(t, err)
446 long, short := find(d.Diff, "long"), find(d.Diff, "short")448 long, short := find(d.Diff, "long"), find(d.Diff, "short")
447- assert.True(t, long.Truncated)449+ assert.NotEqual(t, domain.OmitNone, long.Omitted)
448 assert.Empty(t, long.Hunks)450 assert.Empty(t, long.Hunks)
449- assert.False(t, short.Truncated)451+ assert.Equal(t, domain.OmitNone, short.Omitted)
450 assert.Len(t, short.Hunks, 1)452 assert.Len(t, short.Hunks, 1)
451 })453 })
452 454
@@ -458,10 +460,10 @@ func TestDiffLimits(t *testing.T) {
458 id := f.Commit("v2")460 id := f.Commit("v2")
459 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)461 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
460 require.NoError(t, err)462 require.NoError(t, err)
461- assert.True(t, find(d.Diff, "a-big").Truncated)463+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "a-big").Omitted)
462 assert.True(t, find(d.Diff, "b-bin").Binary)464 assert.True(t, find(d.Diff, "b-bin").Binary)
463- assert.False(t, find(d.Diff, "b-bin").Truncated)465+ assert.Equal(t, domain.OmitNone, find(d.Diff, "b-bin").Omitted)
464- assert.False(t, find(d.Diff, "c-mode").Truncated)466+ assert.Equal(t, domain.OmitNone, find(d.Diff, "c-mode").Omitted)
465 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)467 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)
466 // What is kept matches git.468 // What is kept matches git.
467 sections := showSections(f, id)469 sections := showSections(f, id)
@@ -480,8 +482,8 @@ func TestGetCommitStopsGitEarly(t *testing.T) {
480 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())482 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
481 require.NoError(t, err)483 require.NoError(t, err)
482 require.Len(t, d.Diff.Files, 2)484 require.Len(t, d.Diff.Files, 2)
483- assert.False(t, d.Diff.Files[0].Truncated)485+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
484- assert.True(t, d.Diff.Files[1].Truncated)486+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted)
485 assert.Equal(t, 100005, d.Stats.Additions)487 assert.Equal(t, 100005, d.Stats.Additions)
486 }488 }
487 489
@@ -580,23 +582,23 @@ func TestDiffLimitsWindow(t *testing.T) {
580 for i, file := range d.Diff.Files {582 for i, file := range d.Diff.Files {
581 switch i {583 switch i {
582 case 3:584 case 3:
583- assert.False(t, file.Truncated)585+ assert.Equal(t, domain.OmitNone, file.Omitted)
584 assert.Len(t, file.Hunks, 1, "10 lines fit in 15")586 assert.Len(t, file.Hunks, 1, "10 lines fit in 15")
585 case 4:587 case 4:
586- assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left")588+ assert.NotEqual(t, domain.OmitNone, file.Omitted, "the second file of the window does not fit in the 5 lines left")
587 default:589 default:
588- assert.False(t, file.Truncated)590+ assert.Equal(t, domain.OmitNone, file.Omitted)
589 assert.Empty(t, file.Hunks)591 assert.Empty(t, file.Hunks)
590 assert.Equal(t, 10, file.Additions, "counts stay accurate")592 assert.Equal(t, 10, file.Additions, "counts stay accurate")
591 }593 }
592 }594 }
593- assert.True(t, d.Diff.Truncated, "a file of the window is truncated")595+ assert.False(t, d.Diff.Truncated, "an omitted file does not set Diff.Truncated")
594 596
595 limits.From, limits.To = 0, 0597 limits.From, limits.To = 0, 0
596 limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file598 limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file
597 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)599 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
598 require.NoError(t, err)600 require.NoError(t, err)
599- assert.True(t, d.Diff.Files[0].Truncated)601+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[0].Omitted)
600 })602 })
601 603
602 t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {604 t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {
@@ -608,14 +610,14 @@ func TestDiffLimitsWindow(t *testing.T) {
608 require.NoError(t, err)610 require.NoError(t, err)
609 require.Len(t, d.Diff.Files, 2)611 require.Len(t, d.Diff.Files, 2)
610 assert.Len(t, d.Diff.Files[0].Hunks, 1)612 assert.Len(t, d.Diff.Files[0].Hunks, 1)
611- assert.False(t, d.Diff.Files[1].Truncated)613+ assert.Equal(t, domain.OmitNone, d.Diff.Files[1].Omitted)
612 assert.Empty(t, d.Diff.Files[1].Hunks)614 assert.Empty(t, d.Diff.Files[1].Hunks)
613 assert.False(t, d.Diff.Truncated)615 assert.False(t, d.Diff.Truncated)
614 limits.From, limits.To = 1, 2616 limits.From, limits.To = 1, 2
615 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)617 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
616 require.NoError(t, err)618 require.NoError(t, err)
617- assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit")619+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "in the window, over the per-file limit")
618- assert.True(t, d.Diff.Truncated)620+ assert.False(t, d.Diff.Truncated)
619 })621 })
620 622
621 t.Run("MaxFiles 0 lists every file", func(t *testing.T) {623 t.Run("MaxFiles 0 lists every file", func(t *testing.T) {
@@ -650,7 +652,7 @@ func TestDiffLimitsWindow(t *testing.T) {
650 } else {652 } else {
651 assert.Empty(t, file.Hunks)653 assert.Empty(t, file.Hunks)
652 }654 }
653- assert.False(t, file.Truncated)655+ assert.Equal(t, domain.OmitNone, file.Omitted)
654 }656 }
655 657
656 // The default limits still cut at 300.658 // The default limits still cut at 300.
M internal/adapters/rpc/convert.go +17 -1
@@ -394,7 +394,8 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
394394 Additions: int32(f.Additions),
395395 Deletions: int32(f.Deletions),
396396 Similarity: int32(f.Similarity),
397- Truncated: f.Truncated,
397+ Truncated: f.Omitted != domain.OmitNone,
398+ Omitted: fileOmissionToPB(f.Omitted),
398399 }
399400 for _, h := range f.Hunks {
400401 out.Hunks = append(out.Hunks, hunkToPB(h))
@@ -402,6 +403,20 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
402403 return out
403404 }
404405
406+func fileOmissionToPB(o domain.FileOmission) pb.FileOmission {
407+ switch o {
408+ case domain.OmitGenerated:
409+ return pb.FileOmission_FILE_OMISSION_GENERATED
410+ case domain.OmitLarge:
411+ return pb.FileOmission_FILE_OMISSION_LARGE
412+ case domain.OmitBudget:
413+ return pb.FileOmission_FILE_OMISSION_BUDGET
414+ case domain.OmitUnavailable:
415+ return pb.FileOmission_FILE_OMISSION_UNAVAILABLE
416+ }
417+ return pb.FileOmission_FILE_OMISSION_UNSPECIFIED
418+}
419+
405420 func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
406421 return &pb.DiffStats{
407422 FilesChanged: int32(s.FilesChanged),
@@ -413,6 +428,7 @@ func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
413428 func diffToPB(d domain.Diff) *pb.Diff {
414429 out := &pb.Diff{Truncated: d.Truncated}
415430 for _, f := range d.Files {
431+ out.Truncated = out.Truncated || f.Omitted != domain.OmitNone
416432 out.Files = append(out.Files, fileDiffToPB(f))
417433 }
418434 return out
@@ -394,7 +394,8 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
394 Additions: int32(f.Additions),394 Additions: int32(f.Additions),
395 Deletions: int32(f.Deletions),395 Deletions: int32(f.Deletions),
396 Similarity: int32(f.Similarity),396 Similarity: int32(f.Similarity),
397- Truncated: f.Truncated,397+ Truncated: f.Omitted != domain.OmitNone,
398+ Omitted: fileOmissionToPB(f.Omitted),
398 }399 }
399 for _, h := range f.Hunks {400 for _, h := range f.Hunks {
400 out.Hunks = append(out.Hunks, hunkToPB(h))401 out.Hunks = append(out.Hunks, hunkToPB(h))
@@ -402,6 +403,20 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
402 return out403 return out
403 }404 }
404 405
406+func fileOmissionToPB(o domain.FileOmission) pb.FileOmission {
407+ switch o {
408+ case domain.OmitGenerated:
409+ return pb.FileOmission_FILE_OMISSION_GENERATED
410+ case domain.OmitLarge:
411+ return pb.FileOmission_FILE_OMISSION_LARGE
412+ case domain.OmitBudget:
413+ return pb.FileOmission_FILE_OMISSION_BUDGET
414+ case domain.OmitUnavailable:
415+ return pb.FileOmission_FILE_OMISSION_UNAVAILABLE
416+ }
417+ return pb.FileOmission_FILE_OMISSION_UNSPECIFIED
418+}
419+
405 func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {420 func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
406 return &pb.DiffStats{421 return &pb.DiffStats{
407 FilesChanged: int32(s.FilesChanged),422 FilesChanged: int32(s.FilesChanged),
@@ -413,6 +428,7 @@ func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
413 func diffToPB(d domain.Diff) *pb.Diff {428 func diffToPB(d domain.Diff) *pb.Diff {
414 out := &pb.Diff{Truncated: d.Truncated}429 out := &pb.Diff{Truncated: d.Truncated}
415 for _, f := range d.Files {430 for _, f := range d.Files {
431+ out.Truncated = out.Truncated || f.Omitted != domain.OmitNone
416 out.Files = append(out.Files, fileDiffToPB(f))432 out.Files = append(out.Files, fileDiffToPB(f))
417 }433 }
418 return out434 return out
M internal/adapters/rpc/convert_test.go +18 -0
@@ -398,3 +398,21 @@ func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) {
398398 assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())
399399 }
400400 }
401+
402+func TestFileDiffOmissionMapping(t *testing.T) {
403+ for o, want := range map[domain.FileOmission]pb.FileOmission{
404+ domain.OmitNone: pb.FileOmission_FILE_OMISSION_UNSPECIFIED,
405+ domain.OmitGenerated: pb.FileOmission_FILE_OMISSION_GENERATED,
406+ domain.OmitLarge: pb.FileOmission_FILE_OMISSION_LARGE,
407+ domain.OmitBudget: pb.FileOmission_FILE_OMISSION_BUDGET,
408+ domain.OmitUnavailable: pb.FileOmission_FILE_OMISSION_UNAVAILABLE,
409+ } {
410+ f := fileDiffToPB(domain.FileDiff{NewPath: "a", Omitted: o})
411+ assert.Equal(t, want, f.GetOmitted())
412+ assert.Equal(t, o != domain.OmitNone, f.GetTruncated())
413+ d := diffToPB(domain.Diff{Files: []domain.FileDiff{{Omitted: o}}})
414+ assert.Equal(t, o != domain.OmitNone, d.GetTruncated())
415+ }
416+ assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated())
417+ assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated())
418+}
@@ -398,3 +398,21 @@ func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) {
398 assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())398 assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())
399 }399 }
400 }400 }
401+
402+func TestFileDiffOmissionMapping(t *testing.T) {
403+ for o, want := range map[domain.FileOmission]pb.FileOmission{
404+ domain.OmitNone: pb.FileOmission_FILE_OMISSION_UNSPECIFIED,
405+ domain.OmitGenerated: pb.FileOmission_FILE_OMISSION_GENERATED,
406+ domain.OmitLarge: pb.FileOmission_FILE_OMISSION_LARGE,
407+ domain.OmitBudget: pb.FileOmission_FILE_OMISSION_BUDGET,
408+ domain.OmitUnavailable: pb.FileOmission_FILE_OMISSION_UNAVAILABLE,
409+ } {
410+ f := fileDiffToPB(domain.FileDiff{NewPath: "a", Omitted: o})
411+ assert.Equal(t, want, f.GetOmitted())
412+ assert.Equal(t, o != domain.OmitNone, f.GetTruncated())
413+ d := diffToPB(domain.Diff{Files: []domain.FileDiff{{Omitted: o}}})
414+ assert.Equal(t, o != domain.OmitNone, d.GetTruncated())
415+ }
416+ assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated())
417+ assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated())
418+}
M internal/adapters/rpc/git.go +1 -1
@@ -180,7 +180,7 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC
180180 if err != nil {
181181 return nil, connectError(ctx, err)
182182 }
183- detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)
183+ detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev, ports.GetCommitOptions{})
184184 if err != nil {
185185 return nil, connectError(ctx, err)
186186 }
@@ -180,7 +180,7 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC
180 if err != nil {180 if err != nil {
181 return nil, connectError(ctx, err)181 return nil, connectError(ctx, err)
182 }182 }
183- detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)183+ detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev, ports.GetCommitOptions{})
184 if err != nil {184 if err != nil {
185 return nil, connectError(ctx, err)185 return nil, connectError(ctx, err)
186 }186 }
M internal/adapters/rpc/git_test.go +1 -1
@@ -157,7 +157,7 @@ func TestGitServiceGetCommitAndResolveRevision(t *testing.T) {
157157 t.Run("GetCommit not found", func(t *testing.T) {
158158 t.Parallel()
159159 browse := mocks.NewBrowseService(t)
160- browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
160+ browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
161161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))
162162 requireCode(t, connect.CodeNotFound, err)
163163 })
@@ -157,7 +157,7 @@ func TestGitServiceGetCommitAndResolveRevision(t *testing.T) {
157 t.Run("GetCommit not found", func(t *testing.T) {157 t.Run("GetCommit not found", func(t *testing.T) {
158 t.Parallel()158 t.Parallel()
159 browse := mocks.NewBrowseService(t)159 browse := mocks.NewBrowseService(t)
160- browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()160+ browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))
162 requireCode(t, connect.CodeNotFound, err)162 requireCode(t, connect.CodeNotFound, err)
163 })163 })
M internal/adapters/web/assets.go +2 -1
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
2424 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2525 // the content, so they can be cached for ever.
2626 type Assets struct {
27- CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string
27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, Lazy, MR, IssueForm, PowWorker, Icon string
2828 // Manifest is the web app manifest (install on a phone's home screen),
2929 // AppleIcon the home screen icon iOS reads from a link instead.
3030 Manifest, AppleIcon string
@@ -64,6 +64,7 @@ var scripts = []struct {
6464 {"files.js", func(a *Assets, u string) { a.Files = u }},
6565 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
6666 {"log.js", func(a *Assets, u string) { a.Log = u }},
67+ {"lazy.js", func(a *Assets, u string) { a.Lazy = u }},
6768 {"mr.js", func(a *Assets, u string) { a.MR = u }},
6869 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
6970 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
24 // Assets are the URLs of the assets a page loads. Their names carry a hash of24 // Assets are the URLs of the assets a page loads. Their names carry a hash of
25 // the content, so they can be cached for ever.25 // the content, so they can be cached for ever.
26 type Assets struct {26 type Assets struct {
27- CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, Lazy, MR, IssueForm, PowWorker, Icon string
28 // Manifest is the web app manifest (install on a phone's home screen),28 // Manifest is the web app manifest (install on a phone's home screen),
29 // AppleIcon the home screen icon iOS reads from a link instead.29 // AppleIcon the home screen icon iOS reads from a link instead.
30 Manifest, AppleIcon string30 Manifest, AppleIcon string
@@ -64,6 +64,7 @@ var scripts = []struct {
64 {"files.js", func(a *Assets, u string) { a.Files = u }},64 {"files.js", func(a *Assets, u string) { a.Files = u }},
65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
66 {"log.js", func(a *Assets, u string) { a.Log = u }},66 {"log.js", func(a *Assets, u string) { a.Log = u }},
67+ {"lazy.js", func(a *Assets, u string) { a.Lazy = u }},
67 {"mr.js", func(a *Assets, u string) { a.MR = u }},68 {"mr.js", func(a *Assets, u string) { a.MR = u }},
68 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},69 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
69 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},70 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
M internal/adapters/web/browse.go +2 -2
@@ -45,12 +45,12 @@ func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path st
4545 }
4646
4747 // getCommit reads a commit with its diff.
48-func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
48+func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
4949 r, err := domain.ParseRevision(rev)
5050 if err != nil {
5151 return domain.CommitDetail{}, err
5252 }
53- return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r)
53+ return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r, opts)
5454 }
5555
5656 // openBlobAt opens the file path of the commit rev. The caller closes the
@@ -45,12 +45,12 @@ func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path st
45 }45 }
46 46
47 // getCommit reads a commit with its diff.47 // getCommit reads a commit with its diff.
48-func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) {48+func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
49 r, err := domain.ParseRevision(rev)49 r, err := domain.ParseRevision(rev)
50 if err != nil {50 if err != nil {
51 return domain.CommitDetail{}, err51 return domain.CommitDetail{}, err
52 }52 }
53- return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r)53+ return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r, opts)
54 }54 }
55 55
56 // openBlobAt opens the file path of the commit rev. The caller closes the56 // openBlobAt opens the file path of the commit rev. The caller closes the
M internal/adapters/web/commit.go +78 -15
@@ -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,83 @@ 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+// commitWindow is how many files of a commit get their hunks read with the
209+// page; the files past it are collapsed, and load on their own.
210+const commitWindow = 300
211+
212+// commitDiff reads a commit for its page (and the compare page): every file
213+// listed, the hunks of the first commitWindow files read, generated files
214+// collapsed. Files past the window are collapsed too. With ?file=i (the link
215+// of a page without scripts) file i is read in full and replaces its entry.
216+func (s *Server) commitDiff(ctx context.Context, r *http.Request, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
217+ res, err := s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: commitWindow}})
218+ if err != nil {
219+ return res, err
197220 }
198- cut := 0
199- for _, f := range d.Files {
200- if f.Truncated {
201- cut++
221+ for i := commitWindow; i < len(res.Diff.Files); i++ {
222+ if f := &res.Diff.Files[i]; !f.Binary && f.Omitted == domain.OmitNone && f.Additions+f.Deletions > 0 {
223+ f.Omitted = domain.OmitBudget
224+ }
225+ }
226+ if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() {
227+ one, err := s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
228+ if err != nil {
229+ return res, err
202230 }
231+ if i < len(one.Diff.Files) {
232+ res.Diff.Files[i] = one.Diff.Files[i]
233+ }
234+ }
235+ return res, nil
236+}
237+
238+// commitLinks are the addresses a commit page loads its collapsed files from.
239+func commitLinks(repo, rev string) fileLinks {
240+ return fileLinks{src: repoHref(repo, "commit", rev, "files"), repo: repo, rev: rev}
241+}
242+
243+// commitFiles is GET /<repo>/commit/<sha>/files?one=1&from=i[&view=&layouts=]:
244+// the one file i of the commit with its hunks, in full, as the one
245+// details.dfile the page swaps for its placeholder.
246+func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
247+ sha := r.PathValue("sha")
248+ i, err := parseOne(r)
249+ if err != nil || !isCommitID(sha) {
250+ return nil, 0, errNotFound
203251 }
204- if cut > 0 {
205- parts = append(parts, plural(cut, "file is", "files are")+" too large to show")
252+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
253+ if err != nil {
254+ return nil, 0, err
255+ }
256+ res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
257+ if err != nil {
258+ return nil, 0, err
259+ }
260+ if i >= len(res.Diff.Files) {
261+ return nil, 0, errNotFound
206262 }
207- return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."
263+ cid := res.Commit.ID.String()
264+ raw := res.Diff.Files[i : i+1]
265+ files := s.hl.files(ctx, raw)
266+ files[0].ID = "f" + itoa(i+1)
267+ contextGaps(files, raw, repoHref(d.name(), "context", cid))
268+ files[0].Raw = rawLink(raw[0], commitLinks(d.name(), cid))
269+ lines, _ := diffLines(files)
270+ return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil
208271 }
@@ -12,6 +12,7 @@ import (
12 "github.com/a-h/templ"12 "github.com/a-h/templ"
13 13
14 "bnema.dev/ermite/internal/domain"14 "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
15 )16 )
16 17
17 // bothViewsLines is the largest diff, in lines, that is sent in both the18 // bothViewsLines is the largest diff, in lines, that is sent in both the
@@ -105,7 +106,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
105 if err != nil {106 if err != nil {
106 return nil, 0, err107 return nil, 0, err
107 }108 }
108- res, err := s.getCommit(ctx, d.repo.Name, sha)109+ res, err := s.commitDiff(ctx, r, d.repo.Name, sha)
109 if err != nil {110 if err != nil {
110 return nil, 0, err111 return nil, 0, err
111 }112 }
@@ -128,6 +129,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
128 Verification: c.Verification,129 Verification: c.Verification,
129 }130 }
130 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))131 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
132+ collapseFiles(p.Files, res.Diff.Files, commitLinks(d.name(), cid))
131 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {133 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
132 p.Committer = &cm134 p.Committer = &cm
133 }135 }
@@ -145,7 +147,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
145 p.Refs = append(p.Refs, "tag: "+t.ShortName)147 p.Refs = append(p.Refs, "tag: "+t.ShortName)
146 }148 }
147 }149 }
148- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)150+ p.Note = diffNote(res.Diff)
149 p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))151 p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
150 152
151 lines, invisible := diffLines(p.Files)153 lines, invisible := diffLines(p.Files)
@@ -166,6 +168,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
166 {Label: p.Short, Current: true},168 {Label: p.Short, Current: true},
167 }169 }
168 l.HasRail = true170 l.HasRail = true
171+ l.Lazy = true
169 return Commit(l, p), http.StatusOK, nil172 return Commit(l, p), http.StatusOK, nil
170 }173 }
171 174
@@ -186,23 +189,83 @@ func commitBody(c domain.Commit) string {
186 return messageText(strings.TrimSpace(body))189 return messageText(strings.TrimSpace(body))
187 }190 }
188 191
189-// diffNote says what is missing from an incomplete diff, "" for a complete one.192+// diffNote says what is missing from an incomplete diff, "" for a complete one:
190-func diffNote(d domain.Diff, shown, total int) string {193+// files too large for the browser at all. Collapsed files say so on their own
191- if !d.Truncated {194+// line and load on their own; every changed file is listed.
195+func diffNote(d domain.Diff) string {
196+ gone := 0
197+ for _, f := range d.Files {
198+ if f.Omitted == domain.OmitUnavailable {
199+ gone++
200+ }
201+ }
202+ if gone == 0 {
192 return ""203 return ""
193 }204 }
194- var parts []string205+ return "this diff is incomplete: " + plural(gone, "file is", "files are") + " too large to show. the totals above count everything; clone the repository to read the rest."
195- if shown < total {206+}
196- parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")207+
208+// commitWindow is how many files of a commit get their hunks read with the
209+// page; the files past it are collapsed, and load on their own.
210+const commitWindow = 300
211+
212+// commitDiff reads a commit for its page (and the compare page): every file
213+// listed, the hunks of the first commitWindow files read, generated files
214+// collapsed. Files past the window are collapsed too. With ?file=i (the link
215+// of a page without scripts) file i is read in full and replaces its entry.
216+func (s *Server) commitDiff(ctx context.Context, r *http.Request, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
217+ res, err := s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: commitWindow}})
218+ if err != nil {
219+ return res, err
197 }220 }
198- cut := 0221+ for i := commitWindow; i < len(res.Diff.Files); i++ {
199- for _, f := range d.Files {222+ if f := &res.Diff.Files[i]; !f.Binary && f.Omitted == domain.OmitNone && f.Additions+f.Deletions > 0 {
200- if f.Truncated {223+ f.Omitted = domain.OmitBudget
201- cut++224+ }
225+ }
226+ if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() {
227+ one, err := s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
228+ if err != nil {
229+ return res, err
202 }230 }
231+ if i < len(one.Diff.Files) {
232+ res.Diff.Files[i] = one.Diff.Files[i]
233+ }
234+ }
235+ return res, nil
236+}
237+
238+// commitLinks are the addresses a commit page loads its collapsed files from.
239+func commitLinks(repo, rev string) fileLinks {
240+ return fileLinks{src: repoHref(repo, "commit", rev, "files"), repo: repo, rev: rev}
241+}
242+
243+// commitFiles is GET /<repo>/commit/<sha>/files?one=1&from=i[&view=&layouts=]:
244+// the one file i of the commit with its hunks, in full, as the one
245+// details.dfile the page swaps for its placeholder.
246+func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
247+ sha := r.PathValue("sha")
248+ i, err := parseOne(r)
249+ if err != nil || !isCommitID(sha) {
250+ return nil, 0, errNotFound
203 }251 }
204- if cut > 0 {252+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
205- parts = append(parts, plural(cut, "file is", "files are")+" too large to show")253+ if err != nil {
254+ return nil, 0, err
255+ }
256+ res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
257+ if err != nil {
258+ return nil, 0, err
259+ }
260+ if i >= len(res.Diff.Files) {
261+ return nil, 0, errNotFound
206 }262 }
207- return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."263+ cid := res.Commit.ID.String()
264+ raw := res.Diff.Files[i : i+1]
265+ files := s.hl.files(ctx, raw)
266+ files[0].ID = "f" + itoa(i+1)
267+ contextGaps(files, raw, repoHref(d.name(), "context", cid))
268+ files[0].Raw = rawLink(raw[0], commitLinks(d.name(), cid))
269+ lines, _ := diffLines(files)
270+ return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil
208 }271 }
M internal/adapters/web/compare.go +4 -2
@@ -76,15 +76,16 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
7676
7777 // The exact diff: one commit on top of the tip of the target.
7878 if exact && !behind && len(ahead) == 1 {
79- res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String())
79+ res, err := s.commitDiff(ctx, r, d.repo.Name, ahead[0].ID.String())
8080 if err != nil {
8181 return nil, 0, err
8282 }
8383 p.HasDiff = true
8484 p.Files = s.hl.files(ctx, res.Diff.Files)
8585 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
86+ collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String()))
8687 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
87- if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
88+ if n := diffNote(res.Diff); n != "" {
8889 p.Note = n
8990 }
9091 lines, invisible := diffLines(p.Files)
@@ -131,6 +132,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
131132 Crumb{Label: p.Source, Current: true},
132133 )
133134 l.HasRail = true
135+ l.Lazy = true
134136 return Compare(l, p), http.StatusOK, nil
135137 }
136138
@@ -76,15 +76,16 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
76 76
77 // The exact diff: one commit on top of the tip of the target.77 // The exact diff: one commit on top of the tip of the target.
78 if exact && !behind && len(ahead) == 1 {78 if exact && !behind && len(ahead) == 1 {
79- res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String())79+ res, err := s.commitDiff(ctx, r, d.repo.Name, ahead[0].ID.String())
80 if err != nil {80 if err != nil {
81 return nil, 0, err81 return nil, 0, err
82 }82 }
83 p.HasDiff = true83 p.HasDiff = true
84 p.Files = s.hl.files(ctx, res.Diff.Files)84 p.Files = s.hl.files(ctx, res.Diff.Files)
85 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))85 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
86+ collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String()))
86 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions87 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
87- if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {88+ if n := diffNote(res.Diff); n != "" {
88 p.Note = n89 p.Note = n
89 }90 }
90 lines, invisible := diffLines(p.Files)91 lines, invisible := diffLines(p.Files)
@@ -131,6 +132,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
131 Crumb{Label: p.Source, Current: true},132 Crumb{Label: p.Source, Current: true},
132 )133 )
133 l.HasRail = true134 l.HasRail = true
135+ l.Lazy = true
134 return Compare(l, p), http.StatusOK, nil136 return Compare(l, p), http.StatusOK, nil
135 }137 }
136 138
M internal/adapters/web/diff.go +107 -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,88 @@ 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+func collapseFiles(out []DiffFile, files []domain.FileDiff, l fileLinks) {
494+ for i, f := range files {
495+ if i >= len(out) {
496+ break
497+ }
498+ switch {
499+ case f.Omitted.Collapsed():
500+ if l.src != "" {
501+ out[i].Src = oneFileSrc(l.src, i)
502+ }
503+ q := "?file=" + itoa(i)
504+ if l.from > 0 {
505+ q = "?from=" + itoa(l.from) + "&file=" + itoa(i)
506+ }
507+ out[i].Href = q + "#" + out[i].ID
508+ default:
509+ out[i].Raw = rawLink(f, l)
510+ }
511+ }
512+}
513+
514+// rawLink is the address of the raw blob of a file too large to show, "" for
515+// any other file.
516+func rawLink(f domain.FileDiff, l fileLinks) string {
517+ if f.Omitted != domain.OmitUnavailable || f.Status == domain.FileDeleted || l.rev == "" {
518+ return ""
519+ }
520+ return repoHref(l.repo, "raw", l.rev, f.NewPath)
521+}
522+
523+// fileParam reads ?file= of a page without scripts: the index of a collapsed
524+// file to read in full, in [0, n).
525+func fileParam(r *http.Request, n int) (int, bool) {
526+ s := r.URL.Query().Get("file")
527+ i, err := strconv.Atoi(s)
528+ if err != nil || i < 0 || i >= n || strconv.Itoa(i) != s {
529+ return 0, false
530+ }
531+ return i, true
532+}
533+
534+// fragmentView lays out a one-file fragment like the page that asks for it:
535+// ?view= is the layout shown and ?layouts= says whether the page holds both.
536+func fragmentView(r *http.Request, lines int) DiffView {
537+ v := newDiffView(r, lines, "", "")
538+ v.pageLayouts(r)
539+ return v
540+}
541+
542+// pageLayouts makes a fragment send the layouts of the page that asks for
543+// it: ?layouts=both when the page holds both and switches them itself,
544+// ?layouts=one when it holds only the shown one.
545+func (v *DiffView) pageLayouts(r *http.Request) {
546+ switch r.URL.Query().Get("layouts") {
547+ case "both":
548+ v.Both = true
549+ case "one":
550+ v.Both = false
551+ }
552+ v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
553+}
@@ -7,8 +7,10 @@ import (
7 "fmt"7 "fmt"
8 "html"8 "html"
9 "io"9 "io"
10+ "net/http"
10 "net/url"11 "net/url"
11 "slices"12 "slices"
13+ "strconv"
12 "strings"14 "strings"
13 "unicode/utf8"15 "unicode/utf8"
14 16
@@ -123,6 +125,14 @@ type DiffFile struct {
123 Note string125 Note string
124 Open bool126 Open bool
125 Hunks []DiffHunk127 Hunks []DiffHunk
128+ // Manual is true for a collapsed file (generated, large, over the page
129+ // budget): no hunks, a placeholder with a "load diff" control that reads
130+ // that one file (Src, empty when it cannot be loaded in place; Href is the
131+ // page without scripts).
132+ Manual bool
133+ Href string
134+ // Raw is the address of the raw blob of a file too large to show.
135+ Raw string
126 // Digits is the width of the largest line number of the file, at least 2136 // Digits is the width of the largest line number of the file, at least 2
127 // and at most 7: the gutter is as wide as the file needs (class lnw-N).137 // and at most 7: the gutter is as wide as the file needs (class lnw-N).
128 Digits int138 Digits int
@@ -163,7 +173,7 @@ const diffContextLines = 3
163 // them: an added or a deleted file shows every line already.173 // them: an added or a deleted file shows every line already.
164 func contextGaps(out []DiffFile, files []domain.FileDiff, base string) {174 func contextGaps(out []DiffFile, files []domain.FileDiff, base string) {
165 for i, f := range files {175 for i, f := range files {
166- if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {176+ if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
167 continue177 continue
168 }178 }
169 switch f.Status {179 switch f.Status {
@@ -370,8 +380,9 @@ func diffFiles(files []domain.FileDiff) []DiffFile {
370 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),380 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
371 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,381 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
372 // A file that is shown in full opens; a file with nothing to read382 // A file that is shown in full opens; a file with nothing to read
373- // (binary, truncated) stays closed under its note.383+ // (binary, omitted) stays closed under its note.
374- Open: len(f.Hunks) > 0 && !f.Truncated,384+ Open: len(f.Hunks) > 0 && f.Omitted == domain.OmitNone,
385+ Manual: f.Omitted.Collapsed(),
375 }386 }
376 df.Key = f.NewPath387 df.Key = f.NewPath
377 switch f.Status {388 switch f.Status {
@@ -406,8 +417,14 @@ func fileNote(f domain.FileDiff) string {
406 switch {417 switch {
407 case f.Binary:418 case f.Binary:
408 parts = append(parts, "binary file: no text diff")419 parts = append(parts, "binary file: no text diff")
409- case f.Truncated:420+ case f.Omitted == domain.OmitGenerated:
410- parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))421+ parts = append(parts, "generated file: collapsed")
422+ case f.Omitted == domain.OmitLarge:
423+ parts = append(parts, fmt.Sprintf("large diff (%d lines changed): collapsed", f.Additions+f.Deletions))
424+ case f.Omitted == domain.OmitBudget:
425+ parts = append(parts, "collapsed to keep the page light")
426+ case f.Omitted == domain.OmitUnavailable:
427+ parts = append(parts, "too large to show in the browser")
411 }428 }
412 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {429 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
413 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))430 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))
@@ -449,3 +466,88 @@ func codeLine(ln DiffLine) templ.Component {
449 return err466 return err
450 })467 })
451 }468 }
469+
470+// fileLinks says where the collapsed files of a diff are loaded from.
471+type fileLinks struct {
472+ // src is the address of the fragment route of the diff ("" when files
473+ // cannot be loaded in place); one file is src plus one=1&from=i.
474+ src string
475+ // from is the window of the page, kept in the links of a page without
476+ // scripts.
477+ from int
478+ // repo and rev name the raw blob of a file that cannot be shown.
479+ repo, rev string
480+}
481+
482+// oneFileSrc is the address of the fragment holding file i alone.
483+func oneFileSrc(src string, i int) string {
484+ sep := "?"
485+ if strings.Contains(src, "?") {
486+ sep = "&"
487+ }
488+ return src + sep + "one=1&from=" + itoa(i)
489+}
490+
491+// collapseFiles gives the collapsed files of a diff their load address, and
492+// the files too large to show a link to their raw blob. Nothing is read.
493+func collapseFiles(out []DiffFile, files []domain.FileDiff, l fileLinks) {
494+ for i, f := range files {
495+ if i >= len(out) {
496+ break
497+ }
498+ switch {
499+ case f.Omitted.Collapsed():
500+ if l.src != "" {
501+ out[i].Src = oneFileSrc(l.src, i)
502+ }
503+ q := "?file=" + itoa(i)
504+ if l.from > 0 {
505+ q = "?from=" + itoa(l.from) + "&file=" + itoa(i)
506+ }
507+ out[i].Href = q + "#" + out[i].ID
508+ default:
509+ out[i].Raw = rawLink(f, l)
510+ }
511+ }
512+}
513+
514+// rawLink is the address of the raw blob of a file too large to show, "" for
515+// any other file.
516+func rawLink(f domain.FileDiff, l fileLinks) string {
517+ if f.Omitted != domain.OmitUnavailable || f.Status == domain.FileDeleted || l.rev == "" {
518+ return ""
519+ }
520+ return repoHref(l.repo, "raw", l.rev, f.NewPath)
521+}
522+
523+// fileParam reads ?file= of a page without scripts: the index of a collapsed
524+// file to read in full, in [0, n).
525+func fileParam(r *http.Request, n int) (int, bool) {
526+ s := r.URL.Query().Get("file")
527+ i, err := strconv.Atoi(s)
528+ if err != nil || i < 0 || i >= n || strconv.Itoa(i) != s {
529+ return 0, false
530+ }
531+ return i, true
532+}
533+
534+// fragmentView lays out a one-file fragment like the page that asks for it:
535+// ?view= is the layout shown and ?layouts= says whether the page holds both.
536+func fragmentView(r *http.Request, lines int) DiffView {
537+ v := newDiffView(r, lines, "", "")
538+ v.pageLayouts(r)
539+ return v
540+}
541+
542+// pageLayouts makes a fragment send the layouts of the page that asks for
543+// it: ?layouts=both when the page holds both and switches them itself,
544+// ?layouts=one when it holds only the shown one.
545+func (v *DiffView) pageLayouts(r *http.Request) {
546+ switch r.URL.Query().Get("layouts") {
547+ case "both":
548+ v.Both = true
549+ case "one":
550+ v.Both = false
551+ }
552+ v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
553+}
M internal/adapters/web/diff.templ +10 -1
@@ -233,12 +233,19 @@ templ DiffFileView(f DiffFile, p DiffView) {
233233 }
234234 </summary>
235235 if f.Lazy {
236- // A placeholder: mr.js fetches the window of the file when it comes
236+ // A placeholder: lazy.js fetches the window of the file when it comes
237237 // near the screen and swaps the files of the window in. Without
238238 // scripts the link opens the page of that window.
239239 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
240240 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
241241 </div>
242+ } else if f.Manual {
243+ // A collapsed file (generated, large, over the page budget): no
244+ // rows. "load diff" reads this one file and swaps it in place
245+ // (lazy.js); without scripts the link reloads the page with the file.
246+ <div class="dlazy" data-src={ f.Src } data-manual>
247+ <a href={ templ.SafeURL(f.Href) }>load diff</a>
248+ </div>
242249 } else if len(f.Hunks) > 0 {
243250 <div class="dscroll">
244251 if p.ShowUnified {
@@ -302,6 +309,8 @@ templ DiffFileView(f DiffFile, p DiffView) {
302309 </table>
303310 }
304311 </div>
312+ } else if f.Raw != "" {
313+ <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p>
305314 } else {
306315 <p class="note pad">
307316 if f.Note != "" {
@@ -233,12 +233,19 @@ templ DiffFileView(f DiffFile, p DiffView) {
233 }233 }
234 </summary>234 </summary>
235 if f.Lazy {235 if f.Lazy {
236- // A placeholder: mr.js fetches the window of the file when it comes236+ // A placeholder: lazy.js fetches the window of the file when it comes
237 // near the screen and swaps the files of the window in. Without237 // near the screen and swaps the files of the window in. Without
238 // scripts the link opens the page of that window.238 // scripts the link opens the page of that window.
239 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>239 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
240 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>240 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
241 </div>241 </div>
242+ } else if f.Manual {
243+ // A collapsed file (generated, large, over the page budget): no
244+ // rows. "load diff" reads this one file and swaps it in place
245+ // (lazy.js); without scripts the link reloads the page with the file.
246+ <div class="dlazy" data-src={ f.Src } data-manual>
247+ <a href={ templ.SafeURL(f.Href) }>load diff</a>
248+ </div>
242 } else if len(f.Hunks) > 0 {249 } else if len(f.Hunks) > 0 {
243 <div class="dscroll">250 <div class="dscroll">
244 if p.ShowUnified {251 if p.ShowUnified {
@@ -302,6 +309,8 @@ templ DiffFileView(f DiffFile, p DiffView) {
302 </table>309 </table>
303 }310 }
304 </div>311 </div>
312+ } else if f.Raw != "" {
313+ <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p>
305 } else {314 } else {
306 <p class="note pad">315 <p class="note pad">
307 if f.Note != "" {316 if f.Note != "" {
M internal/adapters/web/diff_highlight.go +2 -2
@@ -48,7 +48,7 @@ var maxDiffHLTotalBytes = 256 << 10
4848
4949 const (
5050 // maxDiffHLFileBytes is the most text of one file of a diff that is
51- // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 lines a
51+ // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 changed lines a
5252 // file; this keeps a file of very long lines out.
5353 maxDiffHLFileBytes = 128 << 10
5454 // maxDiffHLLine is the longest line that is lexed, in bytes: a minified
@@ -446,7 +446,7 @@ func (c *hlCache) files(ctx context.Context, files []domain.FileDiff) []DiffFile
446446
447447 left := maxDiffHLTotalBytes
448448 for i, f := range files {
449- if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
449+ if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
450450 continue
451451 }
452452 if ctx.Err() != nil {
@@ -48,7 +48,7 @@ var maxDiffHLTotalBytes = 256 << 10
48 48
49 const (49 const (
50 // maxDiffHLFileBytes is the most text of one file of a diff that is50 // maxDiffHLFileBytes is the most text of one file of a diff that is
51- // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 lines a51+ // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 changed lines a
52 // file; this keeps a file of very long lines out.52 // file; this keeps a file of very long lines out.
53 maxDiffHLFileBytes = 128 << 1053 maxDiffHLFileBytes = 128 << 10
54 // maxDiffHLLine is the longest line that is lexed, in bytes: a minified54 // maxDiffHLLine is the longest line that is lexed, in bytes: a minified
@@ -446,7 +446,7 @@ func (c *hlCache) files(ctx context.Context, files []domain.FileDiff) []DiffFile
446 446
447 left := maxDiffHLTotalBytes447 left := maxDiffHLTotalBytes
448 for i, f := range files {448 for i, f := range files {
449- if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {449+ if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
450 continue450 continue
451 }451 }
452 if ctx.Err() != nil {452 if ctx.Err() != nil {
M internal/adapters/web/diff_highlight_test.go +2 -2
@@ -162,7 +162,7 @@ func TestHighlightSkips(t *testing.T) {
162162 binary := hunkOf("a.go", "+package a")
163163 binary.Binary = true
164164 truncated := hunkOf("a.go", "+package a")
165- truncated.Truncated = true
165+ truncated.Omitted = domain.OmitLarge
166166 for name, fd := range map[string]domain.FileDiff{
167167 "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"),
168168 "plain text": hunkOf("notes.txt", "+package a"),
@@ -273,7 +273,7 @@ func goSource(n int) []string {
273273 }
274274
275275 // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place
276-// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a
276+// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 changed lines a
277277 // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.
278278 func largeDiff(n int) []domain.FileDiff {
279279 var files []domain.FileDiff
@@ -162,7 +162,7 @@ func TestHighlightSkips(t *testing.T) {
162 binary := hunkOf("a.go", "+package a")162 binary := hunkOf("a.go", "+package a")
163 binary.Binary = true163 binary.Binary = true
164 truncated := hunkOf("a.go", "+package a")164 truncated := hunkOf("a.go", "+package a")
165- truncated.Truncated = true165+ truncated.Omitted = domain.OmitLarge
166 for name, fd := range map[string]domain.FileDiff{166 for name, fd := range map[string]domain.FileDiff{
167 "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"),167 "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"),
168 "plain text": hunkOf("notes.txt", "+package a"),168 "plain text": hunkOf("notes.txt", "+package a"),
@@ -273,7 +273,7 @@ func goSource(n int) []string {
273 }273 }
274 274
275 // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place275 // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place
276-// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a276+// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 changed lines a
277 // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.277 // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.
278 func largeDiff(n int) []domain.FileDiff {278 func largeDiff(n int) []domain.FileDiff {
279 var files []domain.FileDiff279 var files []domain.FileDiff
M internal/adapters/web/diff_templ.go +235 -173
@@ -1417,31 +1417,62 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14171417 if templ_7745c5c3_Err != nil {
14181418 return templ_7745c5c3_Err
14191419 }
1420+ } else if f.Manual {
1421+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, " <div class=\"dlazy\" data-src=\"")
1422+ if templ_7745c5c3_Err != nil {
1423+ return templ_7745c5c3_Err
1424+ }
1425+ var templ_7745c5c3_Var75 string
1426+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src)
1427+ if templ_7745c5c3_Err != nil {
1428+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 246, Col: 38}
1429+ }
1430+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1431+ if templ_7745c5c3_Err != nil {
1432+ return templ_7745c5c3_Err
1433+ }
1434+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" data-manual><a href=\"")
1435+ if templ_7745c5c3_Err != nil {
1436+ return templ_7745c5c3_Err
1437+ }
1438+ var templ_7745c5c3_Var76 templ.SafeURL
1439+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Href))
1440+ if templ_7745c5c3_Err != nil {
1441+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 247, Col: 35}
1442+ }
1443+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1444+ if templ_7745c5c3_Err != nil {
1445+ return templ_7745c5c3_Err
1446+ }
1447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\">load diff</a></div>")
1448+ if templ_7745c5c3_Err != nil {
1449+ return templ_7745c5c3_Err
1450+ }
14201451 } else if len(f.Hunks) > 0 {
1421- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<div class=\"dscroll\">")
1452+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<div class=\"dscroll\">")
14221453 if templ_7745c5c3_Err != nil {
14231454 return templ_7745c5c3_Err
14241455 }
14251456 if p.ShowUnified {
1426- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<table class=\"dt uni\" aria-label=\"")
1457+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<table class=\"dt uni\" aria-label=\"")
14271458 if templ_7745c5c3_Err != nil {
14281459 return templ_7745c5c3_Err
14291460 }
1430- var templ_7745c5c3_Var75 string
1431- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
1461+ var templ_7745c5c3_Var77 string
1462+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
14321463 if templ_7745c5c3_Err != nil {
1433- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 245, Col: 60}
1464+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 252, Col: 60}
14341465 }
1435- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1466+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
14361467 if templ_7745c5c3_Err != nil {
14371468 return templ_7745c5c3_Err
14381469 }
1439- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\">")
1470+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\">")
14401471 if templ_7745c5c3_Err != nil {
14411472 return templ_7745c5c3_Err
14421473 }
14431474 for _, h := range f.Hunks {
1444- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<tbody>")
1475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<tbody>")
14451476 if templ_7745c5c3_Err != nil {
14461477 return templ_7745c5c3_Err
14471478 }
@@ -1451,43 +1482,43 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14511482 return templ_7745c5c3_Err
14521483 }
14531484 }
1454- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<tr class=\"hunk\"><td colspan=\"4\">")
1485+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tr class=\"hunk\"><td colspan=\"4\">")
14551486 if templ_7745c5c3_Err != nil {
14561487 return templ_7745c5c3_Err
14571488 }
1458- var templ_7745c5c3_Var76 string
1459- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1489+ var templ_7745c5c3_Var78 string
1490+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
14601491 if templ_7745c5c3_Err != nil {
1461- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 251, Col: 51}
1492+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 258, Col: 51}
14621493 }
1463- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1494+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
14641495 if templ_7745c5c3_Err != nil {
14651496 return templ_7745c5c3_Err
14661497 }
1467- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</td></tr>")
1498+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td></tr>")
14681499 if templ_7745c5c3_Err != nil {
14691500 return templ_7745c5c3_Err
14701501 }
14711502 for _, ln := range h.Lines {
1472- var templ_7745c5c3_Var77 = []any{ln.Kind, templ.KV("sel", ln.Selected)}
1473- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var77...)
1503+ var templ_7745c5c3_Var79 = []any{ln.Kind, templ.KV("sel", ln.Selected)}
1504+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...)
14741505 if templ_7745c5c3_Err != nil {
14751506 return templ_7745c5c3_Err
14761507 }
1477- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<tr class=\"")
1508+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<tr class=\"")
14781509 if templ_7745c5c3_Err != nil {
14791510 return templ_7745c5c3_Err
14801511 }
1481- var templ_7745c5c3_Var78 string
1482- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var77).String())
1512+ var templ_7745c5c3_Var80 string
1513+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var79).String())
14831514 if templ_7745c5c3_Err != nil {
14841515 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
14851516 }
1486- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
1517+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
14871518 if templ_7745c5c3_Err != nil {
14881519 return templ_7745c5c3_Err
14891520 }
1490- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\"><td class=\"ln\">")
1521+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\"><td class=\"ln\">")
14911522 if templ_7745c5c3_Err != nil {
14921523 return templ_7745c5c3_Err
14931524 }
@@ -1495,7 +1526,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14951526 if templ_7745c5c3_Err != nil {
14961527 return templ_7745c5c3_Err
14971528 }
1498- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td><td class=\"ln\">")
1529+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td><td class=\"ln\">")
14991530 if templ_7745c5c3_Err != nil {
15001531 return templ_7745c5c3_Err
15011532 }
@@ -1503,20 +1534,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15031534 if templ_7745c5c3_Err != nil {
15041535 return templ_7745c5c3_Err
15051536 }
1506- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"m\">")
1537+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td><td class=\"m\">")
15071538 if templ_7745c5c3_Err != nil {
15081539 return templ_7745c5c3_Err
15091540 }
1510- var templ_7745c5c3_Var79 string
1511- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1541+ var templ_7745c5c3_Var81 string
1542+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
15121543 if templ_7745c5c3_Err != nil {
1513- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 256, Col: 33}
1544+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 263, Col: 33}
15141545 }
1515- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))
1546+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
15161547 if templ_7745c5c3_Err != nil {
15171548 return templ_7745c5c3_Err
15181549 }
1519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"tx chroma\">")
1550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td><td class=\"tx chroma\">")
15201551 if templ_7745c5c3_Err != nil {
15211552 return templ_7745c5c3_Err
15221553 }
@@ -1524,12 +1555,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15241555 if templ_7745c5c3_Err != nil {
15251556 return templ_7745c5c3_Err
15261557 }
1527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td></tr>")
1558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</td></tr>")
15281559 if templ_7745c5c3_Err != nil {
15291560 return templ_7745c5c3_Err
15301561 }
15311562 if len(ln.Notes) > 0 || ln.Form != nil {
1532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
1563+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
15331564 if templ_7745c5c3_Err != nil {
15341565 return templ_7745c5c3_Err
15351566 }
@@ -1537,19 +1568,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15371568 if templ_7745c5c3_Err != nil {
15381569 return templ_7745c5c3_Err
15391570 }
1540- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td></tr>")
1571+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</td></tr>")
15411572 if templ_7745c5c3_Err != nil {
15421573 return templ_7745c5c3_Err
15431574 }
15441575 }
15451576 }
1546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</tbody> ")
1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</tbody> ")
15471578 if templ_7745c5c3_Err != nil {
15481579 return templ_7745c5c3_Err
15491580 }
15501581 }
15511582 if f.Tail != nil {
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 }
@@ -1557,36 +1588,36 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15571588 if templ_7745c5c3_Err != nil {
15581589 return templ_7745c5c3_Err
15591590 }
1560- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</tbody>")
1591+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "</tbody>")
15611592 if templ_7745c5c3_Err != nil {
15621593 return templ_7745c5c3_Err
15631594 }
15641595 }
1565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</table>")
1596+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</table>")
15661597 if templ_7745c5c3_Err != nil {
15671598 return templ_7745c5c3_Err
15681599 }
15691600 }
15701601 if p.ShowSplit {
1571- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<table class=\"dt spl\" aria-label=\"")
1602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<table class=\"dt spl\" aria-label=\"")
15721603 if templ_7745c5c3_Err != nil {
15731604 return templ_7745c5c3_Err
15741605 }
1575- var templ_7745c5c3_Var80 string
1576- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
1606+ var templ_7745c5c3_Var82 string
1607+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
15771608 if templ_7745c5c3_Err != nil {
1578- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 276, Col: 58}
1609+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 283, Col: 58}
15791610 }
1580- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1611+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
15811612 if templ_7745c5c3_Err != nil {
15821613 return templ_7745c5c3_Err
15831614 }
1584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
1615+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
15851616 if templ_7745c5c3_Err != nil {
15861617 return templ_7745c5c3_Err
15871618 }
15881619 for _, h := range f.Hunks {
1589- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<tbody>")
1620+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<tbody>")
15901621 if templ_7745c5c3_Err != nil {
15911622 return templ_7745c5c3_Err
15921623 }
@@ -1596,25 +1627,25 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15961627 return templ_7745c5c3_Err
15971628 }
15981629 }
1599- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<tr class=\"hunk\"><td colspan=\"6\">")
1630+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<tr class=\"hunk\"><td colspan=\"6\">")
16001631 if templ_7745c5c3_Err != nil {
16011632 return templ_7745c5c3_Err
16021633 }
1603- var templ_7745c5c3_Var81 string
1604- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1634+ var templ_7745c5c3_Var83 string
1635+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
16051636 if templ_7745c5c3_Err != nil {
1606- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 283, Col: 51}
1637+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 290, Col: 51}
16071638 }
1608- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1639+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
16091640 if templ_7745c5c3_Err != nil {
16101641 return templ_7745c5c3_Err
16111642 }
1612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "</td></tr>")
1643+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</td></tr>")
16131644 if templ_7745c5c3_Err != nil {
16141645 return templ_7745c5c3_Err
16151646 }
16161647 for _, row := range h.Split {
1617- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<tr>")
1648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<tr>")
16181649 if templ_7745c5c3_Err != nil {
16191650 return templ_7745c5c3_Err
16201651 }
@@ -1626,12 +1657,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16261657 if templ_7745c5c3_Err != nil {
16271658 return templ_7745c5c3_Err
16281659 }
1629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</tr>")
1660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</tr>")
16301661 if templ_7745c5c3_Err != nil {
16311662 return templ_7745c5c3_Err
16321663 }
16331664 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
1634- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<tr class=\"fnote\"><td colspan=\"6\">")
1665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<tr class=\"fnote\"><td colspan=\"6\">")
16351666 if templ_7745c5c3_Err != nil {
16361667 return templ_7745c5c3_Err
16371668 }
@@ -1639,19 +1670,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16391670 if templ_7745c5c3_Err != nil {
16401671 return templ_7745c5c3_Err
16411672 }
1642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</td></tr>")
1673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</td></tr>")
16431674 if templ_7745c5c3_Err != nil {
16441675 return templ_7745c5c3_Err
16451676 }
16461677 }
16471678 }
1648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</tbody> ")
1679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</tbody> ")
16491680 if templ_7745c5c3_Err != nil {
16501681 return templ_7745c5c3_Err
16511682 }
16521683 }
16531684 if f.Tail != nil {
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 }
@@ -1659,48 +1690,79 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16591690 if templ_7745c5c3_Err != nil {
16601691 return templ_7745c5c3_Err
16611692 }
1662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</tbody>")
1693+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</tbody>")
16631694 if templ_7745c5c3_Err != nil {
16641695 return templ_7745c5c3_Err
16651696 }
16661697 }
1667- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</table>")
1698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</table>")
16681699 if templ_7745c5c3_Err != nil {
16691700 return templ_7745c5c3_Err
16701701 }
16711702 }
1672- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</div>")
1703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</div>")
1704+ if templ_7745c5c3_Err != nil {
1705+ return templ_7745c5c3_Err
1706+ }
1707+ } else if f.Raw != "" {
1708+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<p class=\"note pad\">")
1709+ if templ_7745c5c3_Err != nil {
1710+ return templ_7745c5c3_Err
1711+ }
1712+ var templ_7745c5c3_Var84 string
1713+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1714+ if templ_7745c5c3_Err != nil {
1715+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 313, Col: 31}
1716+ }
1717+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
1718+ if templ_7745c5c3_Err != nil {
1719+ return templ_7745c5c3_Err
1720+ }
1721+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, " <a href=\"")
1722+ if templ_7745c5c3_Err != nil {
1723+ return templ_7745c5c3_Err
1724+ }
1725+ var templ_7745c5c3_Var85 templ.SafeURL
1726+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Raw))
1727+ if templ_7745c5c3_Err != nil {
1728+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 313, Col: 64}
1729+ }
1730+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
1731+ if templ_7745c5c3_Err != nil {
1732+ return templ_7745c5c3_Err
1733+ }
1734+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "\">view raw</a></p>")
16731735 if templ_7745c5c3_Err != nil {
16741736 return templ_7745c5c3_Err
16751737 }
16761738 } else {
1677- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<p class=\"note pad\">")
1739+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"note pad\">")
16781740 if templ_7745c5c3_Err != nil {
16791741 return templ_7745c5c3_Err
16801742 }
16811743 if f.Note != "" {
1682- var templ_7745c5c3_Var82 string
1683- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1744+ var templ_7745c5c3_Var86 string
1745+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
16841746 if templ_7745c5c3_Err != nil {
1685- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 308, Col: 13}
1747+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 317, Col: 13}
16861748 }
1687- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82))
1749+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
16881750 if templ_7745c5c3_Err != nil {
16891751 return templ_7745c5c3_Err
16901752 }
16911753 } else {
1692- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "no textual change.")
1754+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "no textual change.")
16931755 if templ_7745c5c3_Err != nil {
16941756 return templ_7745c5c3_Err
16951757 }
16961758 }
1697- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</p>")
1759+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "</p>")
16981760 if templ_7745c5c3_Err != nil {
16991761 return templ_7745c5c3_Err
17001762 }
17011763 }
17021764 if len(f.Loose) > 0 {
1703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<div class=\"dloose\">")
1765+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "<div class=\"dloose\">")
17041766 if templ_7745c5c3_Err != nil {
17051767 return templ_7745c5c3_Err
17061768 }
@@ -1710,12 +1772,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
17101772 return templ_7745c5c3_Err
17111773 }
17121774 }
1713- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</div>")
1775+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "</div>")
17141776 if templ_7745c5c3_Err != nil {
17151777 return templ_7745c5c3_Err
17161778 }
17171779 }
1718- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</details>")
1780+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</details>")
17191781 if templ_7745c5c3_Err != nil {
17201782 return templ_7745c5c3_Err
17211783 }
@@ -1741,9 +1803,9 @@ func MRFiles(p MRFilesPage) templ.Component {
17411803 }()
17421804 }
17431805 ctx = templ.InitializeContext(ctx)
1744- templ_7745c5c3_Var83 := templ.GetChildren(ctx)
1745- if templ_7745c5c3_Var83 == nil {
1746- templ_7745c5c3_Var83 = templ.NopComponent
1806+ templ_7745c5c3_Var87 := templ.GetChildren(ctx)
1807+ if templ_7745c5c3_Var87 == nil {
1808+ templ_7745c5c3_Var87 = templ.NopComponent
17471809 }
17481810 ctx = templ.ClearChildren(ctx)
17491811 for _, f := range p.Files {
@@ -1774,12 +1836,12 @@ func MRFilesMoved() templ.Component {
17741836 }()
17751837 }
17761838 ctx = templ.InitializeContext(ctx)
1777- templ_7745c5c3_Var84 := templ.GetChildren(ctx)
1778- if templ_7745c5c3_Var84 == nil {
1779- templ_7745c5c3_Var84 = templ.NopComponent
1839+ templ_7745c5c3_Var88 := templ.GetChildren(ctx)
1840+ if templ_7745c5c3_Var88 == nil {
1841+ templ_7745c5c3_Var88 = templ.NopComponent
17801842 }
17811843 ctx = templ.ClearChildren(ctx)
1782- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
1844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
17831845 if templ_7745c5c3_Err != nil {
17841846 return templ_7745c5c3_Err
17851847 }
@@ -1806,64 +1868,64 @@ func gapRow(g DiffGap, cols int) templ.Component {
18061868 }()
18071869 }
18081870 ctx = templ.InitializeContext(ctx)
1809- templ_7745c5c3_Var85 := templ.GetChildren(ctx)
1810- if templ_7745c5c3_Var85 == nil {
1811- templ_7745c5c3_Var85 = templ.NopComponent
1871+ templ_7745c5c3_Var89 := templ.GetChildren(ctx)
1872+ if templ_7745c5c3_Var89 == nil {
1873+ templ_7745c5c3_Var89 = templ.NopComponent
18121874 }
18131875 ctx = templ.ClearChildren(ctx)
1814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<tr class=\"gap\" data-js data-gap=\"")
1876+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<tr class=\"gap\" data-js data-gap=\"")
18151877 if templ_7745c5c3_Err != nil {
18161878 return templ_7745c5c3_Err
18171879 }
1818- var templ_7745c5c3_Var86 string
1819- templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
1880+ var templ_7745c5c3_Var90 string
1881+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
18201882 if templ_7745c5c3_Err != nil {
1821- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 342, Col: 41}
1883+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 351, Col: 41}
18221884 }
1823- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)
1885+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
18241886 if templ_7745c5c3_Err != nil {
18251887 return templ_7745c5c3_Err
18261888 }
1827- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\"><td colspan=\"")
1889+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "\"><td colspan=\"")
18281890 if templ_7745c5c3_Err != nil {
18291891 return templ_7745c5c3_Err
18301892 }
1831- var templ_7745c5c3_Var87 string
1832- templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
1893+ var templ_7745c5c3_Var91 string
1894+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
18331895 if templ_7745c5c3_Err != nil {
1834- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 343, Col: 26}
1896+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 352, Col: 26}
18351897 }
1836- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)
1898+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
18371899 if templ_7745c5c3_Err != nil {
18381900 return templ_7745c5c3_Err
18391901 }
1840- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"><button type=\"button\" data-src=\"")
1902+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "\"><button type=\"button\" data-src=\"")
18411903 if templ_7745c5c3_Err != nil {
18421904 return templ_7745c5c3_Err
18431905 }
1844- var templ_7745c5c3_Var88 string
1845- templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
1906+ var templ_7745c5c3_Var92 string
1907+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
18461908 if templ_7745c5c3_Err != nil {
1847- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 344, Col: 41}
1909+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 353, Col: 41}
18481910 }
1849- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
1911+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
18501912 if templ_7745c5c3_Err != nil {
18511913 return templ_7745c5c3_Err
18521914 }
1853- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"><span aria-hidden=\"true\">⋯</span> ")
1915+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\"><span aria-hidden=\"true\">⋯</span> ")
18541916 if templ_7745c5c3_Err != nil {
18551917 return templ_7745c5c3_Err
18561918 }
1857- var templ_7745c5c3_Var89 string
1858- templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
1919+ var templ_7745c5c3_Var93 string
1920+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
18591921 if templ_7745c5c3_Err != nil {
1860- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 345, Col: 51}
1922+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 354, Col: 51}
18611923 }
1862- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
1924+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
18631925 if templ_7745c5c3_Err != nil {
18641926 return templ_7745c5c3_Err
18651927 }
1866- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</button></td></tr>")
1928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</button></td></tr>")
18671929 if templ_7745c5c3_Err != nil {
18681930 return templ_7745c5c3_Err
18691931 }
@@ -1889,56 +1951,56 @@ func DiffContext(c ContextRows) templ.Component {
18891951 }()
18901952 }
18911953 ctx = templ.InitializeContext(ctx)
1892- templ_7745c5c3_Var90 := templ.GetChildren(ctx)
1893- if templ_7745c5c3_Var90 == nil {
1894- templ_7745c5c3_Var90 = templ.NopComponent
1954+ templ_7745c5c3_Var94 := templ.GetChildren(ctx)
1955+ if templ_7745c5c3_Var94 == nil {
1956+ templ_7745c5c3_Var94 = templ.NopComponent
18951957 }
18961958 ctx = templ.ClearChildren(ctx)
1897- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<table><tbody data-for=\"uni\">")
1959+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<table><tbody data-for=\"uni\">")
18981960 if templ_7745c5c3_Err != nil {
18991961 return templ_7745c5c3_Err
19001962 }
19011963 for _, ln := range c.Lines {
1902- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<tr class=\"ctx\"><td class=\"ln\">")
1964+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<tr class=\"ctx\"><td class=\"ln\">")
19031965 if templ_7745c5c3_Err != nil {
19041966 return templ_7745c5c3_Err
19051967 }
1906- var templ_7745c5c3_Var91 string
1907- templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
1968+ var templ_7745c5c3_Var95 string
1969+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
19081970 if templ_7745c5c3_Err != nil {
1909- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 358, Col: 28}
1971+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 367, Col: 28}
19101972 }
1911- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
1973+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
19121974 if templ_7745c5c3_Err != nil {
19131975 return templ_7745c5c3_Err
19141976 }
1915- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "</td><td class=\"ln\">")
1977+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</td><td class=\"ln\">")
19161978 if templ_7745c5c3_Err != nil {
19171979 return templ_7745c5c3_Err
19181980 }
1919- var templ_7745c5c3_Var92 string
1920- templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
1981+ var templ_7745c5c3_Var96 string
1982+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
19211983 if templ_7745c5c3_Err != nil {
1922- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 359, Col: 28}
1984+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 368, Col: 28}
19231985 }
1924- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
1986+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
19251987 if templ_7745c5c3_Err != nil {
19261988 return templ_7745c5c3_Err
19271989 }
1928- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "</td><td class=\"m\">")
1990+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</td><td class=\"m\">")
19291991 if templ_7745c5c3_Err != nil {
19301992 return templ_7745c5c3_Err
19311993 }
1932- var templ_7745c5c3_Var93 string
1933- templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1994+ var templ_7745c5c3_Var97 string
1995+ templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
19341996 if templ_7745c5c3_Err != nil {
1935- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 360, Col: 28}
1997+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 369, Col: 28}
19361998 }
1937- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
1999+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
19382000 if templ_7745c5c3_Err != nil {
19392001 return templ_7745c5c3_Err
19402002 }
1941- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</td><td class=\"tx chroma\">")
2003+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</td><td class=\"tx chroma\">")
19422004 if templ_7745c5c3_Err != nil {
19432005 return templ_7745c5c3_Err
19442006 }
@@ -1946,7 +2008,7 @@ func DiffContext(c ContextRows) templ.Component {
19462008 if templ_7745c5c3_Err != nil {
19472009 return templ_7745c5c3_Err
19482010 }
1949- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td></tr>")
2011+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</td></tr>")
19502012 if templ_7745c5c3_Err != nil {
19512013 return templ_7745c5c3_Err
19522014 }
@@ -1957,12 +2019,12 @@ func DiffContext(c ContextRows) templ.Component {
19572019 return templ_7745c5c3_Err
19582020 }
19592021 }
1960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</tbody> <tbody data-for=\"spl\">")
2022+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</tbody> <tbody data-for=\"spl\">")
19612023 if templ_7745c5c3_Err != nil {
19622024 return templ_7745c5c3_Err
19632025 }
19642026 for i := range c.Lines {
1965- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<tr>")
2027+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<tr>")
19662028 if templ_7745c5c3_Err != nil {
19672029 return templ_7745c5c3_Err
19682030 }
@@ -1974,7 +2036,7 @@ func DiffContext(c ContextRows) templ.Component {
19742036 if templ_7745c5c3_Err != nil {
19752037 return templ_7745c5c3_Err
19762038 }
1977- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</tr>")
2039+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</tr>")
19782040 if templ_7745c5c3_Err != nil {
19792041 return templ_7745c5c3_Err
19802042 }
@@ -1985,7 +2047,7 @@ func DiffContext(c ContextRows) templ.Component {
19852047 return templ_7745c5c3_Err
19862048 }
19872049 }
1988- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</tbody></table>")
2050+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</tbody></table>")
19892051 if templ_7745c5c3_Err != nil {
19902052 return templ_7745c5c3_Err
19912053 }
@@ -2011,36 +2073,36 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20112073 }()
20122074 }
20132075 ctx = templ.InitializeContext(ctx)
2014- templ_7745c5c3_Var94 := templ.GetChildren(ctx)
2015- if templ_7745c5c3_Var94 == nil {
2016- templ_7745c5c3_Var94 = templ.NopComponent
2076+ templ_7745c5c3_Var98 := templ.GetChildren(ctx)
2077+ if templ_7745c5c3_Var98 == nil {
2078+ templ_7745c5c3_Var98 = templ.NopComponent
20172079 }
20182080 ctx = templ.ClearChildren(ctx)
20192081 if ln == nil {
2020- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
2082+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
20212083 if templ_7745c5c3_Err != nil {
20222084 return templ_7745c5c3_Err
20232085 }
20242086 } else {
2025- var templ_7745c5c3_Var95 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}
2026- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var95...)
2087+ var templ_7745c5c3_Var99 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}
2088+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var99...)
20272089 if templ_7745c5c3_Err != nil {
20282090 return templ_7745c5c3_Err
20292091 }
2030- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<td class=\"")
2092+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"")
20312093 if templ_7745c5c3_Err != nil {
20322094 return templ_7745c5c3_Err
20332095 }
2034- var templ_7745c5c3_Var96 string
2035- templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var95).String())
2096+ var templ_7745c5c3_Var100 string
2097+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var99).String())
20362098 if templ_7745c5c3_Err != nil {
20372099 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
20382100 }
2039- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var96)
2101+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var100)
20402102 if templ_7745c5c3_Err != nil {
20412103 return templ_7745c5c3_Err
20422104 }
2043- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "\">")
2105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\">")
20442106 if templ_7745c5c3_Err != nil {
20452107 return templ_7745c5c3_Err
20462108 }
@@ -2055,64 +2117,64 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20552117 return templ_7745c5c3_Err
20562118 }
20572119 }
2058- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "</td>")
2120+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</td>")
20592121 if templ_7745c5c3_Err != nil {
20602122 return templ_7745c5c3_Err
20612123 }
2062- var templ_7745c5c3_Var97 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}
2063- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var97...)
2124+ var templ_7745c5c3_Var101 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}
2125+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var101...)
20642126 if templ_7745c5c3_Err != nil {
20652127 return templ_7745c5c3_Err
20662128 }
2067- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<td class=\"")
2129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<td class=\"")
20682130 if templ_7745c5c3_Err != nil {
20692131 return templ_7745c5c3_Err
20702132 }
2071- var templ_7745c5c3_Var98 string
2072- templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var97).String())
2133+ var templ_7745c5c3_Var102 string
2134+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var101).String())
20732135 if templ_7745c5c3_Err != nil {
20742136 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
20752137 }
2076- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var98)
2138+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var102)
20772139 if templ_7745c5c3_Err != nil {
20782140 return templ_7745c5c3_Err
20792141 }
2080- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "\">")
2142+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\">")
20812143 if templ_7745c5c3_Err != nil {
20822144 return templ_7745c5c3_Err
20832145 }
2084- var templ_7745c5c3_Var99 string
2085- templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
2146+ var templ_7745c5c3_Var103 string
2147+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
20862148 if templ_7745c5c3_Err != nil {
2087- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 397, Col: 68}
2149+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 68}
20882150 }
2089- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
2151+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
20902152 if templ_7745c5c3_Err != nil {
20912153 return templ_7745c5c3_Err
20922154 }
2093- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</td>")
2155+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</td>")
20942156 if templ_7745c5c3_Err != nil {
20952157 return templ_7745c5c3_Err
20962158 }
2097- var templ_7745c5c3_Var100 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}
2098- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var100...)
2159+ var templ_7745c5c3_Var104 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}
2160+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var104...)
20992161 if templ_7745c5c3_Err != nil {
21002162 return templ_7745c5c3_Err
21012163 }
2102- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"")
2164+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "<td class=\"")
21032165 if templ_7745c5c3_Err != nil {
21042166 return templ_7745c5c3_Err
21052167 }
2106- var templ_7745c5c3_Var101 string
2107- templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var100).String())
2168+ var templ_7745c5c3_Var105 string
2169+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var104).String())
21082170 if templ_7745c5c3_Err != nil {
21092171 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
21102172 }
2111- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var101)
2173+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
21122174 if templ_7745c5c3_Err != nil {
21132175 return templ_7745c5c3_Err
21142176 }
2115- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\">")
2177+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "\">")
21162178 if templ_7745c5c3_Err != nil {
21172179 return templ_7745c5c3_Err
21182180 }
@@ -2120,7 +2182,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
21202182 if templ_7745c5c3_Err != nil {
21212183 return templ_7745c5c3_Err
21222184 }
2123- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</td>")
2185+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "</td>")
21242186 if templ_7745c5c3_Err != nil {
21252187 return templ_7745c5c3_Err
21262188 }
@@ -2147,49 +2209,49 @@ func gutter(n, href string) templ.Component {
21472209 }()
21482210 }
21492211 ctx = templ.InitializeContext(ctx)
2150- templ_7745c5c3_Var102 := templ.GetChildren(ctx)
2151- if templ_7745c5c3_Var102 == nil {
2152- templ_7745c5c3_Var102 = templ.NopComponent
2212+ templ_7745c5c3_Var106 := templ.GetChildren(ctx)
2213+ if templ_7745c5c3_Var106 == nil {
2214+ templ_7745c5c3_Var106 = templ.NopComponent
21532215 }
21542216 ctx = templ.ClearChildren(ctx)
21552217 if href != "" {
2156- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<a href=\"")
2218+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "<a href=\"")
21572219 if templ_7745c5c3_Err != nil {
21582220 return templ_7745c5c3_Err
21592221 }
2160- var templ_7745c5c3_Var103 templ.SafeURL
2161- templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
2222+ var templ_7745c5c3_Var107 templ.SafeURL
2223+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
21622224 if templ_7745c5c3_Err != nil {
2163- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 31}
2225+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 415, Col: 31}
21642226 }
2165- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
2227+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
21662228 if templ_7745c5c3_Err != nil {
21672229 return templ_7745c5c3_Err
21682230 }
2169- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\" title=\"comment on this line\">")
2231+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "\" title=\"comment on this line\">")
21702232 if templ_7745c5c3_Err != nil {
21712233 return templ_7745c5c3_Err
21722234 }
2173- var templ_7745c5c3_Var104 string
2174- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2235+ var templ_7745c5c3_Var108 string
2236+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(n)
21752237 if templ_7745c5c3_Err != nil {
2176- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 66}
2238+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 415, Col: 66}
21772239 }
2178- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
2240+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
21792241 if templ_7745c5c3_Err != nil {
21802242 return templ_7745c5c3_Err
21812243 }
2182- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</a>")
2244+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "</a>")
21832245 if templ_7745c5c3_Err != nil {
21842246 return templ_7745c5c3_Err
21852247 }
21862248 } else {
2187- var templ_7745c5c3_Var105 string
2188- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2249+ var templ_7745c5c3_Var109 string
2250+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(n)
21892251 if templ_7745c5c3_Err != nil {
2190- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 408, Col: 5}
2252+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 417, Col: 5}
21912253 }
2192- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2254+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
21932255 if templ_7745c5c3_Err != nil {
21942256 return templ_7745c5c3_Err
21952257 }
@@ -1417,31 +1417,62 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1417 if templ_7745c5c3_Err != nil {1417 if templ_7745c5c3_Err != nil {
1418 return templ_7745c5c3_Err1418 return templ_7745c5c3_Err
1419 }1419 }
1420+ } else if f.Manual {
1421+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, " <div class=\"dlazy\" data-src=\"")
1422+ if templ_7745c5c3_Err != nil {
1423+ return templ_7745c5c3_Err
1424+ }
1425+ var templ_7745c5c3_Var75 string
1426+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src)
1427+ if templ_7745c5c3_Err != nil {
1428+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 246, Col: 38}
1429+ }
1430+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1431+ if templ_7745c5c3_Err != nil {
1432+ return templ_7745c5c3_Err
1433+ }
1434+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" data-manual><a href=\"")
1435+ if templ_7745c5c3_Err != nil {
1436+ return templ_7745c5c3_Err
1437+ }
1438+ var templ_7745c5c3_Var76 templ.SafeURL
1439+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Href))
1440+ if templ_7745c5c3_Err != nil {
1441+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 247, Col: 35}
1442+ }
1443+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1444+ if templ_7745c5c3_Err != nil {
1445+ return templ_7745c5c3_Err
1446+ }
1447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\">load diff</a></div>")
1448+ if templ_7745c5c3_Err != nil {
1449+ return templ_7745c5c3_Err
1450+ }
1420 } else if len(f.Hunks) > 0 {1451 } else if len(f.Hunks) > 0 {
1421- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<div class=\"dscroll\">")1452+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<div class=\"dscroll\">")
1422 if templ_7745c5c3_Err != nil {1453 if templ_7745c5c3_Err != nil {
1423 return templ_7745c5c3_Err1454 return templ_7745c5c3_Err
1424 }1455 }
1425 if p.ShowUnified {1456 if p.ShowUnified {
1426- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<table class=\"dt uni\" aria-label=\"")1457+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<table class=\"dt uni\" aria-label=\"")
1427 if templ_7745c5c3_Err != nil {1458 if templ_7745c5c3_Err != nil {
1428 return templ_7745c5c3_Err1459 return templ_7745c5c3_Err
1429 }1460 }
1430- var templ_7745c5c3_Var75 string1461+ var templ_7745c5c3_Var77 string
1431- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")1462+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
1432 if templ_7745c5c3_Err != nil {1463 if templ_7745c5c3_Err != nil {
1433- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 245, Col: 60}1464+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 252, Col: 60}
1434 }1465 }
1435- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)1466+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
1436 if templ_7745c5c3_Err != nil {1467 if templ_7745c5c3_Err != nil {
1437 return templ_7745c5c3_Err1468 return templ_7745c5c3_Err
1438 }1469 }
1439- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\">")1470+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\">")
1440 if templ_7745c5c3_Err != nil {1471 if templ_7745c5c3_Err != nil {
1441 return templ_7745c5c3_Err1472 return templ_7745c5c3_Err
1442 }1473 }
1443 for _, h := range f.Hunks {1474 for _, h := range f.Hunks {
1444- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<tbody>")1475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<tbody>")
1445 if templ_7745c5c3_Err != nil {1476 if templ_7745c5c3_Err != nil {
1446 return templ_7745c5c3_Err1477 return templ_7745c5c3_Err
1447 }1478 }
@@ -1451,43 +1482,43 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1451 return templ_7745c5c3_Err1482 return templ_7745c5c3_Err
1452 }1483 }
1453 }1484 }
1454- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<tr class=\"hunk\"><td colspan=\"4\">")1485+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tr class=\"hunk\"><td colspan=\"4\">")
1455 if templ_7745c5c3_Err != nil {1486 if templ_7745c5c3_Err != nil {
1456 return templ_7745c5c3_Err1487 return templ_7745c5c3_Err
1457 }1488 }
1458- var templ_7745c5c3_Var76 string1489+ var templ_7745c5c3_Var78 string
1459- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)1490+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1460 if templ_7745c5c3_Err != nil {1491 if templ_7745c5c3_Err != nil {
1461- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 251, Col: 51}1492+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 258, Col: 51}
1462 }1493 }
1463- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))1494+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
1464 if templ_7745c5c3_Err != nil {1495 if templ_7745c5c3_Err != nil {
1465 return templ_7745c5c3_Err1496 return templ_7745c5c3_Err
1466 }1497 }
1467- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</td></tr>")1498+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td></tr>")
1468 if templ_7745c5c3_Err != nil {1499 if templ_7745c5c3_Err != nil {
1469 return templ_7745c5c3_Err1500 return templ_7745c5c3_Err
1470 }1501 }
1471 for _, ln := range h.Lines {1502 for _, ln := range h.Lines {
1472- var templ_7745c5c3_Var77 = []any{ln.Kind, templ.KV("sel", ln.Selected)}1503+ var templ_7745c5c3_Var79 = []any{ln.Kind, templ.KV("sel", ln.Selected)}
1473- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var77...)1504+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...)
1474 if templ_7745c5c3_Err != nil {1505 if templ_7745c5c3_Err != nil {
1475 return templ_7745c5c3_Err1506 return templ_7745c5c3_Err
1476 }1507 }
1477- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<tr class=\"")1508+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<tr class=\"")
1478 if templ_7745c5c3_Err != nil {1509 if templ_7745c5c3_Err != nil {
1479 return templ_7745c5c3_Err1510 return templ_7745c5c3_Err
1480 }1511 }
1481- var templ_7745c5c3_Var78 string1512+ var templ_7745c5c3_Var80 string
1482- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var77).String())1513+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var79).String())
1483 if templ_7745c5c3_Err != nil {1514 if templ_7745c5c3_Err != nil {
1484 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}1515 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
1485 }1516 }
1486- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)1517+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1487 if templ_7745c5c3_Err != nil {1518 if templ_7745c5c3_Err != nil {
1488 return templ_7745c5c3_Err1519 return templ_7745c5c3_Err
1489 }1520 }
1490- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\"><td class=\"ln\">")1521+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\"><td class=\"ln\">")
1491 if templ_7745c5c3_Err != nil {1522 if templ_7745c5c3_Err != nil {
1492 return templ_7745c5c3_Err1523 return templ_7745c5c3_Err
1493 }1524 }
@@ -1495,7 +1526,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1495 if templ_7745c5c3_Err != nil {1526 if templ_7745c5c3_Err != nil {
1496 return templ_7745c5c3_Err1527 return templ_7745c5c3_Err
1497 }1528 }
1498- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td><td class=\"ln\">")1529+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td><td class=\"ln\">")
1499 if templ_7745c5c3_Err != nil {1530 if templ_7745c5c3_Err != nil {
1500 return templ_7745c5c3_Err1531 return templ_7745c5c3_Err
1501 }1532 }
@@ -1503,20 +1534,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1503 if templ_7745c5c3_Err != nil {1534 if templ_7745c5c3_Err != nil {
1504 return templ_7745c5c3_Err1535 return templ_7745c5c3_Err
1505 }1536 }
1506- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"m\">")1537+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td><td class=\"m\">")
1507 if templ_7745c5c3_Err != nil {1538 if templ_7745c5c3_Err != nil {
1508 return templ_7745c5c3_Err1539 return templ_7745c5c3_Err
1509 }1540 }
1510- var templ_7745c5c3_Var79 string1541+ var templ_7745c5c3_Var81 string
1511- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)1542+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1512 if templ_7745c5c3_Err != nil {1543 if templ_7745c5c3_Err != nil {
1513- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 256, Col: 33}1544+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 263, Col: 33}
1514 }1545 }
1515- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))1546+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1516 if templ_7745c5c3_Err != nil {1547 if templ_7745c5c3_Err != nil {
1517 return templ_7745c5c3_Err1548 return templ_7745c5c3_Err
1518 }1549 }
1519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"tx chroma\">")1550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td><td class=\"tx chroma\">")
1520 if templ_7745c5c3_Err != nil {1551 if templ_7745c5c3_Err != nil {
1521 return templ_7745c5c3_Err1552 return templ_7745c5c3_Err
1522 }1553 }
@@ -1524,12 +1555,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1524 if templ_7745c5c3_Err != nil {1555 if templ_7745c5c3_Err != nil {
1525 return templ_7745c5c3_Err1556 return templ_7745c5c3_Err
1526 }1557 }
1527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td></tr>")1558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</td></tr>")
1528 if templ_7745c5c3_Err != nil {1559 if templ_7745c5c3_Err != nil {
1529 return templ_7745c5c3_Err1560 return templ_7745c5c3_Err
1530 }1561 }
1531 if len(ln.Notes) > 0 || ln.Form != nil {1562 if len(ln.Notes) > 0 || ln.Form != nil {
1532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")1563+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
1533 if templ_7745c5c3_Err != nil {1564 if templ_7745c5c3_Err != nil {
1534 return templ_7745c5c3_Err1565 return templ_7745c5c3_Err
1535 }1566 }
@@ -1537,19 +1568,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1537 if templ_7745c5c3_Err != nil {1568 if templ_7745c5c3_Err != nil {
1538 return templ_7745c5c3_Err1569 return templ_7745c5c3_Err
1539 }1570 }
1540- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td></tr>")1571+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</td></tr>")
1541 if templ_7745c5c3_Err != nil {1572 if templ_7745c5c3_Err != nil {
1542 return templ_7745c5c3_Err1573 return templ_7745c5c3_Err
1543 }1574 }
1544 }1575 }
1545 }1576 }
1546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</tbody> ")1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</tbody> ")
1547 if templ_7745c5c3_Err != nil {1578 if templ_7745c5c3_Err != nil {
1548 return templ_7745c5c3_Err1579 return templ_7745c5c3_Err
1549 }1580 }
1550 }1581 }
1551 if f.Tail != nil {1582 if f.Tail != nil {
1552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<tbody>")1583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<tbody>")
1553 if templ_7745c5c3_Err != nil {1584 if templ_7745c5c3_Err != nil {
1554 return templ_7745c5c3_Err1585 return templ_7745c5c3_Err
1555 }1586 }
@@ -1557,36 +1588,36 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1557 if templ_7745c5c3_Err != nil {1588 if templ_7745c5c3_Err != nil {
1558 return templ_7745c5c3_Err1589 return templ_7745c5c3_Err
1559 }1590 }
1560- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</tbody>")1591+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "</tbody>")
1561 if templ_7745c5c3_Err != nil {1592 if templ_7745c5c3_Err != nil {
1562 return templ_7745c5c3_Err1593 return templ_7745c5c3_Err
1563 }1594 }
1564 }1595 }
1565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</table>")1596+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</table>")
1566 if templ_7745c5c3_Err != nil {1597 if templ_7745c5c3_Err != nil {
1567 return templ_7745c5c3_Err1598 return templ_7745c5c3_Err
1568 }1599 }
1569 }1600 }
1570 if p.ShowSplit {1601 if p.ShowSplit {
1571- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<table class=\"dt spl\" aria-label=\"")1602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<table class=\"dt spl\" aria-label=\"")
1572 if templ_7745c5c3_Err != nil {1603 if templ_7745c5c3_Err != nil {
1573 return templ_7745c5c3_Err1604 return templ_7745c5c3_Err
1574 }1605 }
1575- var templ_7745c5c3_Var80 string1606+ var templ_7745c5c3_Var82 string
1576- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")1607+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
1577 if templ_7745c5c3_Err != nil {1608 if templ_7745c5c3_Err != nil {
1578- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 276, Col: 58}1609+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 283, Col: 58}
1579 }1610 }
1580- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)1611+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
1581 if templ_7745c5c3_Err != nil {1612 if templ_7745c5c3_Err != nil {
1582 return templ_7745c5c3_Err1613 return templ_7745c5c3_Err
1583 }1614 }
1584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")1615+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
1585 if templ_7745c5c3_Err != nil {1616 if templ_7745c5c3_Err != nil {
1586 return templ_7745c5c3_Err1617 return templ_7745c5c3_Err
1587 }1618 }
1588 for _, h := range f.Hunks {1619 for _, h := range f.Hunks {
1589- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<tbody>")1620+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<tbody>")
1590 if templ_7745c5c3_Err != nil {1621 if templ_7745c5c3_Err != nil {
1591 return templ_7745c5c3_Err1622 return templ_7745c5c3_Err
1592 }1623 }
@@ -1596,25 +1627,25 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1596 return templ_7745c5c3_Err1627 return templ_7745c5c3_Err
1597 }1628 }
1598 }1629 }
1599- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<tr class=\"hunk\"><td colspan=\"6\">")1630+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<tr class=\"hunk\"><td colspan=\"6\">")
1600 if templ_7745c5c3_Err != nil {1631 if templ_7745c5c3_Err != nil {
1601 return templ_7745c5c3_Err1632 return templ_7745c5c3_Err
1602 }1633 }
1603- var templ_7745c5c3_Var81 string1634+ var templ_7745c5c3_Var83 string
1604- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)1635+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1605 if templ_7745c5c3_Err != nil {1636 if templ_7745c5c3_Err != nil {
1606- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 283, Col: 51}1637+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 290, Col: 51}
1607 }1638 }
1608- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))1639+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
1609 if templ_7745c5c3_Err != nil {1640 if templ_7745c5c3_Err != nil {
1610 return templ_7745c5c3_Err1641 return templ_7745c5c3_Err
1611 }1642 }
1612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "</td></tr>")1643+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</td></tr>")
1613 if templ_7745c5c3_Err != nil {1644 if templ_7745c5c3_Err != nil {
1614 return templ_7745c5c3_Err1645 return templ_7745c5c3_Err
1615 }1646 }
1616 for _, row := range h.Split {1647 for _, row := range h.Split {
1617- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<tr>")1648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<tr>")
1618 if templ_7745c5c3_Err != nil {1649 if templ_7745c5c3_Err != nil {
1619 return templ_7745c5c3_Err1650 return templ_7745c5c3_Err
1620 }1651 }
@@ -1626,12 +1657,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1626 if templ_7745c5c3_Err != nil {1657 if templ_7745c5c3_Err != nil {
1627 return templ_7745c5c3_Err1658 return templ_7745c5c3_Err
1628 }1659 }
1629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</tr>")1660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</tr>")
1630 if templ_7745c5c3_Err != nil {1661 if templ_7745c5c3_Err != nil {
1631 return templ_7745c5c3_Err1662 return templ_7745c5c3_Err
1632 }1663 }
1633 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {1664 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
1634- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<tr class=\"fnote\"><td colspan=\"6\">")1665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<tr class=\"fnote\"><td colspan=\"6\">")
1635 if templ_7745c5c3_Err != nil {1666 if templ_7745c5c3_Err != nil {
1636 return templ_7745c5c3_Err1667 return templ_7745c5c3_Err
1637 }1668 }
@@ -1639,19 +1670,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1639 if templ_7745c5c3_Err != nil {1670 if templ_7745c5c3_Err != nil {
1640 return templ_7745c5c3_Err1671 return templ_7745c5c3_Err
1641 }1672 }
1642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</td></tr>")1673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</td></tr>")
1643 if templ_7745c5c3_Err != nil {1674 if templ_7745c5c3_Err != nil {
1644 return templ_7745c5c3_Err1675 return templ_7745c5c3_Err
1645 }1676 }
1646 }1677 }
1647 }1678 }
1648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</tbody> ")1679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</tbody> ")
1649 if templ_7745c5c3_Err != nil {1680 if templ_7745c5c3_Err != nil {
1650 return templ_7745c5c3_Err1681 return templ_7745c5c3_Err
1651 }1682 }
1652 }1683 }
1653 if f.Tail != nil {1684 if f.Tail != nil {
1654- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<tbody>")1685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "<tbody>")
1655 if templ_7745c5c3_Err != nil {1686 if templ_7745c5c3_Err != nil {
1656 return templ_7745c5c3_Err1687 return templ_7745c5c3_Err
1657 }1688 }
@@ -1659,48 +1690,79 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1659 if templ_7745c5c3_Err != nil {1690 if templ_7745c5c3_Err != nil {
1660 return templ_7745c5c3_Err1691 return templ_7745c5c3_Err
1661 }1692 }
1662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</tbody>")1693+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</tbody>")
1663 if templ_7745c5c3_Err != nil {1694 if templ_7745c5c3_Err != nil {
1664 return templ_7745c5c3_Err1695 return templ_7745c5c3_Err
1665 }1696 }
1666 }1697 }
1667- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</table>")1698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</table>")
1668 if templ_7745c5c3_Err != nil {1699 if templ_7745c5c3_Err != nil {
1669 return templ_7745c5c3_Err1700 return templ_7745c5c3_Err
1670 }1701 }
1671 }1702 }
1672- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</div>")1703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</div>")
1704+ if templ_7745c5c3_Err != nil {
1705+ return templ_7745c5c3_Err
1706+ }
1707+ } else if f.Raw != "" {
1708+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<p class=\"note pad\">")
1709+ if templ_7745c5c3_Err != nil {
1710+ return templ_7745c5c3_Err
1711+ }
1712+ var templ_7745c5c3_Var84 string
1713+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1714+ if templ_7745c5c3_Err != nil {
1715+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 313, Col: 31}
1716+ }
1717+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
1718+ if templ_7745c5c3_Err != nil {
1719+ return templ_7745c5c3_Err
1720+ }
1721+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, " <a href=\"")
1722+ if templ_7745c5c3_Err != nil {
1723+ return templ_7745c5c3_Err
1724+ }
1725+ var templ_7745c5c3_Var85 templ.SafeURL
1726+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Raw))
1727+ if templ_7745c5c3_Err != nil {
1728+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 313, Col: 64}
1729+ }
1730+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
1731+ if templ_7745c5c3_Err != nil {
1732+ return templ_7745c5c3_Err
1733+ }
1734+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "\">view raw</a></p>")
1673 if templ_7745c5c3_Err != nil {1735 if templ_7745c5c3_Err != nil {
1674 return templ_7745c5c3_Err1736 return templ_7745c5c3_Err
1675 }1737 }
1676 } else {1738 } else {
1677- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<p class=\"note pad\">")1739+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"note pad\">")
1678 if templ_7745c5c3_Err != nil {1740 if templ_7745c5c3_Err != nil {
1679 return templ_7745c5c3_Err1741 return templ_7745c5c3_Err
1680 }1742 }
1681 if f.Note != "" {1743 if f.Note != "" {
1682- var templ_7745c5c3_Var82 string1744+ var templ_7745c5c3_Var86 string
1683- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)1745+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1684 if templ_7745c5c3_Err != nil {1746 if templ_7745c5c3_Err != nil {
1685- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 308, Col: 13}1747+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 317, Col: 13}
1686 }1748 }
1687- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82))1749+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
1688 if templ_7745c5c3_Err != nil {1750 if templ_7745c5c3_Err != nil {
1689 return templ_7745c5c3_Err1751 return templ_7745c5c3_Err
1690 }1752 }
1691 } else {1753 } else {
1692- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "no textual change.")1754+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "no textual change.")
1693 if templ_7745c5c3_Err != nil {1755 if templ_7745c5c3_Err != nil {
1694 return templ_7745c5c3_Err1756 return templ_7745c5c3_Err
1695 }1757 }
1696 }1758 }
1697- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</p>")1759+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "</p>")
1698 if templ_7745c5c3_Err != nil {1760 if templ_7745c5c3_Err != nil {
1699 return templ_7745c5c3_Err1761 return templ_7745c5c3_Err
1700 }1762 }
1701 }1763 }
1702 if len(f.Loose) > 0 {1764 if len(f.Loose) > 0 {
1703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<div class=\"dloose\">")1765+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "<div class=\"dloose\">")
1704 if templ_7745c5c3_Err != nil {1766 if templ_7745c5c3_Err != nil {
1705 return templ_7745c5c3_Err1767 return templ_7745c5c3_Err
1706 }1768 }
@@ -1710,12 +1772,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1710 return templ_7745c5c3_Err1772 return templ_7745c5c3_Err
1711 }1773 }
1712 }1774 }
1713- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</div>")1775+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "</div>")
1714 if templ_7745c5c3_Err != nil {1776 if templ_7745c5c3_Err != nil {
1715 return templ_7745c5c3_Err1777 return templ_7745c5c3_Err
1716 }1778 }
1717 }1779 }
1718- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</details>")1780+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</details>")
1719 if templ_7745c5c3_Err != nil {1781 if templ_7745c5c3_Err != nil {
1720 return templ_7745c5c3_Err1782 return templ_7745c5c3_Err
1721 }1783 }
@@ -1741,9 +1803,9 @@ func MRFiles(p MRFilesPage) templ.Component {
1741 }()1803 }()
1742 }1804 }
1743 ctx = templ.InitializeContext(ctx)1805 ctx = templ.InitializeContext(ctx)
1744- templ_7745c5c3_Var83 := templ.GetChildren(ctx)1806+ templ_7745c5c3_Var87 := templ.GetChildren(ctx)
1745- if templ_7745c5c3_Var83 == nil {1807+ if templ_7745c5c3_Var87 == nil {
1746- templ_7745c5c3_Var83 = templ.NopComponent1808+ templ_7745c5c3_Var87 = templ.NopComponent
1747 }1809 }
1748 ctx = templ.ClearChildren(ctx)1810 ctx = templ.ClearChildren(ctx)
1749 for _, f := range p.Files {1811 for _, f := range p.Files {
@@ -1774,12 +1836,12 @@ func MRFilesMoved() templ.Component {
1774 }()1836 }()
1775 }1837 }
1776 ctx = templ.InitializeContext(ctx)1838 ctx = templ.InitializeContext(ctx)
1777- templ_7745c5c3_Var84 := templ.GetChildren(ctx)1839+ templ_7745c5c3_Var88 := templ.GetChildren(ctx)
1778- if templ_7745c5c3_Var84 == nil {1840+ if templ_7745c5c3_Var88 == nil {
1779- templ_7745c5c3_Var84 = templ.NopComponent1841+ templ_7745c5c3_Var88 = templ.NopComponent
1780 }1842 }
1781 ctx = templ.ClearChildren(ctx)1843 ctx = templ.ClearChildren(ctx)
1782- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")1844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
1783 if templ_7745c5c3_Err != nil {1845 if templ_7745c5c3_Err != nil {
1784 return templ_7745c5c3_Err1846 return templ_7745c5c3_Err
1785 }1847 }
@@ -1806,64 +1868,64 @@ func gapRow(g DiffGap, cols int) templ.Component {
1806 }()1868 }()
1807 }1869 }
1808 ctx = templ.InitializeContext(ctx)1870 ctx = templ.InitializeContext(ctx)
1809- templ_7745c5c3_Var85 := templ.GetChildren(ctx)1871+ templ_7745c5c3_Var89 := templ.GetChildren(ctx)
1810- if templ_7745c5c3_Var85 == nil {1872+ if templ_7745c5c3_Var89 == nil {
1811- templ_7745c5c3_Var85 = templ.NopComponent1873+ templ_7745c5c3_Var89 = templ.NopComponent
1812 }1874 }
1813 ctx = templ.ClearChildren(ctx)1875 ctx = templ.ClearChildren(ctx)
1814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<tr class=\"gap\" data-js data-gap=\"")1876+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<tr class=\"gap\" data-js data-gap=\"")
1815 if templ_7745c5c3_Err != nil {1877 if templ_7745c5c3_Err != nil {
1816 return templ_7745c5c3_Err1878 return templ_7745c5c3_Err
1817 }1879 }
1818- var templ_7745c5c3_Var86 string1880+ var templ_7745c5c3_Var90 string
1819- templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)1881+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
1820 if templ_7745c5c3_Err != nil {1882 if templ_7745c5c3_Err != nil {
1821- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 342, Col: 41}1883+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 351, Col: 41}
1822 }1884 }
1823- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)1885+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
1824 if templ_7745c5c3_Err != nil {1886 if templ_7745c5c3_Err != nil {
1825 return templ_7745c5c3_Err1887 return templ_7745c5c3_Err
1826 }1888 }
1827- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\"><td colspan=\"")1889+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "\"><td colspan=\"")
1828 if templ_7745c5c3_Err != nil {1890 if templ_7745c5c3_Err != nil {
1829 return templ_7745c5c3_Err1891 return templ_7745c5c3_Err
1830 }1892 }
1831- var templ_7745c5c3_Var87 string1893+ var templ_7745c5c3_Var91 string
1832- templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))1894+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
1833 if templ_7745c5c3_Err != nil {1895 if templ_7745c5c3_Err != nil {
1834- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 343, Col: 26}1896+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 352, Col: 26}
1835 }1897 }
1836- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)1898+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
1837 if templ_7745c5c3_Err != nil {1899 if templ_7745c5c3_Err != nil {
1838 return templ_7745c5c3_Err1900 return templ_7745c5c3_Err
1839 }1901 }
1840- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"><button type=\"button\" data-src=\"")1902+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "\"><button type=\"button\" data-src=\"")
1841 if templ_7745c5c3_Err != nil {1903 if templ_7745c5c3_Err != nil {
1842 return templ_7745c5c3_Err1904 return templ_7745c5c3_Err
1843 }1905 }
1844- var templ_7745c5c3_Var88 string1906+ var templ_7745c5c3_Var92 string
1845- templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)1907+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
1846 if templ_7745c5c3_Err != nil {1908 if templ_7745c5c3_Err != nil {
1847- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 344, Col: 41}1909+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 353, Col: 41}
1848 }1910 }
1849- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)1911+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
1850 if templ_7745c5c3_Err != nil {1912 if templ_7745c5c3_Err != nil {
1851 return templ_7745c5c3_Err1913 return templ_7745c5c3_Err
1852 }1914 }
1853- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"><span aria-hidden=\"true\">⋯</span> ")1915+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\"><span aria-hidden=\"true\">⋯</span> ")
1854 if templ_7745c5c3_Err != nil {1916 if templ_7745c5c3_Err != nil {
1855 return templ_7745c5c3_Err1917 return templ_7745c5c3_Err
1856 }1918 }
1857- var templ_7745c5c3_Var89 string1919+ var templ_7745c5c3_Var93 string
1858- templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())1920+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
1859 if templ_7745c5c3_Err != nil {1921 if templ_7745c5c3_Err != nil {
1860- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 345, Col: 51}1922+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 354, Col: 51}
1861 }1923 }
1862- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))1924+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
1863 if templ_7745c5c3_Err != nil {1925 if templ_7745c5c3_Err != nil {
1864 return templ_7745c5c3_Err1926 return templ_7745c5c3_Err
1865 }1927 }
1866- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</button></td></tr>")1928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</button></td></tr>")
1867 if templ_7745c5c3_Err != nil {1929 if templ_7745c5c3_Err != nil {
1868 return templ_7745c5c3_Err1930 return templ_7745c5c3_Err
1869 }1931 }
@@ -1889,56 +1951,56 @@ func DiffContext(c ContextRows) templ.Component {
1889 }()1951 }()
1890 }1952 }
1891 ctx = templ.InitializeContext(ctx)1953 ctx = templ.InitializeContext(ctx)
1892- templ_7745c5c3_Var90 := templ.GetChildren(ctx)1954+ templ_7745c5c3_Var94 := templ.GetChildren(ctx)
1893- if templ_7745c5c3_Var90 == nil {1955+ if templ_7745c5c3_Var94 == nil {
1894- templ_7745c5c3_Var90 = templ.NopComponent1956+ templ_7745c5c3_Var94 = templ.NopComponent
1895 }1957 }
1896 ctx = templ.ClearChildren(ctx)1958 ctx = templ.ClearChildren(ctx)
1897- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<table><tbody data-for=\"uni\">")1959+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<table><tbody data-for=\"uni\">")
1898 if templ_7745c5c3_Err != nil {1960 if templ_7745c5c3_Err != nil {
1899 return templ_7745c5c3_Err1961 return templ_7745c5c3_Err
1900 }1962 }
1901 for _, ln := range c.Lines {1963 for _, ln := range c.Lines {
1902- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<tr class=\"ctx\"><td class=\"ln\">")1964+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<tr class=\"ctx\"><td class=\"ln\">")
1903 if templ_7745c5c3_Err != nil {1965 if templ_7745c5c3_Err != nil {
1904 return templ_7745c5c3_Err1966 return templ_7745c5c3_Err
1905 }1967 }
1906- var templ_7745c5c3_Var91 string1968+ var templ_7745c5c3_Var95 string
1907- templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)1969+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
1908 if templ_7745c5c3_Err != nil {1970 if templ_7745c5c3_Err != nil {
1909- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 358, Col: 28}1971+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 367, Col: 28}
1910 }1972 }
1911- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))1973+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
1912 if templ_7745c5c3_Err != nil {1974 if templ_7745c5c3_Err != nil {
1913 return templ_7745c5c3_Err1975 return templ_7745c5c3_Err
1914 }1976 }
1915- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "</td><td class=\"ln\">")1977+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</td><td class=\"ln\">")
1916 if templ_7745c5c3_Err != nil {1978 if templ_7745c5c3_Err != nil {
1917 return templ_7745c5c3_Err1979 return templ_7745c5c3_Err
1918 }1980 }
1919- var templ_7745c5c3_Var92 string1981+ var templ_7745c5c3_Var96 string
1920- templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)1982+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
1921 if templ_7745c5c3_Err != nil {1983 if templ_7745c5c3_Err != nil {
1922- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 359, Col: 28}1984+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 368, Col: 28}
1923 }1985 }
1924- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))1986+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
1925 if templ_7745c5c3_Err != nil {1987 if templ_7745c5c3_Err != nil {
1926 return templ_7745c5c3_Err1988 return templ_7745c5c3_Err
1927 }1989 }
1928- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "</td><td class=\"m\">")1990+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</td><td class=\"m\">")
1929 if templ_7745c5c3_Err != nil {1991 if templ_7745c5c3_Err != nil {
1930 return templ_7745c5c3_Err1992 return templ_7745c5c3_Err
1931 }1993 }
1932- var templ_7745c5c3_Var93 string1994+ var templ_7745c5c3_Var97 string
1933- templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)1995+ templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1934 if templ_7745c5c3_Err != nil {1996 if templ_7745c5c3_Err != nil {
1935- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 360, Col: 28}1997+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 369, Col: 28}
1936 }1998 }
1937- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))1999+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
1938 if templ_7745c5c3_Err != nil {2000 if templ_7745c5c3_Err != nil {
1939 return templ_7745c5c3_Err2001 return templ_7745c5c3_Err
1940 }2002 }
1941- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</td><td class=\"tx chroma\">")2003+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</td><td class=\"tx chroma\">")
1942 if templ_7745c5c3_Err != nil {2004 if templ_7745c5c3_Err != nil {
1943 return templ_7745c5c3_Err2005 return templ_7745c5c3_Err
1944 }2006 }
@@ -1946,7 +2008,7 @@ func DiffContext(c ContextRows) templ.Component {
1946 if templ_7745c5c3_Err != nil {2008 if templ_7745c5c3_Err != nil {
1947 return templ_7745c5c3_Err2009 return templ_7745c5c3_Err
1948 }2010 }
1949- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td></tr>")2011+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</td></tr>")
1950 if templ_7745c5c3_Err != nil {2012 if templ_7745c5c3_Err != nil {
1951 return templ_7745c5c3_Err2013 return templ_7745c5c3_Err
1952 }2014 }
@@ -1957,12 +2019,12 @@ func DiffContext(c ContextRows) templ.Component {
1957 return templ_7745c5c3_Err2019 return templ_7745c5c3_Err
1958 }2020 }
1959 }2021 }
1960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</tbody> <tbody data-for=\"spl\">")2022+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</tbody> <tbody data-for=\"spl\">")
1961 if templ_7745c5c3_Err != nil {2023 if templ_7745c5c3_Err != nil {
1962 return templ_7745c5c3_Err2024 return templ_7745c5c3_Err
1963 }2025 }
1964 for i := range c.Lines {2026 for i := range c.Lines {
1965- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<tr>")2027+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<tr>")
1966 if templ_7745c5c3_Err != nil {2028 if templ_7745c5c3_Err != nil {
1967 return templ_7745c5c3_Err2029 return templ_7745c5c3_Err
1968 }2030 }
@@ -1974,7 +2036,7 @@ func DiffContext(c ContextRows) templ.Component {
1974 if templ_7745c5c3_Err != nil {2036 if templ_7745c5c3_Err != nil {
1975 return templ_7745c5c3_Err2037 return templ_7745c5c3_Err
1976 }2038 }
1977- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</tr>")2039+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</tr>")
1978 if templ_7745c5c3_Err != nil {2040 if templ_7745c5c3_Err != nil {
1979 return templ_7745c5c3_Err2041 return templ_7745c5c3_Err
1980 }2042 }
@@ -1985,7 +2047,7 @@ func DiffContext(c ContextRows) templ.Component {
1985 return templ_7745c5c3_Err2047 return templ_7745c5c3_Err
1986 }2048 }
1987 }2049 }
1988- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</tbody></table>")2050+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</tbody></table>")
1989 if templ_7745c5c3_Err != nil {2051 if templ_7745c5c3_Err != nil {
1990 return templ_7745c5c3_Err2052 return templ_7745c5c3_Err
1991 }2053 }
@@ -2011,36 +2073,36 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2011 }()2073 }()
2012 }2074 }
2013 ctx = templ.InitializeContext(ctx)2075 ctx = templ.InitializeContext(ctx)
2014- templ_7745c5c3_Var94 := templ.GetChildren(ctx)2076+ templ_7745c5c3_Var98 := templ.GetChildren(ctx)
2015- if templ_7745c5c3_Var94 == nil {2077+ if templ_7745c5c3_Var98 == nil {
2016- templ_7745c5c3_Var94 = templ.NopComponent2078+ templ_7745c5c3_Var98 = templ.NopComponent
2017 }2079 }
2018 ctx = templ.ClearChildren(ctx)2080 ctx = templ.ClearChildren(ctx)
2019 if ln == nil {2081 if ln == nil {
2020- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")2082+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
2021 if templ_7745c5c3_Err != nil {2083 if templ_7745c5c3_Err != nil {
2022 return templ_7745c5c3_Err2084 return templ_7745c5c3_Err
2023 }2085 }
2024 } else {2086 } else {
2025- var templ_7745c5c3_Var95 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}2087+ var templ_7745c5c3_Var99 = []any{"ln", ln.Kind, templ.KV("sel", ln.Selected)}
2026- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var95...)2088+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var99...)
2027 if templ_7745c5c3_Err != nil {2089 if templ_7745c5c3_Err != nil {
2028 return templ_7745c5c3_Err2090 return templ_7745c5c3_Err
2029 }2091 }
2030- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<td class=\"")2092+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"")
2031 if templ_7745c5c3_Err != nil {2093 if templ_7745c5c3_Err != nil {
2032 return templ_7745c5c3_Err2094 return templ_7745c5c3_Err
2033 }2095 }
2034- var templ_7745c5c3_Var96 string2096+ var templ_7745c5c3_Var100 string
2035- templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var95).String())2097+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var99).String())
2036 if templ_7745c5c3_Err != nil {2098 if templ_7745c5c3_Err != nil {
2037 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}2099 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
2038 }2100 }
2039- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var96)2101+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var100)
2040 if templ_7745c5c3_Err != nil {2102 if templ_7745c5c3_Err != nil {
2041 return templ_7745c5c3_Err2103 return templ_7745c5c3_Err
2042 }2104 }
2043- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "\">")2105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\">")
2044 if templ_7745c5c3_Err != nil {2106 if templ_7745c5c3_Err != nil {
2045 return templ_7745c5c3_Err2107 return templ_7745c5c3_Err
2046 }2108 }
@@ -2055,64 +2117,64 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2055 return templ_7745c5c3_Err2117 return templ_7745c5c3_Err
2056 }2118 }
2057 }2119 }
2058- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "</td>")2120+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</td>")
2059 if templ_7745c5c3_Err != nil {2121 if templ_7745c5c3_Err != nil {
2060 return templ_7745c5c3_Err2122 return templ_7745c5c3_Err
2061 }2123 }
2062- var templ_7745c5c3_Var97 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}2124+ var templ_7745c5c3_Var101 = []any{"m", ln.Kind, templ.KV("sel", ln.Selected)}
2063- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var97...)2125+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var101...)
2064 if templ_7745c5c3_Err != nil {2126 if templ_7745c5c3_Err != nil {
2065 return templ_7745c5c3_Err2127 return templ_7745c5c3_Err
2066 }2128 }
2067- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<td class=\"")2129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<td class=\"")
2068 if templ_7745c5c3_Err != nil {2130 if templ_7745c5c3_Err != nil {
2069 return templ_7745c5c3_Err2131 return templ_7745c5c3_Err
2070 }2132 }
2071- var templ_7745c5c3_Var98 string2133+ var templ_7745c5c3_Var102 string
2072- templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var97).String())2134+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var101).String())
2073 if templ_7745c5c3_Err != nil {2135 if templ_7745c5c3_Err != nil {
2074 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}2136 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
2075 }2137 }
2076- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var98)2138+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var102)
2077 if templ_7745c5c3_Err != nil {2139 if templ_7745c5c3_Err != nil {
2078 return templ_7745c5c3_Err2140 return templ_7745c5c3_Err
2079 }2141 }
2080- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "\">")2142+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\">")
2081 if templ_7745c5c3_Err != nil {2143 if templ_7745c5c3_Err != nil {
2082 return templ_7745c5c3_Err2144 return templ_7745c5c3_Err
2083 }2145 }
2084- var templ_7745c5c3_Var99 string2146+ var templ_7745c5c3_Var103 string
2085- templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)2147+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
2086 if templ_7745c5c3_Err != nil {2148 if templ_7745c5c3_Err != nil {
2087- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 397, Col: 68}2149+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 68}
2088 }2150 }
2089- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))2151+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
2090 if templ_7745c5c3_Err != nil {2152 if templ_7745c5c3_Err != nil {
2091 return templ_7745c5c3_Err2153 return templ_7745c5c3_Err
2092 }2154 }
2093- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</td>")2155+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</td>")
2094 if templ_7745c5c3_Err != nil {2156 if templ_7745c5c3_Err != nil {
2095 return templ_7745c5c3_Err2157 return templ_7745c5c3_Err
2096 }2158 }
2097- var templ_7745c5c3_Var100 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}2159+ var templ_7745c5c3_Var104 = []any{"tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected)}
2098- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var100...)2160+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var104...)
2099 if templ_7745c5c3_Err != nil {2161 if templ_7745c5c3_Err != nil {
2100 return templ_7745c5c3_Err2162 return templ_7745c5c3_Err
2101 }2163 }
2102- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"")2164+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "<td class=\"")
2103 if templ_7745c5c3_Err != nil {2165 if templ_7745c5c3_Err != nil {
2104 return templ_7745c5c3_Err2166 return templ_7745c5c3_Err
2105 }2167 }
2106- var templ_7745c5c3_Var101 string2168+ var templ_7745c5c3_Var105 string
2107- templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var100).String())2169+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var104).String())
2108 if templ_7745c5c3_Err != nil {2170 if templ_7745c5c3_Err != nil {
2109 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}2171 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
2110 }2172 }
2111- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var101)2173+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
2112 if templ_7745c5c3_Err != nil {2174 if templ_7745c5c3_Err != nil {
2113 return templ_7745c5c3_Err2175 return templ_7745c5c3_Err
2114 }2176 }
2115- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\">")2177+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "\">")
2116 if templ_7745c5c3_Err != nil {2178 if templ_7745c5c3_Err != nil {
2117 return templ_7745c5c3_Err2179 return templ_7745c5c3_Err
2118 }2180 }
@@ -2120,7 +2182,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2120 if templ_7745c5c3_Err != nil {2182 if templ_7745c5c3_Err != nil {
2121 return templ_7745c5c3_Err2183 return templ_7745c5c3_Err
2122 }2184 }
2123- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</td>")2185+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "</td>")
2124 if templ_7745c5c3_Err != nil {2186 if templ_7745c5c3_Err != nil {
2125 return templ_7745c5c3_Err2187 return templ_7745c5c3_Err
2126 }2188 }
@@ -2147,49 +2209,49 @@ func gutter(n, href string) templ.Component {
2147 }()2209 }()
2148 }2210 }
2149 ctx = templ.InitializeContext(ctx)2211 ctx = templ.InitializeContext(ctx)
2150- templ_7745c5c3_Var102 := templ.GetChildren(ctx)2212+ templ_7745c5c3_Var106 := templ.GetChildren(ctx)
2151- if templ_7745c5c3_Var102 == nil {2213+ if templ_7745c5c3_Var106 == nil {
2152- templ_7745c5c3_Var102 = templ.NopComponent2214+ templ_7745c5c3_Var106 = templ.NopComponent
2153 }2215 }
2154 ctx = templ.ClearChildren(ctx)2216 ctx = templ.ClearChildren(ctx)
2155 if href != "" {2217 if href != "" {
2156- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<a href=\"")2218+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "<a href=\"")
2157 if templ_7745c5c3_Err != nil {2219 if templ_7745c5c3_Err != nil {
2158 return templ_7745c5c3_Err2220 return templ_7745c5c3_Err
2159 }2221 }
2160- var templ_7745c5c3_Var103 templ.SafeURL2222+ var templ_7745c5c3_Var107 templ.SafeURL
2161- templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))2223+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
2162 if templ_7745c5c3_Err != nil {2224 if templ_7745c5c3_Err != nil {
2163- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 31}2225+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 415, Col: 31}
2164 }2226 }
2165- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))2227+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
2166 if templ_7745c5c3_Err != nil {2228 if templ_7745c5c3_Err != nil {
2167 return templ_7745c5c3_Err2229 return templ_7745c5c3_Err
2168 }2230 }
2169- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\" title=\"comment on this line\">")2231+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "\" title=\"comment on this line\">")
2170 if templ_7745c5c3_Err != nil {2232 if templ_7745c5c3_Err != nil {
2171 return templ_7745c5c3_Err2233 return templ_7745c5c3_Err
2172 }2234 }
2173- var templ_7745c5c3_Var104 string2235+ var templ_7745c5c3_Var108 string
2174- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(n)2236+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2175 if templ_7745c5c3_Err != nil {2237 if templ_7745c5c3_Err != nil {
2176- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 66}2238+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 415, Col: 66}
2177 }2239 }
2178- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))2240+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
2179 if templ_7745c5c3_Err != nil {2241 if templ_7745c5c3_Err != nil {
2180 return templ_7745c5c3_Err2242 return templ_7745c5c3_Err
2181 }2243 }
2182- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</a>")2244+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "</a>")
2183 if templ_7745c5c3_Err != nil {2245 if templ_7745c5c3_Err != nil {
2184 return templ_7745c5c3_Err2246 return templ_7745c5c3_Err
2185 }2247 }
2186 } else {2248 } else {
2187- var templ_7745c5c3_Var105 string2249+ var templ_7745c5c3_Var109 string
2188- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(n)2250+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2189 if templ_7745c5c3_Err != nil {2251 if templ_7745c5c3_Err != nil {
2190- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 408, Col: 5}2252+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 417, Col: 5}
2191 }2253 }
2192- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))2254+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
2193 if templ_7745c5c3_Err != nil {2255 if templ_7745c5c3_Err != nil {
2194 return templ_7745c5c3_Err2256 return templ_7745c5c3_Err
2195 }2257 }
M internal/adapters/web/diff_test.go +51 -2
@@ -99,7 +99,10 @@ func TestFileNotes(t *testing.T) {
9999 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
100100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
101101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
102- {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false},
102+ {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},
103+ {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true},
104+ {"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true},
105+ {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true},
103106 }
104107 for _, tt := range tests {
105108 t.Run(tt.name, func(t *testing.T) {
@@ -120,7 +123,7 @@ func TestDiffFilesPaths(t *testing.T) {
120123 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
121124 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
122125 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
123- {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000},
126+ {Status: domain.FileModified, NewPath: "big.go", Omitted: domain.OmitLarge, Additions: 3000},
124127 })
125128 assert.Equal(t, "a.go", fs[0].Path)
126129 assert.True(t, fs[0].Open)
@@ -191,3 +194,49 @@ func TestContextGaps(t *testing.T) {
191194 contextGaps(o, odd, "/r/context/c")
192195 assert.Nil(t, o[0].Hunks[0].Gap)
193196 }
197+
198+func TestDiffNote(t *testing.T) {
199+ t.Parallel()
200+ files := func(w ...domain.FileOmission) domain.Diff {
201+ var d domain.Diff
202+ for _, o := range w {
203+ d.Files = append(d.Files, domain.FileDiff{Omitted: o})
204+ }
205+ return d
206+ }
207+ assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note")
208+ assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.",
209+ diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable)))
210+}
211+
212+func TestCollapsedFileMarkup(t *testing.T) {
213+ t.Parallel()
214+ view := DiffView{ShowUnified: true}
215+ src := "/demo/commit/abc1234/files"
216+ for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} {
217+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}}
218+ fs := diffFiles(raw)
219+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
220+ out := renderToString(t, DiffFileView(fs[0], view))
221+ assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why)
222+ assert.Regexp(t, `<details `, out, "the file is a details element")
223+ assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
224+ assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
225+ assert.NotContains(t, out, "<table")
226+ }
227+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}}
228+ fs := diffFiles(raw)
229+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
230+ out := renderToString(t, DiffFileView(fs[0], view))
231+ assert.Contains(t, out, "too large to show in the browser")
232+ assert.NotContains(t, out, "load diff")
233+ assert.NotContains(t, out, "data-manual")
234+ assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`)
235+
236+ // Past the first window of a merge request the link keeps the window.
237+ raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}}
238+ fs = diffFiles(raw)
239+ collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25})
240+ assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src)
241+ assert.Equal(t, "?from=25&file=0#f1", fs[0].Href)
242+}
@@ -99,7 +99,10 @@ func TestFileNotes(t *testing.T) {
99 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},99 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
102- {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false},102+ {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},
103+ {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true},
104+ {"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true},
105+ {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true},
103 }106 }
104 for _, tt := range tests {107 for _, tt := range tests {
105 t.Run(tt.name, func(t *testing.T) {108 t.Run(tt.name, func(t *testing.T) {
@@ -120,7 +123,7 @@ func TestDiffFilesPaths(t *testing.T) {
120 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},123 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
121 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},124 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
122 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},125 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
123- {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000},126+ {Status: domain.FileModified, NewPath: "big.go", Omitted: domain.OmitLarge, Additions: 3000},
124 })127 })
125 assert.Equal(t, "a.go", fs[0].Path)128 assert.Equal(t, "a.go", fs[0].Path)
126 assert.True(t, fs[0].Open)129 assert.True(t, fs[0].Open)
@@ -191,3 +194,49 @@ func TestContextGaps(t *testing.T) {
191 contextGaps(o, odd, "/r/context/c")194 contextGaps(o, odd, "/r/context/c")
192 assert.Nil(t, o[0].Hunks[0].Gap)195 assert.Nil(t, o[0].Hunks[0].Gap)
193 }196 }
197+
198+func TestDiffNote(t *testing.T) {
199+ t.Parallel()
200+ files := func(w ...domain.FileOmission) domain.Diff {
201+ var d domain.Diff
202+ for _, o := range w {
203+ d.Files = append(d.Files, domain.FileDiff{Omitted: o})
204+ }
205+ return d
206+ }
207+ assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note")
208+ assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.",
209+ diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable)))
210+}
211+
212+func TestCollapsedFileMarkup(t *testing.T) {
213+ t.Parallel()
214+ view := DiffView{ShowUnified: true}
215+ src := "/demo/commit/abc1234/files"
216+ for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} {
217+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}}
218+ fs := diffFiles(raw)
219+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
220+ out := renderToString(t, DiffFileView(fs[0], view))
221+ assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why)
222+ assert.Regexp(t, `<details `, out, "the file is a details element")
223+ assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
224+ assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
225+ assert.NotContains(t, out, "<table")
226+ }
227+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}}
228+ fs := diffFiles(raw)
229+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
230+ out := renderToString(t, DiffFileView(fs[0], view))
231+ assert.Contains(t, out, "too large to show in the browser")
232+ assert.NotContains(t, out, "load diff")
233+ assert.NotContains(t, out, "data-manual")
234+ assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`)
235+
236+ // Past the first window of a merge request the link keeps the window.
237+ raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}}
238+ fs = diffFiles(raw)
239+ collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25})
240+ assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src)
241+ assert.Equal(t, "?from=25&file=0#f1", fs[0].Href)
242+}
A internal/adapters/web/jstest/lazy.test.mjs +189 -0
@@ -0,0 +1,189 @@
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() {} };
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+// A file of the page (old) or of a fragment (new), with the queries swap makes.
103+function fileEl(id, { lazy = false, manual = false, open = false } = {}) {
104+ const f = { id, open, queried: [] };
105+ f.querySelector = (sel) => {
106+ if (sel === '.dlazy[data-manual]') return manual ? {} : null;
107+ if (sel === '.dlazy') return lazy || manual ? {} : null;
108+ if (sel === '.ln a') return f.line || null;
109+ return null;
110+ };
111+ f.querySelectorAll = () => [];
112+ return f;
113+}
114+
115+test('a loaded manual file opens and takes the focus without scrolling', async () => {
116+ const a = placeholder('/f?one=1&from=0', true);
117+ const old = fileEl('f1', { manual: true });
118+ const fresh = fileEl('f1');
119+ const focused = [];
120+ fresh.line = { focus: (o) => focused.push(o) };
121+ let current = old;
122+ old.replaceWith = (n) => { current = n; };
123+ try {
124+ await setup([a.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => current });
125+ window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() {} });
126+ await settle(); await settle();
127+ assert.equal(current, fresh);
128+ assert.equal(fresh.open, true);
129+ assert.deepEqual(focused, [{ preventScroll: true }]);
130+ } finally { await settle(); restore(); }
131+});
132+
133+test('a window that still holds a collapsed file keeps it closed', async () => {
134+ const w = placeholder('/w?from=25', false);
135+ const old = fileEl('f26', { lazy: true, open: true });
136+ const fresh = fileEl('f26', { manual: true, open: true });
137+ let replaced = null;
138+ old.replaceWith = (n) => { replaced = n; };
139+ try {
140+ await setup([w.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => old });
141+ await window.ermiteLazy.load(w.ph);
142+ assert.equal(replaced, fresh);
143+ assert.equal(fresh.open, false);
144+ } finally { await settle(); restore(); }
145+});
146+
147+test('a 409 makes the page stale: no later load fetches', async () => {
148+ const a = placeholder('/f?one=1&from=0', true);
149+ const b = placeholder('/f?one=1&from=1', true);
150+ try {
151+ const { urls } = await setup([a.ph, b.ph], true, { reply: { status: 409, ok: false, body: '<p>moved</p>' } });
152+ await window.ermiteLazy.load(a.ph);
153+ assert.equal(a.ph.innerHTML, '<p>moved</p>');
154+ assert.equal(urls.length, 1);
155+ await window.ermiteLazy.load(b.ph);
156+ assert.equal(urls.length, 1, 'stale: nothing is fetched');
157+ } finally { await settle(); restore(); }
158+});
159+
160+test('a failed load clears the loading mark and can be retried', async () => {
161+ const a = placeholder('/f?one=1&from=0', true);
162+ const marks = new Set();
163+ a.ph.setAttribute = (n) => marks.add(n);
164+ a.ph.removeAttribute = (n) => marks.delete(n);
165+ try {
166+ const { urls } = await setup([a.ph], true, { reply: { status: 500, ok: false, body: 'boom' } });
167+ const p = window.ermiteLazy.load(a.ph);
168+ assert.ok(marks.has('data-loading'));
169+ await p;
170+ assert.equal(marks.has('data-loading'), false);
171+ await window.ermiteLazy.load(a.ph);
172+ assert.equal(urls.length, 2, 'a retry fetches again');
173+ } finally { await settle(); restore(); }
174+});
175+
176+test('Space is prevented on the link; a held key is ignored', async () => {
177+ const a = placeholder('/f?one=1&from=1', true);
178+ try {
179+ const { urls } = await setup([a.ph]);
180+ let prevented = 0;
181+ const ev = (extra) => ({ target: a.link, key: ' ', preventDefault() { prevented++; }, ...extra });
182+ assert.equal(window.ermiteLazy.onKey(ev({ repeat: true })), false);
183+ assert.equal(prevented, 0);
184+ assert.equal(urls.length, 0);
185+ assert.equal(window.ermiteLazy.onKey(ev({})), true);
186+ assert.equal(prevented, 1, 'Space does not scroll the page');
187+ assert.equal(urls.length, 1);
188+ } finally { await settle(); restore(); }
189+});
@@ -0,0 +1,189 @@
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() {} };
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+// A file of the page (old) or of a fragment (new), with the queries swap makes.
103+function fileEl(id, { lazy = false, manual = false, open = false } = {}) {
104+ const f = { id, open, queried: [] };
105+ f.querySelector = (sel) => {
106+ if (sel === '.dlazy[data-manual]') return manual ? {} : null;
107+ if (sel === '.dlazy') return lazy || manual ? {} : null;
108+ if (sel === '.ln a') return f.line || null;
109+ return null;
110+ };
111+ f.querySelectorAll = () => [];
112+ return f;
113+}
114+
115+test('a loaded manual file opens and takes the focus without scrolling', async () => {
116+ const a = placeholder('/f?one=1&from=0', true);
117+ const old = fileEl('f1', { manual: true });
118+ const fresh = fileEl('f1');
119+ const focused = [];
120+ fresh.line = { focus: (o) => focused.push(o) };
121+ let current = old;
122+ old.replaceWith = (n) => { current = n; };
123+ try {
124+ await setup([a.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => current });
125+ window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() {} });
126+ await settle(); await settle();
127+ assert.equal(current, fresh);
128+ assert.equal(fresh.open, true);
129+ assert.deepEqual(focused, [{ preventScroll: true }]);
130+ } finally { await settle(); restore(); }
131+});
132+
133+test('a window that still holds a collapsed file keeps it closed', async () => {
134+ const w = placeholder('/w?from=25', false);
135+ const old = fileEl('f26', { lazy: true, open: true });
136+ const fresh = fileEl('f26', { manual: true, open: true });
137+ let replaced = null;
138+ old.replaceWith = (n) => { replaced = n; };
139+ try {
140+ await setup([w.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => old });
141+ await window.ermiteLazy.load(w.ph);
142+ assert.equal(replaced, fresh);
143+ assert.equal(fresh.open, false);
144+ } finally { await settle(); restore(); }
145+});
146+
147+test('a 409 makes the page stale: no later load fetches', async () => {
148+ const a = placeholder('/f?one=1&from=0', true);
149+ const b = placeholder('/f?one=1&from=1', true);
150+ try {
151+ const { urls } = await setup([a.ph, b.ph], true, { reply: { status: 409, ok: false, body: '<p>moved</p>' } });
152+ await window.ermiteLazy.load(a.ph);
153+ assert.equal(a.ph.innerHTML, '<p>moved</p>');
154+ assert.equal(urls.length, 1);
155+ await window.ermiteLazy.load(b.ph);
156+ assert.equal(urls.length, 1, 'stale: nothing is fetched');
157+ } finally { await settle(); restore(); }
158+});
159+
160+test('a failed load clears the loading mark and can be retried', async () => {
161+ const a = placeholder('/f?one=1&from=0', true);
162+ const marks = new Set();
163+ a.ph.setAttribute = (n) => marks.add(n);
164+ a.ph.removeAttribute = (n) => marks.delete(n);
165+ try {
166+ const { urls } = await setup([a.ph], true, { reply: { status: 500, ok: false, body: 'boom' } });
167+ const p = window.ermiteLazy.load(a.ph);
168+ assert.ok(marks.has('data-loading'));
169+ await p;
170+ assert.equal(marks.has('data-loading'), false);
171+ await window.ermiteLazy.load(a.ph);
172+ assert.equal(urls.length, 2, 'a retry fetches again');
173+ } finally { await settle(); restore(); }
174+});
175+
176+test('Space is prevented on the link; a held key is ignored', async () => {
177+ const a = placeholder('/f?one=1&from=1', true);
178+ try {
179+ const { urls } = await setup([a.ph]);
180+ let prevented = 0;
181+ const ev = (extra) => ({ target: a.link, key: ' ', preventDefault() { prevented++; }, ...extra });
182+ assert.equal(window.ermiteLazy.onKey(ev({ repeat: true })), false);
183+ assert.equal(prevented, 0);
184+ assert.equal(urls.length, 0);
185+ assert.equal(window.ermiteLazy.onKey(ev({})), true);
186+ assert.equal(prevented, 1, 'Space does not scroll the page');
187+ assert.equal(urls.length, 1);
188+ } finally { await settle(); restore(); }
189+});
M internal/adapters/web/jstest/mr.test.mjs +1 -0
@@ -5,6 +5,7 @@ import { test } from 'node:test';
55
66 globalThis.window = globalThis;
77 globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };
8+await import('../static/js/lazy.js');
89 await import('../static/js/mr.js');
910 const C = window.ermiteMR.core;
1011
@@ -5,6 +5,7 @@ import { test } from 'node:test';
5 5
6 globalThis.window = globalThis;6 globalThis.window = globalThis;
7 globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };7 globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };
8+await import('../static/js/lazy.js');
8 await import('../static/js/mr.js');9 await import('../static/js/mr.js');
9 const C = window.ermiteMR.core;10 const C = window.ermiteMR.core;
10 11
M internal/adapters/web/mr.go +23 -2
@@ -4,6 +4,7 @@ package web
44
55 import (
66 "context"
7+ "errors"
78 "html"
89 "html/template"
910 "io"
@@ -568,6 +569,21 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
568569 if from > 0 && from >= len(res.Diff.Files) {
569570 return nil, 0, errNotFound
570571 }
572+ // A page without scripts: ?file=i reads that collapsed file in full.
573+ // Without a pinned base, or when the pair moved since the first read, the
574+ // page is shown unspliced.
575+ if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() && !res.DiffBaseID.IsZero() {
576+ one, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
577+ SinceLastRead: since, DiffOnly: true, Base: res.DiffBaseID, Head: res.MergeRequest.HeadID,
578+ Files: &ports.FileWindow{From: i, To: i + 1, Full: true},
579+ })
580+ if err != nil && !errors.Is(err, domain.ErrConflict) {
581+ return nil, 0, err
582+ }
583+ if err == nil && i < len(one.Diff.Files) {
584+ res.Diff.Files[i] = one.Diff.Files[i]
585+ }
586+ }
571587 m := res.MergeRequest
572588 signed := SignedIn(ctx)
573589 open := m.State == domain.MRStateOpen
@@ -589,11 +605,16 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
589605 Files: s.hl.files(ctx, res.Diff.Files),
590606 }
591607 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
608+ src := ""
609+ if !res.DiffBaseID.IsZero() {
610+ src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
611+ }
612+ collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
592613 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
593614 return nil, 0, err
594615 }
595616
596- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
617+ p.Note = diffNote(res.Diff)
597618 countNotes(p.Files, res.Comments)
598619 if !res.DiffBaseID.IsZero() {
599620 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
@@ -648,7 +669,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
648669 p.Live = true
649670 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))
650671 l.HasRail = true
651- l.MR = true
672+ l.MR, l.Lazy = true, true
652673 return MergeRequest(l, p), http.StatusOK, nil
653674 }
654675
@@ -4,6 +4,7 @@ package web
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "html"8 "html"
8 "html/template"9 "html/template"
9 "io"10 "io"
@@ -568,6 +569,21 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
568 if from > 0 && from >= len(res.Diff.Files) {569 if from > 0 && from >= len(res.Diff.Files) {
569 return nil, 0, errNotFound570 return nil, 0, errNotFound
570 }571 }
572+ // A page without scripts: ?file=i reads that collapsed file in full.
573+ // Without a pinned base, or when the pair moved since the first read, the
574+ // page is shown unspliced.
575+ if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() && !res.DiffBaseID.IsZero() {
576+ one, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
577+ SinceLastRead: since, DiffOnly: true, Base: res.DiffBaseID, Head: res.MergeRequest.HeadID,
578+ Files: &ports.FileWindow{From: i, To: i + 1, Full: true},
579+ })
580+ if err != nil && !errors.Is(err, domain.ErrConflict) {
581+ return nil, 0, err
582+ }
583+ if err == nil && i < len(one.Diff.Files) {
584+ res.Diff.Files[i] = one.Diff.Files[i]
585+ }
586+ }
571 m := res.MergeRequest587 m := res.MergeRequest
572 signed := SignedIn(ctx)588 signed := SignedIn(ctx)
573 open := m.State == domain.MRStateOpen589 open := m.State == domain.MRStateOpen
@@ -589,11 +605,16 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
589 Files: s.hl.files(ctx, res.Diff.Files),605 Files: s.hl.files(ctx, res.Diff.Files),
590 }606 }
591 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))607 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
608+ src := ""
609+ if !res.DiffBaseID.IsZero() {
610+ src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
611+ }
612+ collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
592 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {613 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
593 return nil, 0, err614 return nil, 0, err
594 }615 }
595 616
596- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)617+ p.Note = diffNote(res.Diff)
597 countNotes(p.Files, res.Comments)618 countNotes(p.Files, res.Comments)
598 if !res.DiffBaseID.IsZero() {619 if !res.DiffBaseID.IsZero() {
599 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))620 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
@@ -648,7 +669,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
648 p.Live = true669 p.Live = true
649 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))670 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))
650 l.HasRail = true671 l.HasRail = true
651- l.MR = true672+ l.MR, l.Lazy = true, true
652 return MergeRequest(l, p), http.StatusOK, nil673 return MergeRequest(l, p), http.StatusOK, nil
653 }674 }
654 675
M internal/adapters/web/mr_files.go +36 -10
@@ -73,6 +73,18 @@ func parseFrom(r *http.Request) (int, error) {
7373 return n, nil
7474 }
7575
76+// parseOne reads ?one=1&from=i of a one-file fragment: the index i of the
77+// file, any non-negative number.
78+func parseOne(r *http.Request) (int, error) {
79+ q := r.URL.Query()
80+ s := q.Get("from")
81+ n, err := strconv.Atoi(s)
82+ if q.Get("one") != "1" || err != nil || n < 0 || strconv.Itoa(n) != s {
83+ return 0, errNotFound
84+ }
85+ return n, nil
86+}
87+
7688 // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows
7789 // a placeholder that loads their window. src is the URL of /files with the
7890 // base and the head of the diff; self the page, for the links of a page
@@ -167,7 +179,13 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
167179 if since && !SignedIn(ctx) {
168180 return nil, 0, errNotFound
169181 }
170- from, err := parseFrom(r)
182+ one := q.Get("one") == "1"
183+ var from int
184+ if one {
185+ from, err = parseOne(r)
186+ } else {
187+ from, err = parseFrom(r)
188+ }
171189 if err != nil {
172190 return nil, 0, err
173191 }
@@ -180,9 +198,12 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
180198 if err != nil {
181199 return nil, 0, err
182200 }
201+ win := &ports.FileWindow{From: from, To: from + mrWindow}
202+ if one {
203+ win = &ports.FileWindow{From: from, To: from + 1, Full: true}
204+ }
183205 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
184- SinceLastRead: since, DiffOnly: true, Base: base, Head: head,
185- Files: &ports.FileWindow{From: from, To: from + mrWindow},
206+ SinceLastRead: since, DiffOnly: true, Base: base, Head: head, Files: win,
186207 })
187208 if errors.Is(err, domain.ErrConflict) {
188209 return MRFilesMoved(), http.StatusConflict, nil
@@ -198,8 +219,19 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
198219 canWrite := signed && m.State == domain.MRStateOpen && !since
199220 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200221 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
222+ links := fileLinks{src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String()}
223+ if one {
224+ // A one-file read holds file from alone, at index from: the load
225+ // addresses of a window do not apply, only the raw link.
226+ p.Files[from].Raw = rawLink(res.Diff.Files[from], links)
227+ } else {
228+ collapseFiles(p.Files, res.Diff.Files, links)
229+ }
201230 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232+ if one {
233+ window = p.Files[from : from+1]
234+ }
203235 // The layouts follow the page: it sends ?view= (the layout shown) and
204236 // ?layouts=both when it holds both layouts and switches them itself, so
205237 // that every window of the page renders the same way.
@@ -209,12 +241,6 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
209241 }
210242 lines, _ := diffLines(window)
211243 v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
212- switch r.URL.Query().Get("layouts") {
213- case "both":
214- v.Both = true
215- case "one":
216- v.Both = false
217- }
218- v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
244+ v.pageLayouts(r)
219245 return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil
220246 }
@@ -73,6 +73,18 @@ func parseFrom(r *http.Request) (int, error) {
73 return n, nil73 return n, nil
74 }74 }
75 75
76+// parseOne reads ?one=1&from=i of a one-file fragment: the index i of the
77+// file, any non-negative number.
78+func parseOne(r *http.Request) (int, error) {
79+ q := r.URL.Query()
80+ s := q.Get("from")
81+ n, err := strconv.Atoi(s)
82+ if q.Get("one") != "1" || err != nil || n < 0 || strconv.Itoa(n) != s {
83+ return 0, errNotFound
84+ }
85+ return n, nil
86+}
87+
76 // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows88 // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows
77 // a placeholder that loads their window. src is the URL of /files with the89 // a placeholder that loads their window. src is the URL of /files with the
78 // base and the head of the diff; self the page, for the links of a page90 // base and the head of the diff; self the page, for the links of a page
@@ -167,7 +179,13 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
167 if since && !SignedIn(ctx) {179 if since && !SignedIn(ctx) {
168 return nil, 0, errNotFound180 return nil, 0, errNotFound
169 }181 }
170- from, err := parseFrom(r)182+ one := q.Get("one") == "1"
183+ var from int
184+ if one {
185+ from, err = parseOne(r)
186+ } else {
187+ from, err = parseFrom(r)
188+ }
171 if err != nil {189 if err != nil {
172 return nil, 0, err190 return nil, 0, err
173 }191 }
@@ -180,9 +198,12 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
180 if err != nil {198 if err != nil {
181 return nil, 0, err199 return nil, 0, err
182 }200 }
201+ win := &ports.FileWindow{From: from, To: from + mrWindow}
202+ if one {
203+ win = &ports.FileWindow{From: from, To: from + 1, Full: true}
204+ }
183 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{205 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
184- SinceLastRead: since, DiffOnly: true, Base: base, Head: head,206+ SinceLastRead: since, DiffOnly: true, Base: base, Head: head, Files: win,
185- Files: &ports.FileWindow{From: from, To: from + mrWindow},
186 })207 })
187 if errors.Is(err, domain.ErrConflict) {208 if errors.Is(err, domain.ErrConflict) {
188 return MRFilesMoved(), http.StatusConflict, nil209 return MRFilesMoved(), http.StatusConflict, nil
@@ -198,8 +219,19 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
198 canWrite := signed && m.State == domain.MRStateOpen && !since219 canWrite := signed && m.State == domain.MRStateOpen && !since
199 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}220 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))221 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
222+ links := fileLinks{src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String()}
223+ if one {
224+ // A one-file read holds file from alone, at index from: the load
225+ // addresses of a window do not apply, only the raw link.
226+ p.Files[from].Raw = rawLink(res.Diff.Files[from], links)
227+ } else {
228+ collapseFiles(p.Files, res.Diff.Files, links)
229+ }
201 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)230 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202 window := p.Files[from:min(from+mrWindow, len(p.Files))]231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232+ if one {
233+ window = p.Files[from : from+1]
234+ }
203 // The layouts follow the page: it sends ?view= (the layout shown) and235 // The layouts follow the page: it sends ?view= (the layout shown) and
204 // ?layouts=both when it holds both layouts and switches them itself, so236 // ?layouts=both when it holds both layouts and switches them itself, so
205 // that every window of the page renders the same way.237 // that every window of the page renders the same way.
@@ -209,12 +241,6 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
209 }241 }
210 lines, _ := diffLines(window)242 lines, _ := diffLines(window)
211 v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))243 v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
212- switch r.URL.Query().Get("layouts") {244+ v.pageLayouts(r)
213- case "both":
214- v.Both = true
215- case "one":
216- v.Both = false
217- }
218- v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
219 return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil245 return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil
220 }246 }
M internal/adapters/web/shell.templ +3 -0
@@ -30,6 +30,9 @@ templ Shell(l Layout, rail templ.Component) {
3030 <script src={ l.Assets.Files } defer></script>
3131 <script src={ l.Assets.Passkey } defer></script>
3232 <script src={ l.Assets.Log } defer></script>
33+ if l.Lazy {
34+ <script src={ l.Assets.Lazy } defer></script>
35+ }
3336 if l.MR {
3437 <script src={ l.Assets.MR } defer></script>
3538 }
@@ -30,6 +30,9 @@ templ Shell(l Layout, rail templ.Component) {
30 <script src={ l.Assets.Files } defer></script>30 <script src={ l.Assets.Files } defer></script>
31 <script src={ l.Assets.Passkey } defer></script>31 <script src={ l.Assets.Passkey } defer></script>
32 <script src={ l.Assets.Log } defer></script>32 <script src={ l.Assets.Log } defer></script>
33+ if l.Lazy {
34+ <script src={ l.Assets.Lazy } defer></script>
35+ }
33 if l.MR {36 if l.MR {
34 <script src={ l.Assets.MR } defer></script>37 <script src={ l.Assets.MR } defer></script>
35 }38 }
M internal/adapters/web/shell_templ.go +199 -180 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +3 -1
@@ -159,7 +159,9 @@
159159 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }
160160 /* with scripts the window comes by itself: the link of the page without
161161 scripts would only flash */
162- html.js & > a { visibility: hidden; }
162+ html.js &:not([data-manual]) > a { visibility: hidden; }
163+ &[data-manual] { height: auto; padding: var(--s3); }
164+ &[data-manual][data-loading] > a { opacity: .5; }
163165 & .notice { margin: 0; }
164166 }
165167 .dloose {
@@ -159,7 +159,9 @@
159 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }159 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }
160 /* with scripts the window comes by itself: the link of the page without160 /* with scripts the window comes by itself: the link of the page without
161 scripts would only flash */161 scripts would only flash */
162- html.js & > a { visibility: hidden; }162+ html.js &:not([data-manual]) > a { visibility: hidden; }
163+ &[data-manual] { height: auto; padding: var(--s3); }
164+ &[data-manual][data-loading] > a { opacity: .5; }
163 & .notice { margin: 0; }165 & .notice { margin: 0; }
164 }166 }
165 .dloose {167 .dloose {
A internal/adapters/web/static/js/lazy.js +154 -0
@@ -0,0 +1,154 @@
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+
24+ // load fetches the fragment of a placeholder and swaps its files in. The
25+ // promise resolves once they are in the page (or the page said to reload).
26+ function load(ph) {
27+ const src = ph?.dataset.src;
28+ if (!src || moved) return Promise.resolve();
29+ if (loading.has(src)) return loading.get(src);
30+ for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
31+ // The fragment renders like the page: the layout shown, and both layouts
32+ // when the page switches them itself (data-diff) or one (data-view).
33+ const d = $('#diffs')?.dataset || {};
34+ const view = d.diff || d.view;
35+ const layouts = d.diff ? 'both' : d.view ? 'one' : '';
36+ const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
37+ const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
38+ .then(async (res) => {
39+ const html = await res.text();
40+ if (res.status === 409) return stale(html);
41+ if (!res.ok) throw new Error('loading files: ' + res.status);
42+ // A fragment of the old diff after the page went stale stays out.
43+ if (!moved) swap(html);
44+ })
45+ .catch((err) => {
46+ loading.delete(src);
47+ for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
48+ window.ermite?.flash?.(err.message, 4000);
49+ });
50+ loading.set(src, p);
51+ return p;
52+ }
53+
54+ // swap replaces each placeholder file with the file of the same id. A
55+ // file the reader folded stays folded.
56+ function swap(html) {
57+ const t = document.createElement('template');
58+ t.innerHTML = html;
59+ for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
60+ const old = document.getElementById(f.id);
61+ if (!old || !old.querySelector('.dlazy')) continue;
62+ // A file still collapsed stays closed; a collapsed file opens once
63+ // loaded; a file of a window keeps the fold.
64+ f.open = f.querySelector('.dlazy[data-manual]') ? false
65+ : old.querySelector('.dlazy[data-manual]') ? true : old.open;
66+ for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
67+ old.replaceWith(f);
68+ window.htmx?.process(f);
69+ window.ermite?.watchFile?.(f, old);
70+ }
71+ }
72+
73+ // stale: the diff changed under the page. The placeholders say so; no file
74+ // of another diff comes in.
75+ function stale(html) {
76+ moved = true;
77+ for (const p of $$('.dlazy')) {
78+ p.removeAttribute('data-loading');
79+ p.innerHTML = html;
80+ }
81+ }
82+
83+ // Automatic placeholders load as they come within two screens of the view;
84+ // files that come in are marked in the list of files (ermite.js marks the
85+ // one in view). Manual ones are left alone.
86+ function watch() {
87+ if (!('IntersectionObserver' in window)) {
88+ for (const p of $$(AUTO)) load(p);
89+ return;
90+ }
91+ lazyIO = new IntersectionObserver((entries) => {
92+ for (const e of entries) {
93+ if (!e.isIntersecting) continue;
94+ lazyIO.unobserve(e.target);
95+ load(e.target);
96+ }
97+ }, { rootMargin: '200% 0px 200% 0px' });
98+ for (const p of $$(AUTO)) lazyIO.observe(p);
99+ }
100+
101+ // manualLink is the "load diff" link of an event target, when it is one that
102+ // loads in place (the placeholder has an address).
103+ const manualLink = (t) => {
104+ const a = t?.closest?.('.dlazy[data-manual] > a');
105+ return a && a.closest('.dlazy')?.dataset.src ? a : null;
106+ };
107+
108+ // loadManual loads the file of a "load diff" link and puts the focus in it:
109+ // on its first line link, else on its header. The scroll never moves for it.
110+ function loadManual(a) {
111+ const ph = a.closest('.dlazy');
112+ const id = ph.closest('details')?.id;
113+ return load(ph).then(() => {
114+ const f = id && document.getElementById(id);
115+ if (!f || f.querySelector('.dlazy[data-manual]')) return;
116+ (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true });
117+ });
118+ }
119+
120+ function onClick(e) {
121+ if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
122+ const a = manualLink(e.target);
123+ if (!a) return false;
124+ e.preventDefault();
125+ loadManual(a);
126+ return true;
127+ }
128+ // Enter makes the browser click a link by itself; Space does not. Both are
129+ // handled here, once (a fragment is fetched once).
130+ function onKey(e) {
131+ // A held key repeats: after the load, focus is on a line link.
132+ if (e.repeat) return false;
133+ if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
134+ const a = manualLink(e.target);
135+ if (!a) return false;
136+ e.preventDefault();
137+ loadManual(a);
138+ return true;
139+ }
140+
141+ let started = false;
142+ function start() {
143+ if (started || !$('#diffs')) return;
144+ started = true;
145+ watch();
146+ document.addEventListener('click', onClick);
147+ document.addEventListener('keydown', onKey);
148+ }
149+
150+ return { load, start, onClick, onKey };
151+})();
152+
153+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start());
154+else window.ermiteLazy.start();
@@ -0,0 +1,154 @@
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+
24+ // load fetches the fragment of a placeholder and swaps its files in. The
25+ // promise resolves once they are in the page (or the page said to reload).
26+ function load(ph) {
27+ const src = ph?.dataset.src;
28+ if (!src || moved) return Promise.resolve();
29+ if (loading.has(src)) return loading.get(src);
30+ for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
31+ // The fragment renders like the page: the layout shown, and both layouts
32+ // when the page switches them itself (data-diff) or one (data-view).
33+ const d = $('#diffs')?.dataset || {};
34+ const view = d.diff || d.view;
35+ const layouts = d.diff ? 'both' : d.view ? 'one' : '';
36+ const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
37+ const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
38+ .then(async (res) => {
39+ const html = await res.text();
40+ if (res.status === 409) return stale(html);
41+ if (!res.ok) throw new Error('loading files: ' + res.status);
42+ // A fragment of the old diff after the page went stale stays out.
43+ if (!moved) swap(html);
44+ })
45+ .catch((err) => {
46+ loading.delete(src);
47+ for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
48+ window.ermite?.flash?.(err.message, 4000);
49+ });
50+ loading.set(src, p);
51+ return p;
52+ }
53+
54+ // swap replaces each placeholder file with the file of the same id. A
55+ // file the reader folded stays folded.
56+ function swap(html) {
57+ const t = document.createElement('template');
58+ t.innerHTML = html;
59+ for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
60+ const old = document.getElementById(f.id);
61+ if (!old || !old.querySelector('.dlazy')) continue;
62+ // A file still collapsed stays closed; a collapsed file opens once
63+ // loaded; a file of a window keeps the fold.
64+ f.open = f.querySelector('.dlazy[data-manual]') ? false
65+ : old.querySelector('.dlazy[data-manual]') ? true : old.open;
66+ for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
67+ old.replaceWith(f);
68+ window.htmx?.process(f);
69+ window.ermite?.watchFile?.(f, old);
70+ }
71+ }
72+
73+ // stale: the diff changed under the page. The placeholders say so; no file
74+ // of another diff comes in.
75+ function stale(html) {
76+ moved = true;
77+ for (const p of $$('.dlazy')) {
78+ p.removeAttribute('data-loading');
79+ p.innerHTML = html;
80+ }
81+ }
82+
83+ // Automatic placeholders load as they come within two screens of the view;
84+ // files that come in are marked in the list of files (ermite.js marks the
85+ // one in view). Manual ones are left alone.
86+ function watch() {
87+ if (!('IntersectionObserver' in window)) {
88+ for (const p of $$(AUTO)) load(p);
89+ return;
90+ }
91+ lazyIO = new IntersectionObserver((entries) => {
92+ for (const e of entries) {
93+ if (!e.isIntersecting) continue;
94+ lazyIO.unobserve(e.target);
95+ load(e.target);
96+ }
97+ }, { rootMargin: '200% 0px 200% 0px' });
98+ for (const p of $$(AUTO)) lazyIO.observe(p);
99+ }
100+
101+ // manualLink is the "load diff" link of an event target, when it is one that
102+ // loads in place (the placeholder has an address).
103+ const manualLink = (t) => {
104+ const a = t?.closest?.('.dlazy[data-manual] > a');
105+ return a && a.closest('.dlazy')?.dataset.src ? a : null;
106+ };
107+
108+ // loadManual loads the file of a "load diff" link and puts the focus in it:
109+ // on its first line link, else on its header. The scroll never moves for it.
110+ function loadManual(a) {
111+ const ph = a.closest('.dlazy');
112+ const id = ph.closest('details')?.id;
113+ return load(ph).then(() => {
114+ const f = id && document.getElementById(id);
115+ if (!f || f.querySelector('.dlazy[data-manual]')) return;
116+ (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true });
117+ });
118+ }
119+
120+ function onClick(e) {
121+ if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
122+ const a = manualLink(e.target);
123+ if (!a) return false;
124+ e.preventDefault();
125+ loadManual(a);
126+ return true;
127+ }
128+ // Enter makes the browser click a link by itself; Space does not. Both are
129+ // handled here, once (a fragment is fetched once).
130+ function onKey(e) {
131+ // A held key repeats: after the load, focus is on a line link.
132+ if (e.repeat) return false;
133+ if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
134+ const a = manualLink(e.target);
135+ if (!a) return false;
136+ e.preventDefault();
137+ loadManual(a);
138+ return true;
139+ }
140+
141+ let started = false;
142+ function start() {
143+ if (started || !$('#diffs')) return;
144+ started = true;
145+ watch();
146+ document.addEventListener('click', onClick);
147+ document.addEventListener('keydown', onKey);
148+ }
149+
150+ return { load, start, onClick, onKey };
151+})();
152+
153+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start());
154+else window.ermiteLazy.start();
M internal/adapters/web/static/js/mr.js +5 -83
@@ -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.
@@ -59,66 +58,8 @@ window.ermiteMR = (() => {
5958
6059 const $ = (sel, root = document) => root.querySelector(sel);
6160 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
62- const loading = new Map(); // src -> promise of the swap
63- let moved = false;
64- let lazyIO = null; // the observer of the placeholders (watch)
6561 let revealGen = 0; // the last reveal wins the scroll
66-
67- // load fetches the window of a placeholder and swaps its files in. The
68- // promise resolves once they are in the page (or the page said to reload).
69- function load(ph) {
70- const src = ph?.dataset.src;
71- if (!src || moved) return Promise.resolve();
72- if (loading.has(src)) return loading.get(src);
73- for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
74- // The window renders like the page: the layout shown, and both layouts
75- // when the page switches them itself (data-diff) or one (data-view).
76- const d = $('#diffs')?.dataset || {};
77- const view = d.diff || d.view;
78- const layouts = d.diff ? 'both' : d.view ? 'one' : '';
79- const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
80- const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
81- .then(async (res) => {
82- const html = await res.text();
83- if (res.status === 409) return stale(html);
84- if (!res.ok) throw new Error('loading files: ' + res.status);
85- // A window of the old diff after the page went stale stays out.
86- if (!moved) swap(html);
87- })
88- .catch((err) => {
89- loading.delete(src);
90- for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
91- window.ermite?.flash?.(err.message, 4000);
92- });
93- loading.set(src, p);
94- return p;
95- }
96-
97- // swap replaces each placeholder file with the file of the same id. A
98- // file the reader folded stays folded.
99- function swap(html) {
100- const t = document.createElement('template');
101- t.innerHTML = html;
102- for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
103- const old = document.getElementById(f.id);
104- if (!old || !old.querySelector('.dlazy')) continue;
105- f.open = old.open;
106- for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
107- old.replaceWith(f);
108- window.htmx?.process(f);
109- window.ermite?.watchFile?.(f, old);
110- }
111- }
112-
113- // stale: the diff changed under the page. The placeholders say so; no file
114- // of another diff comes in.
115- function stale(html) {
116- moved = true;
117- for (const p of $$('.dlazy')) {
118- p.removeAttribute('data-loading');
119- p.innerHTML = html;
120- }
121- }
62+ const load = (ph) => window.ermiteLazy.load(ph);
12263
12364 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
12465 // a placeholder, then scrolls to it. It returns false when there is nothing
@@ -136,7 +77,7 @@ window.ermiteMR = (() => {
13677 }
13778 return document.getElementById(id);
13879 };
139- let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
80+ let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])');
14081 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
14182 const base = id.replace(/-s$/, '');
14283 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
@@ -168,24 +109,6 @@ window.ermiteMR = (() => {
168109 return true;
169110 }
170111
171- // Placeholders load as they come within two screens of the view; files
172- // that come in are marked in the list of files (ermite.js marks the one in
173- // view).
174- function watch() {
175- if (!('IntersectionObserver' in window)) {
176- for (const p of $$('.dlazy[data-src]')) load(p);
177- return;
178- }
179- lazyIO = new IntersectionObserver((entries) => {
180- for (const e of entries) {
181- if (!e.isIntersecting) continue;
182- lazyIO.unobserve(e.target);
183- load(e.target);
184- }
185- }, { rootMargin: '200% 0px 200% 0px' });
186- for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
187- }
188-
189112 // The merge menu.
190113 const menu = () => $('#merge-menu');
191114 function openMerge() {
@@ -294,7 +217,6 @@ window.ermiteMR = (() => {
294217 // the address of the request, so that a reload does not post again.
295218 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
296219 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
297- watch();
298220 // A link to a file or a comment that is not loaded yet.
299221 const hash = () => {
300222 const id = decodeURIComponent(location.hash.slice(1));
@@ -1,10 +1,9 @@
1 // mr.js: the merge request page beyond what htmx and hx-live give.1 // mr.js: the merge request page beyond what htmx and hx-live give.
2 //2 //
3 // 1. Windows of files. The page holds the hunks of one window of files; the3 // 1. Windows of files. The page holds the hunks of one window of files; the
4-// other files are placeholders (.dlazy[data-src]). A placeholder that comes4+// other files are placeholders (.dlazy[data-src]), loaded by lazy.js (which
5-// near the screen fetches its window (/files?from=&base=&head=), whose5+// the commit and compare pages use too) when they come near the screen. A
6-// files replace the placeholders of the same ids. A window is fetched once;6+// link to a file or to a comment of a placeholder loads it first (reveal).
7-// a link to a file or to a comment of a placeholder fetches it first.
8 // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu7 // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu
9 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter8 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter
10 // submits (the browser does it: the focus is on the button), Esc closes.9 // submits (the browser does it: the focus is on the button), Esc closes.
@@ -59,66 +58,8 @@ window.ermiteMR = (() => {
59 58
60 const $ = (sel, root = document) => root.querySelector(sel);59 const $ = (sel, root = document) => root.querySelector(sel);
61 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];60 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
62- const loading = new Map(); // src -> promise of the swap
63- let moved = false;
64- let lazyIO = null; // the observer of the placeholders (watch)
65 let revealGen = 0; // the last reveal wins the scroll61 let revealGen = 0; // the last reveal wins the scroll
66-62+ const load = (ph) => window.ermiteLazy.load(ph);
67- // load fetches the window of a placeholder and swaps its files in. The
68- // promise resolves once they are in the page (or the page said to reload).
69- function load(ph) {
70- const src = ph?.dataset.src;
71- if (!src || moved) return Promise.resolve();
72- if (loading.has(src)) return loading.get(src);
73- for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
74- // The window renders like the page: the layout shown, and both layouts
75- // when the page switches them itself (data-diff) or one (data-view).
76- const d = $('#diffs')?.dataset || {};
77- const view = d.diff || d.view;
78- const layouts = d.diff ? 'both' : d.view ? 'one' : '';
79- const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
80- const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
81- .then(async (res) => {
82- const html = await res.text();
83- if (res.status === 409) return stale(html);
84- if (!res.ok) throw new Error('loading files: ' + res.status);
85- // A window of the old diff after the page went stale stays out.
86- if (!moved) swap(html);
87- })
88- .catch((err) => {
89- loading.delete(src);
90- for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
91- window.ermite?.flash?.(err.message, 4000);
92- });
93- loading.set(src, p);
94- return p;
95- }
96-
97- // swap replaces each placeholder file with the file of the same id. A
98- // file the reader folded stays folded.
99- function swap(html) {
100- const t = document.createElement('template');
101- t.innerHTML = html;
102- for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
103- const old = document.getElementById(f.id);
104- if (!old || !old.querySelector('.dlazy')) continue;
105- f.open = old.open;
106- for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
107- old.replaceWith(f);
108- window.htmx?.process(f);
109- window.ermite?.watchFile?.(f, old);
110- }
111- }
112-
113- // stale: the diff changed under the page. The placeholders say so; no file
114- // of another diff comes in.
115- function stale(html) {
116- moved = true;
117- for (const p of $$('.dlazy')) {
118- p.removeAttribute('data-loading');
119- p.innerHTML = html;
120- }
121- }
122 63
123 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is64 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
124 // a placeholder, then scrolls to it. It returns false when there is nothing65 // a placeholder, then scrolls to it. It returns false when there is nothing
@@ -136,7 +77,7 @@ window.ermiteMR = (() => {
136 }77 }
137 return document.getElementById(id);78 return document.getElementById(id);
138 };79 };
139- let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');80+ let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])');
140 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {81 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
141 const base = id.replace(/-s$/, '');82 const base = id.replace(/-s$/, '');
142 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));83 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
@@ -168,24 +109,6 @@ window.ermiteMR = (() => {
168 return true;109 return true;
169 }110 }
170 111
171- // Placeholders load as they come within two screens of the view; files
172- // that come in are marked in the list of files (ermite.js marks the one in
173- // view).
174- function watch() {
175- if (!('IntersectionObserver' in window)) {
176- for (const p of $$('.dlazy[data-src]')) load(p);
177- return;
178- }
179- lazyIO = new IntersectionObserver((entries) => {
180- for (const e of entries) {
181- if (!e.isIntersecting) continue;
182- lazyIO.unobserve(e.target);
183- load(e.target);
184- }
185- }, { rootMargin: '200% 0px 200% 0px' });
186- for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
187- }
188-
189 // The merge menu.112 // The merge menu.
190 const menu = () => $('#merge-menu');113 const menu = () => $('#merge-menu');
191 function openMerge() {114 function openMerge() {
@@ -294,7 +217,6 @@ window.ermiteMR = (() => {
294 // the address of the request, so that a reload does not post again.217 // the address of the request, so that a reload does not post again.
295 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;218 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
296 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);219 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
297- watch();
298 // A link to a file or a comment that is not loaded yet.220 // A link to a file or a comment that is not loaded yet.
299 const hash = () => {221 const hash = () => {
300 const id = decodeURIComponent(location.hash.slice(1));222 const id = decodeURIComponent(location.hash.slice(1));
M internal/adapters/web/view.go +3 -0
@@ -67,6 +67,9 @@ type Layout struct {
6767 // Passkeys tells that the passkey routes exist: the header then links to
6868 // them.
6969 Passkeys bool
70+ // Lazy loads the loader of the collapsed and lazy files of a diff
71+ // (lazy.js): the commit, compare and merge request pages.
72+ Lazy bool
7073 // MR loads the script of the merge request page (mr.js).
7174 MR bool
7275 // IssueForm loads the script of the public issue form (issue-form.js).
@@ -67,6 +67,9 @@ type Layout struct {
67 // Passkeys tells that the passkey routes exist: the header then links to67 // Passkeys tells that the passkey routes exist: the header then links to
68 // them.68 // them.
69 Passkeys bool69 Passkeys bool
70+ // Lazy loads the loader of the collapsed and lazy files of a diff
71+ // (lazy.js): the commit, compare and merge request pages.
72+ Lazy bool
70 // MR loads the script of the merge request page (mr.js).73 // MR loads the script of the merge request page (mr.js).
71 MR bool74 MR bool
72 // IssueForm loads the script of the public issue form (issue-form.js).75 // IssueForm loads the script of the public issue form (issue-form.js).
M internal/adapters/web/web.go +1 -0
@@ -207,6 +207,7 @@ func (s *Server) routes() *http.ServeMux {
207207 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
208208 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
209209 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
210+ mux.HandleFunc("GET /{repo}/commit/{sha}/files", s.page(s.commitFiles))
210211 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
211212 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
212213 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
@@ -207,6 +207,7 @@ func (s *Server) routes() *http.ServeMux {
207 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))207 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
208 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))208 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
209 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))209 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
210+ mux.HandleFunc("GET /{repo}/commit/{sha}/files", s.page(s.commitFiles))
210 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))211 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
211 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))212 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
212 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))213 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
M internal/app/cli_mr.go +21 -10
@@ -470,14 +470,16 @@ type jsonHunk struct {
470470 }
471471
472472 type jsonFile struct {
473- OldPath string `json:"old_path"`
474- NewPath string `json:"new_path"`
475- Status string `json:"status"`
476- Additions int32 `json:"additions"`
477- Deletions int32 `json:"deletions"`
478- Binary bool `json:"binary"`
479- Truncated bool `json:"truncated"`
480- Hunks []jsonHunk `json:"hunks,omitempty"`
473+ OldPath string `json:"old_path"`
474+ NewPath string `json:"new_path"`
475+ Status string `json:"status"`
476+ Additions int32 `json:"additions"`
477+ Deletions int32 `json:"deletions"`
478+ Binary bool `json:"binary"`
479+ Truncated bool `json:"truncated"`
480+ // Omitted is generated, large, budget or unavailable; absent when the hunks are not omitted.
481+ Omitted string `json:"omitted,omitempty"`
482+ Hunks []jsonHunk `json:"hunks,omitempty"`
481483 }
482484
483485 type jsonCommit struct {
@@ -516,6 +518,14 @@ func fileStatusName(s ermite.FileStatus) string {
516518 return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_"))
517519 }
518520
521+// fileOmissionName is the lowercase reason of an omitted file, "" for none.
522+func fileOmissionName(o ermite.FileOmission) string {
523+ if o == ermite.FileOmission_FILE_OMISSION_UNSPECIFIED {
524+ return ""
525+ }
526+ return strings.ToLower(strings.TrimPrefix(o.String(), "FILE_OMISSION_"))
527+}
528+
519529 func lineKindName(k ermite.LineKind) string {
520530 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
521531 }
@@ -540,6 +550,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri
540550 jf := jsonFile{
541551 OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()),
542552 Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(),
553+ Omitted: fileOmissionName(f.GetOmitted()),
543554 }
544555 if withDiff {
545556 for _, h := range f.GetHunks() {
@@ -581,8 +592,8 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
581592 "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +
582593 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
583594 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
584- "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
585- "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
595+ "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated, omitted (generated, large, budget or unavailable; absent when the hunks are\n" +
596+ "not omitted) and, with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
586597 "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
587598 Args: cobra.ExactArgs(2),
588599 ValidArgsFunction: completeMRRepoThenNumber(env),
@@ -470,14 +470,16 @@ type jsonHunk struct {
470 }470 }
471 471
472 type jsonFile struct {472 type jsonFile struct {
473- OldPath string `json:"old_path"`473+ OldPath string `json:"old_path"`
474- NewPath string `json:"new_path"`474+ NewPath string `json:"new_path"`
475- Status string `json:"status"`475+ Status string `json:"status"`
476- Additions int32 `json:"additions"`476+ Additions int32 `json:"additions"`
477- Deletions int32 `json:"deletions"`477+ Deletions int32 `json:"deletions"`
478- Binary bool `json:"binary"`478+ Binary bool `json:"binary"`
479- Truncated bool `json:"truncated"`479+ Truncated bool `json:"truncated"`
480- Hunks []jsonHunk `json:"hunks,omitempty"`480+ // Omitted is generated, large, budget or unavailable; absent when the hunks are not omitted.
481+ Omitted string `json:"omitted,omitempty"`
482+ Hunks []jsonHunk `json:"hunks,omitempty"`
481 }483 }
482 484
483 type jsonCommit struct {485 type jsonCommit struct {
@@ -516,6 +518,14 @@ func fileStatusName(s ermite.FileStatus) string {
516 return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_"))518 return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_"))
517 }519 }
518 520
521+// fileOmissionName is the lowercase reason of an omitted file, "" for none.
522+func fileOmissionName(o ermite.FileOmission) string {
523+ if o == ermite.FileOmission_FILE_OMISSION_UNSPECIFIED {
524+ return ""
525+ }
526+ return strings.ToLower(strings.TrimPrefix(o.String(), "FILE_OMISSION_"))
527+}
528+
519 func lineKindName(k ermite.LineKind) string {529 func lineKindName(k ermite.LineKind) string {
520 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))530 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
521 }531 }
@@ -540,6 +550,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri
540 jf := jsonFile{550 jf := jsonFile{
541 OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()),551 OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()),
542 Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(),552 Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(),
553+ Omitted: fileOmissionName(f.GetOmitted()),
543 }554 }
544 if withDiff {555 if withDiff {
545 for _, h := range f.GetHunks() {556 for _, h := range f.GetHunks() {
@@ -581,8 +592,8 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
581 "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +592 "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +
582 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +593 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
583 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +594 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
584- "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +595+ "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated, omitted (generated, large, budget or unavailable; absent when the hunks are\n" +
585- "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +596+ "not omitted) and, with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
586 "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",597 "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
587 Args: cobra.ExactArgs(2),598 Args: cobra.ExactArgs(2),
588 ValidArgsFunction: completeMRRepoThenNumber(env),599 ValidArgsFunction: completeMRRepoThenNumber(env),
M internal/app/cli_mr_test.go +15 -0
@@ -563,3 +563,18 @@ func TestMRWebURL(t *testing.T) {
563563 t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })
564564 }
565565 }
566+
567+func TestJSONFileOmitted(t *testing.T) {
568+ d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Diff: &ermite.Diff{Files: []*ermite.FileDiff{
569+ {NewPath: "a"},
570+ {NewPath: "go.sum", Omitted: ermite.FileOmission_FILE_OMISSION_GENERATED, Truncated: true},
571+ {NewPath: "b", Omitted: ermite.FileOmission_FILE_OMISSION_UNAVAILABLE, Truncated: true},
572+ }}}
573+ got := toJSONDetail(d, false, "https://git.example.org")
574+ assert.Equal(t, []string{"", "generated", "unavailable"}, []string{got.Files[0].Omitted, got.Files[1].Omitted, got.Files[2].Omitted})
575+ raw, err := json.Marshal(got.Files)
576+ require.NoError(t, err)
577+ assert.Contains(t, string(raw), `"omitted":"generated"`)
578+ assert.Contains(t, string(raw), `"truncated":true`)
579+ assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`))
580+}
@@ -563,3 +563,18 @@ func TestMRWebURL(t *testing.T) {
563 t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })563 t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })
564 }564 }
565 }565 }
566+
567+func TestJSONFileOmitted(t *testing.T) {
568+ d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Diff: &ermite.Diff{Files: []*ermite.FileDiff{
569+ {NewPath: "a"},
570+ {NewPath: "go.sum", Omitted: ermite.FileOmission_FILE_OMISSION_GENERATED, Truncated: true},
571+ {NewPath: "b", Omitted: ermite.FileOmission_FILE_OMISSION_UNAVAILABLE, Truncated: true},
572+ }}}
573+ got := toJSONDetail(d, false, "https://git.example.org")
574+ assert.Equal(t, []string{"", "generated", "unavailable"}, []string{got.Files[0].Omitted, got.Files[1].Omitted, got.Files[2].Omitted})
575+ raw, err := json.Marshal(got.Files)
576+ require.NoError(t, err)
577+ assert.Contains(t, string(raw), `"omitted":"generated"`)
578+ assert.Contains(t, string(raw), `"truncated":true`)
579+ assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`))
580+}
M internal/app/testdata/web/commit-big.html +6 -3
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -141,7 +143,6 @@
141143 </div>
142144 </details>
143145 </div>
144-<p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>
145146 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
146147 <details class="dfile lnw-2" id="f1" data-file="big.txt">
147148 <summary>
@@ -149,9 +150,11 @@
149150 <span class="p">big.txt</span>
150151 <span class="plus">+2600</span>
151152 <span class="minus">-0</span>
152-<span class="note">diff too large to show (2600 lines changed): collapsed</span>
153+<span class="note">large diff (2600 lines changed): collapsed</span>
153154 </summary>
154-<p class="note pad">diff too large to show (2600 lines changed): collapsed</p>
155+<div class="dlazy" data-src="/diffs/commit/SHA40/files?one=1&amp;from=0" data-manual>
156+<a href="?file=0#f1">load diff</a>
157+</div>
155158 </details>
156159 </div>
157160 </main>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
@@ -141,7 +143,6 @@
141 </div>143 </div>
142 </details>144 </details>
143 </div>145 </div>
144-<p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>
145 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">146 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
146 <details class="dfile lnw-2" id="f1" data-file="big.txt">147 <details class="dfile lnw-2" id="f1" data-file="big.txt">
147 <summary>148 <summary>
@@ -149,9 +150,11 @@
149 <span class="p">big.txt</span>150 <span class="p">big.txt</span>
150 <span class="plus">+2600</span>151 <span class="plus">+2600</span>
151 <span class="minus">-0</span>152 <span class="minus">-0</span>
152-<span class="note">diff too large to show (2600 lines changed): collapsed</span>153+<span class="note">large diff (2600 lines changed): collapsed</span>
153 </summary>154 </summary>
154-<p class="note pad">diff too large to show (2600 lines changed): collapsed</p>155+<div class="dlazy" data-src="/diffs/commit/SHA40/files?one=1&amp;from=0" data-manual>
156+<a href="?file=0#f1">load diff</a>
157+</div>
155 </details>158 </details>
156 </div>159 </div>
157 </main>160 </main>
M internal/app/testdata/web/commit-binary.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-merge.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify-split.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-rename.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-root.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-trojan.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/web_commit_test.go +63 -5
@@ -41,6 +41,34 @@ func TestWebCommitGolden(t *testing.T) {
4141 }
4242 }
4343
44+// A commit page reads the hunks of its first 300 files; the others are listed
45+// collapsed and load on their own. A binary file or one without changed lines
46+// past the window has nothing to load and gets no link.
47+func TestWebCommitFileWindow(t *testing.T) {
48+ r := newMRRepo(t, domain.VisibilityPublic)
49+ b := browser{r.harness}
50+ for i := range 305 {
51+ require.NoError(t, os.MkdirAll(filepath.Join(r.wt, "many"), 0o755))
52+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, fmt.Sprintf("many/f%03d.txt", i)), []byte(fmt.Sprintf("line %d\n", i)), 0o644))
53+ }
54+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zbin.bin"), []byte("a\x00b"), 0o644))
55+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zempty.txt"), nil, 0o644))
56+ r.mustGit(r.wt, "add", ".")
57+ r.mustGit(r.wt, "commit", "-q", "-m", "many files")
58+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
59+
60+ page := b.get("/proj/commit/"+r.headOf("feat/search"), nil)
61+ require.Equal(t, 200, page.status)
62+ assert.Contains(t, page.body, "line 299</td>", "index 299 is read")
63+ assert.NotContains(t, page.body, "line 300</td>", "index 300 is collapsed")
64+ assert.Equal(t, 5, strings.Count(page.body, "collapsed to keep the page light"))
65+ assert.Equal(t, 5, strings.Count(page.body, "data-manual"), "binary and empty files past the window have nothing to load")
66+ assert.Contains(t, page.body, `<a href="?file=300#f301">load diff</a>`)
67+ assert.NotContains(t, page.body, `?file=299#`)
68+ assert.NotContains(t, page.body, `?file=305#`)
69+ assert.NotContains(t, page.body, `?file=306#`)
70+}
71+
4472 // The behaviours of the commit pages, asserted on their own.
4573 func TestWebCommit(t *testing.T) {
4674 h := newHarness(t, harnessOpts{})
@@ -112,12 +140,42 @@ func TestWebCommit(t *testing.T) {
112140 assert.Contains(t, body, "holds invisible characters")
113141 })
114142
115- t.Run("truncated", func(t *testing.T) {
143+ t.Run("large file is collapsed and loads on its own", func(t *testing.T) {
116144 body := get("/diffs/commit/"+f.big, 200)
117- assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed")
118- assert.Contains(t, body, "this diff is incomplete")
119- assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed")
120- assert.NotContains(t, body, "line 2000", "no lines of a truncated file")
145+ assert.Contains(t, body, "large diff (2600 lines changed): collapsed")
146+ assert.NotContains(t, body, "this diff is incomplete", "a collapsed file is not a global note")
147+ assert.NotRegexp(t, `<details class="dfile[^"]*" id="f1"[^>]* open`, body, "a collapsed file is closed")
148+ assert.Contains(t, body, `<div class="dlazy" data-src="/diffs/commit/`+f.big+`/files?one=1&amp;from=0" data-manual>`)
149+ assert.Contains(t, body, `<a href="?file=0#f1">load diff</a>`)
150+ assert.NotContains(t, body, "line 2000", "no rows for a collapsed file")
151+
152+ // One file, in full, over the 2,000 lines of the page.
153+ frag := get("/diffs/commit/"+f.big+"/files?one=1&from=0", 200)
154+ assert.NotContains(t, frag, "<html")
155+ assert.Equal(t, 1, strings.Count(frag, `<details class="dfile`))
156+ assert.Contains(t, frag, "line 2599")
157+ assert.NotContains(t, frag, "data-manual")
158+ assert.Contains(t, frag, `id="f1"`)
159+ both := get("/diffs/commit/"+f.big+"/files?one=1&from=0&view=split&layouts=one", 200)
160+ assert.NotContains(t, both, `class="dt uni"`)
161+ assert.Contains(t, both, `class="dt spl"`)
162+
163+ // Without scripts: the page splices the file in.
164+ page := get("/diffs/commit/"+f.big+"?file=0", 200)
165+ assert.Contains(t, page, "line 2599")
166+ assert.NotContains(t, page, "data-manual")
167+ assert.Contains(t, get("/diffs/commit/"+f.big+"?file=9", 200), "data-manual", "out of range: ignored")
168+ })
169+
170+ t.Run("one file route", func(t *testing.T) {
171+ for _, p := range []string{
172+ "/files?one=1&from=1", "/files?one=1&from=-1", "/files?one=1&from=x", "/files?from=0", "/files?one=1",
173+ } {
174+ st, _, _ := h.get("/diffs/commit/" + f.big + p)
175+ assert.Equal(t, 404, st, p)
176+ }
177+ st, _, _ := h.get("/diffs/commit/main/files?one=1&from=0")
178+ assert.Equal(t, 404, st)
121179 })
122180
123181 t.Run("not found", func(t *testing.T) {
@@ -41,6 +41,34 @@ func TestWebCommitGolden(t *testing.T) {
41 }41 }
42 }42 }
43 43
44+// A commit page reads the hunks of its first 300 files; the others are listed
45+// collapsed and load on their own. A binary file or one without changed lines
46+// past the window has nothing to load and gets no link.
47+func TestWebCommitFileWindow(t *testing.T) {
48+ r := newMRRepo(t, domain.VisibilityPublic)
49+ b := browser{r.harness}
50+ for i := range 305 {
51+ require.NoError(t, os.MkdirAll(filepath.Join(r.wt, "many"), 0o755))
52+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, fmt.Sprintf("many/f%03d.txt", i)), []byte(fmt.Sprintf("line %d\n", i)), 0o644))
53+ }
54+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zbin.bin"), []byte("a\x00b"), 0o644))
55+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zempty.txt"), nil, 0o644))
56+ r.mustGit(r.wt, "add", ".")
57+ r.mustGit(r.wt, "commit", "-q", "-m", "many files")
58+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
59+
60+ page := b.get("/proj/commit/"+r.headOf("feat/search"), nil)
61+ require.Equal(t, 200, page.status)
62+ assert.Contains(t, page.body, "line 299</td>", "index 299 is read")
63+ assert.NotContains(t, page.body, "line 300</td>", "index 300 is collapsed")
64+ assert.Equal(t, 5, strings.Count(page.body, "collapsed to keep the page light"))
65+ assert.Equal(t, 5, strings.Count(page.body, "data-manual"), "binary and empty files past the window have nothing to load")
66+ assert.Contains(t, page.body, `<a href="?file=300#f301">load diff</a>`)
67+ assert.NotContains(t, page.body, `?file=299#`)
68+ assert.NotContains(t, page.body, `?file=305#`)
69+ assert.NotContains(t, page.body, `?file=306#`)
70+}
71+
44 // The behaviours of the commit pages, asserted on their own.72 // The behaviours of the commit pages, asserted on their own.
45 func TestWebCommit(t *testing.T) {73 func TestWebCommit(t *testing.T) {
46 h := newHarness(t, harnessOpts{})74 h := newHarness(t, harnessOpts{})
@@ -112,12 +140,42 @@ func TestWebCommit(t *testing.T) {
112 assert.Contains(t, body, "holds invisible characters")140 assert.Contains(t, body, "holds invisible characters")
113 })141 })
114 142
115- t.Run("truncated", func(t *testing.T) {143+ t.Run("large file is collapsed and loads on its own", func(t *testing.T) {
116 body := get("/diffs/commit/"+f.big, 200)144 body := get("/diffs/commit/"+f.big, 200)
117- assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed")145+ assert.Contains(t, body, "large diff (2600 lines changed): collapsed")
118- assert.Contains(t, body, "this diff is incomplete")146+ assert.NotContains(t, body, "this diff is incomplete", "a collapsed file is not a global note")
119- assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed")147+ assert.NotRegexp(t, `<details class="dfile[^"]*" id="f1"[^>]* open`, body, "a collapsed file is closed")
120- assert.NotContains(t, body, "line 2000", "no lines of a truncated file")148+ assert.Contains(t, body, `<div class="dlazy" data-src="/diffs/commit/`+f.big+`/files?one=1&amp;from=0" data-manual>`)
149+ assert.Contains(t, body, `<a href="?file=0#f1">load diff</a>`)
150+ assert.NotContains(t, body, "line 2000", "no rows for a collapsed file")
151+
152+ // One file, in full, over the 2,000 lines of the page.
153+ frag := get("/diffs/commit/"+f.big+"/files?one=1&from=0", 200)
154+ assert.NotContains(t, frag, "<html")
155+ assert.Equal(t, 1, strings.Count(frag, `<details class="dfile`))
156+ assert.Contains(t, frag, "line 2599")
157+ assert.NotContains(t, frag, "data-manual")
158+ assert.Contains(t, frag, `id="f1"`)
159+ both := get("/diffs/commit/"+f.big+"/files?one=1&from=0&view=split&layouts=one", 200)
160+ assert.NotContains(t, both, `class="dt uni"`)
161+ assert.Contains(t, both, `class="dt spl"`)
162+
163+ // Without scripts: the page splices the file in.
164+ page := get("/diffs/commit/"+f.big+"?file=0", 200)
165+ assert.Contains(t, page, "line 2599")
166+ assert.NotContains(t, page, "data-manual")
167+ assert.Contains(t, get("/diffs/commit/"+f.big+"?file=9", 200), "data-manual", "out of range: ignored")
168+ })
169+
170+ t.Run("one file route", func(t *testing.T) {
171+ for _, p := range []string{
172+ "/files?one=1&from=1", "/files?one=1&from=-1", "/files?one=1&from=x", "/files?from=0", "/files?one=1",
173+ } {
174+ st, _, _ := h.get("/diffs/commit/" + f.big + p)
175+ assert.Equal(t, 404, st, p)
176+ }
177+ st, _, _ := h.get("/diffs/commit/main/files?one=1&from=0")
178+ assert.Equal(t, 404, st)
121 })179 })
122 180
123 t.Run("not found", func(t *testing.T) {181 t.Run("not found", func(t *testing.T) {
M internal/app/web_mr_test.go +77 -0
@@ -920,3 +920,80 @@ func TestWebMergeRequestMergeabilityLive(t *testing.T) {
920920 break
921921 }
922922 }
923+
924+// Collapsed files of a merge request: a generated file and a file over the
925+// line cap are listed closed, with no rows and a manual placeholder; /files
926+// with one=1 reads exactly one file in full, whatever its index; the page
927+// without scripts (?file=i) splices the file in.
928+func TestWebMergeRequestCollapsedFiles(t *testing.T) {
929+ r := newMRRepo(t, domain.VisibilityPublic)
930+ b := browser{r.harness}
931+ var big strings.Builder
932+ for i := range 2600 {
933+ fmt.Fprintf(&big, "row %d\n", i)
934+ }
935+ r.writeFile(r.wt, "zz/big.txt", []byte(big.String()))
936+ r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n"))
937+ r.writeFile(r.wt, "zz/small.txt", []byte("small line\n"))
938+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
939+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t)
940+
941+ page := b.get("/proj/merge-requests/1", nil)
942+ require.Equal(t, 200, page.status)
943+ assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
944+ assert.Contains(t, page.body, "generated file: collapsed")
945+ assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
946+ assert.NotRegexp(t, `row \d+`, page.body, "no rows for a collapsed file")
947+ assert.NotContains(t, page.body, "generated line</td>")
948+ assert.Contains(t, page.body, "small line")
949+ assert.NotContains(t, page.body, "this diff is incomplete")
950+ srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
951+ require.Len(t, srcs, 2)
952+
953+ for _, m := range srcs {
954+ one := b.get(html.UnescapeString(m[1]), nil)
955+ require.Equal(t, 200, one.status, one.body)
956+ assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file")
957+ assert.NotContains(t, one.body, "data-manual")
958+ }
959+ bigOne := b.get(html.UnescapeString(srcs[1][1]), nil)
960+ genOne := b.get(html.UnescapeString(srcs[0][1]), nil)
961+ assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599"))
962+ assert.Contains(t, bigOne.body+genOne.body, "generated line")
963+
964+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status)
965+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status)
966+
967+ for _, m := range srcs {
968+ own := b.get("/proj/merge-requests/1?file="+m[2], nil)
969+ require.Equal(t, 200, own.status)
970+ assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
971+ }
972+}
973+
974+// The page of a window without scripts: ?file=i splices that collapsed file in
975+// and the load link of a window keeps the window.
976+func TestWebMergeRequestWindowFileSplice(t *testing.T) {
977+ r := newMRRepo(t, domain.VisibilityPublic)
978+ b := browser{r.harness}
979+ for i := range 30 {
980+ r.writeFile(r.wt, fmt.Sprintf("a/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i)))
981+ }
982+ r.writeFile(r.wt, "vendor/gen.txt", []byte("generated line\n"))
983+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
984+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Window").ok(t)
985+
986+ // Files sort as a/f00..a/f29, search.go, vendor/gen.txt: the generated
987+ // file is index 31, in the window that starts at 25.
988+ win := b.get("/proj/merge-requests/1?from=25", nil)
989+ require.Equal(t, 200, win.status)
990+ m := regexp.MustCompile(`href="(\?from=25&amp;file=(\d+)#f\d+)"`).FindStringSubmatch(win.body)
991+ require.Len(t, m, 3, "the load link keeps the window")
992+ assert.Equal(t, "31", m[2])
993+ assert.NotContains(t, win.body, "generated line</td>")
994+
995+ own := b.get("/proj/merge-requests/1?from=25&file=31", nil)
996+ require.Equal(t, 200, own.status)
997+ assert.Contains(t, own.body, "generated line</td>", "the file is spliced in")
998+ assert.NotContains(t, own.body, "data-manual")
999+}
@@ -920,3 +920,80 @@ func TestWebMergeRequestMergeabilityLive(t *testing.T) {
920 break920 break
921 }921 }
922 }922 }
923+
924+// Collapsed files of a merge request: a generated file and a file over the
925+// line cap are listed closed, with no rows and a manual placeholder; /files
926+// with one=1 reads exactly one file in full, whatever its index; the page
927+// without scripts (?file=i) splices the file in.
928+func TestWebMergeRequestCollapsedFiles(t *testing.T) {
929+ r := newMRRepo(t, domain.VisibilityPublic)
930+ b := browser{r.harness}
931+ var big strings.Builder
932+ for i := range 2600 {
933+ fmt.Fprintf(&big, "row %d\n", i)
934+ }
935+ r.writeFile(r.wt, "zz/big.txt", []byte(big.String()))
936+ r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n"))
937+ r.writeFile(r.wt, "zz/small.txt", []byte("small line\n"))
938+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
939+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t)
940+
941+ page := b.get("/proj/merge-requests/1", nil)
942+ require.Equal(t, 200, page.status)
943+ assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
944+ assert.Contains(t, page.body, "generated file: collapsed")
945+ assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
946+ assert.NotRegexp(t, `row \d+`, page.body, "no rows for a collapsed file")
947+ assert.NotContains(t, page.body, "generated line</td>")
948+ assert.Contains(t, page.body, "small line")
949+ assert.NotContains(t, page.body, "this diff is incomplete")
950+ srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
951+ require.Len(t, srcs, 2)
952+
953+ for _, m := range srcs {
954+ one := b.get(html.UnescapeString(m[1]), nil)
955+ require.Equal(t, 200, one.status, one.body)
956+ assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file")
957+ assert.NotContains(t, one.body, "data-manual")
958+ }
959+ bigOne := b.get(html.UnescapeString(srcs[1][1]), nil)
960+ genOne := b.get(html.UnescapeString(srcs[0][1]), nil)
961+ assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599"))
962+ assert.Contains(t, bigOne.body+genOne.body, "generated line")
963+
964+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status)
965+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status)
966+
967+ for _, m := range srcs {
968+ own := b.get("/proj/merge-requests/1?file="+m[2], nil)
969+ require.Equal(t, 200, own.status)
970+ assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
971+ }
972+}
973+
974+// The page of a window without scripts: ?file=i splices that collapsed file in
975+// and the load link of a window keeps the window.
976+func TestWebMergeRequestWindowFileSplice(t *testing.T) {
977+ r := newMRRepo(t, domain.VisibilityPublic)
978+ b := browser{r.harness}
979+ for i := range 30 {
980+ r.writeFile(r.wt, fmt.Sprintf("a/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i)))
981+ }
982+ r.writeFile(r.wt, "vendor/gen.txt", []byte("generated line\n"))
983+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
984+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Window").ok(t)
985+
986+ // Files sort as a/f00..a/f29, search.go, vendor/gen.txt: the generated
987+ // file is index 31, in the window that starts at 25.
988+ win := b.get("/proj/merge-requests/1?from=25", nil)
989+ require.Equal(t, 200, win.status)
990+ m := regexp.MustCompile(`href="(\?from=25&amp;file=(\d+)#f\d+)"`).FindStringSubmatch(win.body)
991+ require.Len(t, m, 3, "the load link keeps the window")
992+ assert.Equal(t, "31", m[2])
993+ assert.NotContains(t, win.body, "generated line</td>")
994+
995+ own := b.get("/proj/merge-requests/1?from=25&file=31", nil)
996+ require.Equal(t, 200, own.status)
997+ assert.Contains(t, own.body, "generated line</td>", "the file is spliced in")
998+ assert.NotContains(t, own.body, "data-manual")
999+}
M internal/domain/budget.go +60 -11
@@ -2,7 +2,10 @@
22
33 package domain
44
5-import "fmt"
5+import (
6+ "fmt"
7+ "math"
8+)
69
710 // ValidateCommitObjects checks a request before any repository I/O. max is the
811 // caller's commit-count limit.
@@ -45,15 +48,17 @@ func (b *CommitObjectBudget) Charge(id ObjectID, size int64) {
4548 b.total += size
4649 }
4750
48-// DiffBudget owns file selection and retained hunk accounting. Truncated files
51+// DiffBudget owns file selection and retained hunk accounting. Omitted files
4952 // consume no budget; streaming readers may use a copy while attempting a file.
5053 type DiffBudget struct {
5154 limits DiffLimits
5255 linesLeft, bytesLeft int
56+ // fileBytes is bytesLeft when the current file started (StartFile).
57+ fileBytes int
5358 }
5459
5560 func NewDiffBudget(limits DiffLimits) DiffBudget {
56- return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes}
61+ return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes, fileBytes: limits.MaxTotalBytes}
5762 }
5863
5964 func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
@@ -69,17 +74,61 @@ func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
6974 return
7075 }
7176
72-func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 }
77+// Admit decides whether the hunks of a file are read. read is false with
78+// OmitNone for a binary file or one without changed lines (nothing to show).
79+// Otherwise read is false with the reason the file is omitted. path is the path
80+// a comment designates (see FileDiff.CommentPath).
81+func (b DiffBudget) Admit(binary bool, path string, changed int) (read bool, why FileOmission) {
82+ switch {
83+ case binary || changed <= 0:
84+ return false, OmitNone
85+ case b.limits.SkipGenerated && IsGeneratedPath(path):
86+ return false, OmitGenerated
87+ case b.limits.MaxAloneLines > 0 && changed > b.limits.MaxAloneLines:
88+ return false, OmitUnavailable
89+ case b.limits.MaxFileLines > 0 && changed > b.limits.MaxFileLines:
90+ return false, OmitLarge
91+ case b.limits.MaxTotalLines > 0 && changed > b.linesLeft, b.bytesLeft <= 0:
92+ return false, OmitBudget
93+ }
94+ return true, OmitNone
95+}
7396
74-func (b DiffBudget) WantsHunks(binary bool, n int) bool {
75- return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 0
97+// FileLines is the most changed lines the next file may have; math.MaxInt when
98+// neither MaxFileLines nor MaxTotalLines limits it.
99+func (b DiffBudget) FileLines() int {
100+ n := math.MaxInt
101+ if b.limits.MaxFileLines > 0 {
102+ n = b.limits.MaxFileLines
103+ }
104+ if b.limits.MaxTotalLines > 0 {
105+ n = min(n, b.linesLeft)
106+ }
107+ return n
76108 }
77109
78-func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }
110+// StartFile marks the start of a file's lines, for ChargeLine to tell a file
111+// over the byte limit on its own from one that met the bytes of earlier files.
112+func (b *DiffBudget) StartFile() { b.fileBytes = b.bytesLeft }
79113
80-// ChargeLine accounts for one decoded hunk line and reports whether it fits.
81-func (b *DiffBudget) ChargeLine(bytes int) bool {
82- b.linesLeft--
114+// ChargeLine accounts for decoded text bytes and, for additions or deletions,
115+// one changed line. Context consumes bytes only. It returns OmitNone when the
116+// line fits and OmitBudget when the changed lines ran out. When the bytes run
117+// out it returns OmitBudget if files read before this one (since StartFile)
118+// spent some of them, as this file may still fit on its own, and
119+// OmitUnavailable if this file alone is over MaxTotalBytes.
120+func (b *DiffBudget) ChargeLine(bytes int, changed bool) FileOmission {
121+ if changed && b.limits.MaxTotalLines > 0 {
122+ b.linesLeft--
123+ }
83124 b.bytesLeft -= bytes
84- return b.linesLeft >= 0 && b.bytesLeft >= 0
125+ switch {
126+ case b.limits.MaxTotalLines > 0 && b.linesLeft < 0:
127+ return OmitBudget
128+ case b.bytesLeft < 0 && b.fileBytes < b.limits.MaxTotalBytes:
129+ return OmitBudget
130+ case b.bytesLeft < 0:
131+ return OmitUnavailable
132+ }
133+ return OmitNone
85134 }
@@ -2,7 +2,10 @@
2 2
3 package domain3 package domain
4 4
5-import "fmt"5+import (
6+ "fmt"
7+ "math"
8+)
6 9
7 // ValidateCommitObjects checks a request before any repository I/O. max is the10 // ValidateCommitObjects checks a request before any repository I/O. max is the
8 // caller's commit-count limit.11 // caller's commit-count limit.
@@ -45,15 +48,17 @@ func (b *CommitObjectBudget) Charge(id ObjectID, size int64) {
45 b.total += size48 b.total += size
46 }49 }
47 50
48-// DiffBudget owns file selection and retained hunk accounting. Truncated files51+// DiffBudget owns file selection and retained hunk accounting. Omitted files
49 // consume no budget; streaming readers may use a copy while attempting a file.52 // consume no budget; streaming readers may use a copy while attempting a file.
50 type DiffBudget struct {53 type DiffBudget struct {
51 limits DiffLimits54 limits DiffLimits
52 linesLeft, bytesLeft int55 linesLeft, bytesLeft int
56+ // fileBytes is bytesLeft when the current file started (StartFile).
57+ fileBytes int
53 }58 }
54 59
55 func NewDiffBudget(limits DiffLimits) DiffBudget {60 func NewDiffBudget(limits DiffLimits) DiffBudget {
56- return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes}61+ return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes, fileBytes: limits.MaxTotalBytes}
57 }62 }
58 63
59 func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {64 func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
@@ -69,17 +74,61 @@ func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
69 return74 return
70 }75 }
71 76
72-func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 }77+// Admit decides whether the hunks of a file are read. read is false with
78+// OmitNone for a binary file or one without changed lines (nothing to show).
79+// Otherwise read is false with the reason the file is omitted. path is the path
80+// a comment designates (see FileDiff.CommentPath).
81+func (b DiffBudget) Admit(binary bool, path string, changed int) (read bool, why FileOmission) {
82+ switch {
83+ case binary || changed <= 0:
84+ return false, OmitNone
85+ case b.limits.SkipGenerated && IsGeneratedPath(path):
86+ return false, OmitGenerated
87+ case b.limits.MaxAloneLines > 0 && changed > b.limits.MaxAloneLines:
88+ return false, OmitUnavailable
89+ case b.limits.MaxFileLines > 0 && changed > b.limits.MaxFileLines:
90+ return false, OmitLarge
91+ case b.limits.MaxTotalLines > 0 && changed > b.linesLeft, b.bytesLeft <= 0:
92+ return false, OmitBudget
93+ }
94+ return true, OmitNone
95+}
73 96
74-func (b DiffBudget) WantsHunks(binary bool, n int) bool {97+// FileLines is the most changed lines the next file may have; math.MaxInt when
75- return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 098+// neither MaxFileLines nor MaxTotalLines limits it.
99+func (b DiffBudget) FileLines() int {
100+ n := math.MaxInt
101+ if b.limits.MaxFileLines > 0 {
102+ n = b.limits.MaxFileLines
103+ }
104+ if b.limits.MaxTotalLines > 0 {
105+ n = min(n, b.linesLeft)
106+ }
107+ return n
76 }108 }
77 109
78-func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }110+// StartFile marks the start of a file's lines, for ChargeLine to tell a file
111+// over the byte limit on its own from one that met the bytes of earlier files.
112+func (b *DiffBudget) StartFile() { b.fileBytes = b.bytesLeft }
79 113
80-// ChargeLine accounts for one decoded hunk line and reports whether it fits.114+// ChargeLine accounts for decoded text bytes and, for additions or deletions,
81-func (b *DiffBudget) ChargeLine(bytes int) bool {115+// one changed line. Context consumes bytes only. It returns OmitNone when the
82- b.linesLeft--116+// line fits and OmitBudget when the changed lines ran out. When the bytes run
117+// out it returns OmitBudget if files read before this one (since StartFile)
118+// spent some of them, as this file may still fit on its own, and
119+// OmitUnavailable if this file alone is over MaxTotalBytes.
120+func (b *DiffBudget) ChargeLine(bytes int, changed bool) FileOmission {
121+ if changed && b.limits.MaxTotalLines > 0 {
122+ b.linesLeft--
123+ }
83 b.bytesLeft -= bytes124 b.bytesLeft -= bytes
84- return b.linesLeft >= 0 && b.bytesLeft >= 0125+ switch {
126+ case b.limits.MaxTotalLines > 0 && b.linesLeft < 0:
127+ return OmitBudget
128+ case b.bytesLeft < 0 && b.fileBytes < b.limits.MaxTotalBytes:
129+ return OmitBudget
130+ case b.bytesLeft < 0:
131+ return OmitUnavailable
132+ }
133+ return OmitNone
85 }134 }
M internal/domain/budget_test.go +118 -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,114 @@ 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+}
@@ -94,21 +94,32 @@ func TestDiffBudgetWindow(t *testing.T) {
94 }94 }
95 }95 }
96 96
97-func TestDiffBudgetHunks(t *testing.T) {97+func TestDiffBudgetAdmit(t *testing.T) {
98 for _, tt := range []struct {98 for _, tt := range []struct {
99- name string99+ name string
100- binary bool100+ limits DiffLimits
101- n, lines, bytes int101+ binary bool
102- want bool102+ path string
103+ n int
104+ wantRead bool
105+ wantWhy FileOmission
103 }{106 }{
104- {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false},107+ {"fits", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3, MaxTotalBytes: 1}, false, "a.go", 3, true, OmitNone},
105- {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false},108+ {"binary", DiffLimits{MaxTotalBytes: 1}, true, "a.bin", 3, false, OmitNone},
106- {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false},109+ {"no changes", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 0, false, OmitNone},
110+ {"file limit", DiffLimits{MaxFileLines: 3, MaxTotalLines: 5, MaxTotalBytes: 1}, false, "a.go", 4, false, OmitLarge},
111+ {"total lines", DiffLimits{MaxFileLines: 3, MaxTotalLines: 2, MaxTotalBytes: 1}, false, "a.go", 3, false, OmitBudget},
112+ {"no bytes", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3}, false, "a.go", 1, false, OmitBudget},
113+ {"generated", DiffLimits{SkipGenerated: true, MaxTotalBytes: 1}, false, "go.sum", 1, false, OmitGenerated},
114+ {"generated not skipped", DiffLimits{MaxTotalBytes: 1}, false, "go.sum", 1, true, OmitNone},
115+ {"generated before large", DiffLimits{SkipGenerated: true, MaxFileLines: 1, MaxTotalBytes: 1}, false, "go.sum", 9, false, OmitGenerated},
116+ {"unlimited lines", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 1 << 20, true, OmitNone},
107 } {117 } {
108 t.Run(tt.name, func(t *testing.T) {118 t.Run(tt.name, func(t *testing.T) {
109- b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes})119+ b := NewDiffBudget(tt.limits)
110- if got := b.WantsHunks(tt.binary, tt.n); got != tt.want {120+ read, why := b.Admit(tt.binary, tt.path, tt.n)
111- t.Fatalf("got %v", got)121+ if read != tt.wantRead || why != tt.wantWhy {
122+ t.Fatalf("got %v,%v", read, why)
112 }123 }
113 })124 })
114 }125 }
@@ -118,24 +129,114 @@ func TestDiffBudgetCharge(t *testing.T) {
118 for _, tt := range []struct {129 for _, tt := range []struct {
119 name string130 name string
120 bytes int131 bytes int
121- fits bool132+ want FileOmission
122- }{{"exact", 3, true}, {"over", 4, false}} {133+ }{{"exact", 3, OmitNone}, {"over", 4, OmitUnavailable}} {
123 t.Run(tt.name, func(t *testing.T) {134 t.Run(tt.name, func(t *testing.T) {
124 b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})135 b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})
125 before := b136 before := b
126 if b.FileLines() != 1 {137 if b.FileLines() != 1 {
127 t.Fatal("wrong file limit")138 t.Fatal("wrong file limit")
128 }139 }
129- if b.ChargeLine(tt.bytes) != tt.fits {140+ if got := b.ChargeLine(tt.bytes, true); got != tt.want {
130- t.Fatal("wrong charge result")141+ t.Fatalf("wrong charge result %v", got)
131 }142 }
132- if b.ChargeLine(0) {143+ if b.ChargeLine(0, true) == OmitNone {
133 t.Fatal("line budget exceeded")144 t.Fatal("line budget exceeded")
134 }145 }
135 b = before146 b = before
136- if !b.WantsHunks(false, 1) {147+ if read, _ := b.Admit(false, "a", 1); !read {
137 t.Fatal("discarded file consumed budget")148 t.Fatal("discarded file consumed budget")
138 }149 }
139 })150 })
140 }151 }
141 }152 }
153+
154+func TestDiffBudgetChargeReasons(t *testing.T) {
155+ b := NewDiffBudget(DiffLimits{MaxTotalLines: 1, MaxTotalBytes: 3})
156+ if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 1 {
157+ t.Fatal("context consumes bytes only")
158+ }
159+ if b.ChargeLine(1, true) != OmitNone || b.FileLines() != 0 {
160+ t.Fatal("first changed line fits")
161+ }
162+ if got := b.ChargeLine(0, true); got != OmitBudget {
163+ t.Fatalf("lines ran out: %v", got)
164+ }
165+ b = NewDiffBudget(DiffLimits{MaxTotalLines: 9, MaxTotalBytes: 1})
166+ if got := b.ChargeLine(2, false); got != OmitUnavailable {
167+ t.Fatalf("bytes ran out: %v", got)
168+ }
169+ free := NewDiffBudget(DiffLimits{MaxTotalBytes: 5})
170+ for range 100 {
171+ if free.ChargeLine(0, true) != OmitNone {
172+ t.Fatal("0 means no line limit")
173+ }
174+ }
175+ if free.FileLines() < 1<<30 {
176+ t.Fatal("unlimited FileLines")
177+ }
178+}
179+
180+func TestFileOmissionCollapsed(t *testing.T) {
181+ for o, want := range map[FileOmission]bool{OmitNone: false, OmitGenerated: true, OmitLarge: true, OmitBudget: true, OmitUnavailable: false} {
182+ if o.Collapsed() != want {
183+ t.Errorf("%d: got %v", o, !want)
184+ }
185+ }
186+}
187+
188+func TestOneFileDiffLimits(t *testing.T) {
189+ l := OneFileDiffLimits(3)
190+ if l.From != 3 || l.To != 4 || l.MaxFileLines != 0 || l.MaxAloneLines != MaxOneFileLines || l.MaxTotalLines != 0 || l.MaxFiles != 0 || l.MaxTotalBytes != DefaultDiffLimits().MaxTotalBytes {
191+ t.Fatalf("got %+v", l)
192+ }
193+ // A negative index (an unchecked IndexFunc) selects no file, not the whole diff.
194+ if _, from, to, _ := NewDiffBudget(OneFileDiffLimits(-1)).Window(10); from < to {
195+ t.Fatalf("OneFileDiffLimits(-1) selects files [%d, %d)", from, to)
196+ }
197+}
198+
199+func TestDiffBudgetBytesOfEarlierFiles(t *testing.T) {
200+ b := NewDiffBudget(DiffLimits{MaxTotalBytes: 10})
201+ b.StartFile()
202+ if b.ChargeLine(4, true) != OmitNone {
203+ t.Fatal("the first file fits")
204+ }
205+ b.StartFile()
206+ if got := b.ChargeLine(7, true); got != OmitBudget {
207+ t.Fatalf("a file meeting the bytes of earlier files: got %v, want OmitBudget", got)
208+ }
209+ alone := NewDiffBudget(DiffLimits{MaxTotalBytes: 10})
210+ alone.StartFile()
211+ if got := alone.ChargeLine(11, true); got != OmitUnavailable {
212+ t.Fatalf("a file over the bytes on its own: got %v, want OmitUnavailable", got)
213+ }
214+}
215+
216+func TestDiffBudgetContext(t *testing.T) {
217+ b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 2, MaxTotalBytes: 6})
218+ if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 2 {
219+ t.Fatal("context consumed changed-line budget")
220+ }
221+ first := b.ChargeLine(1, true)
222+ second := b.ChargeLine(1, true)
223+ if first != OmitNone || second != OmitNone || b.FileLines() != 0 {
224+ t.Fatal("additions and deletions must consume changed-line budget")
225+ }
226+ if b.ChargeLine(2, false) != OmitNone {
227+ t.Fatal("context at the byte boundary must fit even without changed lines left")
228+ }
229+ if b.ChargeLine(1, false) != OmitUnavailable {
230+ t.Fatal("context must not exceed the byte budget")
231+ }
232+}
233+
234+func TestDiffBudgetAdmitAloneLines(t *testing.T) {
235+ b := NewDiffBudget(OneFileDiffLimits(0))
236+ if read, why := b.Admit(false, "a.go", MaxOneFileLines); !read || why != OmitNone {
237+ t.Fatalf("at the cap: read=%v why=%v", read, why)
238+ }
239+ if read, why := b.Admit(false, "a.go", MaxOneFileLines+1); read || why != OmitUnavailable {
240+ t.Fatalf("over the cap: read=%v why=%v, want OmitUnavailable", read, why)
241+ }
242+}
A internal/domain/generated.go +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "path"
7+ "strings"
8+)
9+
10+var generatedSuffixes = []string{
11+ "_templ.go", ".pb.go", "_gen.go", ".gen.go",
12+ ".min.js", ".min.css", ".js.map", ".css.map",
13+}
14+
15+var generatedNames = map[string]struct{}{
16+ "go.sum": {}, "package-lock.json": {}, "pnpm-lock.yaml": {}, "yarn.lock": {},
17+ "Cargo.lock": {}, "bun.lockb": {}, "bun.lock": {}, "composer.lock": {},
18+ "Gemfile.lock": {}, "poetry.lock": {}, "uv.lock": {}, "flake.lock": {},
19+}
20+
21+// IsGeneratedPath reports whether a changed file is generated or vendored by
22+// its path alone: its diff is collapsed by default in the web view.
23+func IsGeneratedPath(p string) bool {
24+ if p == "" {
25+ return false
26+ }
27+ base := path.Base(p)
28+ if _, ok := generatedNames[base]; ok {
29+ return true
30+ }
31+ for _, s := range generatedSuffixes {
32+ if strings.HasSuffix(base, s) {
33+ return true
34+ }
35+ }
36+ // Directory segments only: the last segment is the file name.
37+ dir := p[:len(p)-len(base)]
38+ for dir != "" {
39+ seg, rest, _ := strings.Cut(dir, "/")
40+ if seg == "vendor" || seg == "node_modules" {
41+ return true
42+ }
43+ dir = rest
44+ }
45+ return false
46+}
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "path"
7+ "strings"
8+)
9+
10+var generatedSuffixes = []string{
11+ "_templ.go", ".pb.go", "_gen.go", ".gen.go",
12+ ".min.js", ".min.css", ".js.map", ".css.map",
13+}
14+
15+var generatedNames = map[string]struct{}{
16+ "go.sum": {}, "package-lock.json": {}, "pnpm-lock.yaml": {}, "yarn.lock": {},
17+ "Cargo.lock": {}, "bun.lockb": {}, "bun.lock": {}, "composer.lock": {},
18+ "Gemfile.lock": {}, "poetry.lock": {}, "uv.lock": {}, "flake.lock": {},
19+}
20+
21+// IsGeneratedPath reports whether a changed file is generated or vendored by
22+// its path alone: its diff is collapsed by default in the web view.
23+func IsGeneratedPath(p string) bool {
24+ if p == "" {
25+ return false
26+ }
27+ base := path.Base(p)
28+ if _, ok := generatedNames[base]; ok {
29+ return true
30+ }
31+ for _, s := range generatedSuffixes {
32+ if strings.HasSuffix(base, s) {
33+ return true
34+ }
35+ }
36+ // Directory segments only: the last segment is the file name.
37+ dir := p[:len(p)-len(base)]
38+ for dir != "" {
39+ seg, rest, _ := strings.Cut(dir, "/")
40+ if seg == "vendor" || seg == "node_modules" {
41+ return true
42+ }
43+ dir = rest
44+ }
45+ return false
46+}
A internal/domain/generated_test.go +51 -0
@@ -0,0 +1,51 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "testing"
6+
7+func TestIsGeneratedPath(t *testing.T) {
8+ t.Parallel()
9+ tests := []struct {
10+ path string
11+ want bool
12+ }{
13+ {"views/a_templ.go", true},
14+ {"api/x.pb.go", true},
15+ {"x_gen.go", true},
16+ {"x.gen.go", true},
17+ {"static/app.min.js", true},
18+ {"static/app.min.css", true},
19+ {"static/app.js.map", true},
20+ {"static/app.css.map", true},
21+ {"go.sum", true},
22+ {"web/package-lock.json", true},
23+ {"pnpm-lock.yaml", true},
24+ {"yarn.lock", true},
25+ {"Cargo.lock", true},
26+ {"bun.lockb", true},
27+ {"bun.lock", true},
28+ {"composer.lock", true},
29+ {"Gemfile.lock", true},
30+ {"poetry.lock", true},
31+ {"uv.lock", true},
32+ {"flake.lock", true},
33+ {"vendor/x.go", true},
34+ {"a/vendor/b/x.go", true},
35+ {"node_modules/a.js", true},
36+ {"web/node_modules/a.js", true},
37+ {"vendor.go", false},
38+ {"templ.go", false},
39+ {"main.go", false},
40+ {"go.mod", false},
41+ {"minify.js", false},
42+ {"myvendor/x.go", false},
43+ {"x.min.jsx", false},
44+ {"", false},
45+ }
46+ for _, tt := range tests {
47+ if got := IsGeneratedPath(tt.path); got != tt.want {
48+ t.Errorf("IsGeneratedPath(%q) = %v, want %v", tt.path, got, tt.want)
49+ }
50+ }
51+}
@@ -0,0 +1,51 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "testing"
6+
7+func TestIsGeneratedPath(t *testing.T) {
8+ t.Parallel()
9+ tests := []struct {
10+ path string
11+ want bool
12+ }{
13+ {"views/a_templ.go", true},
14+ {"api/x.pb.go", true},
15+ {"x_gen.go", true},
16+ {"x.gen.go", true},
17+ {"static/app.min.js", true},
18+ {"static/app.min.css", true},
19+ {"static/app.js.map", true},
20+ {"static/app.css.map", true},
21+ {"go.sum", true},
22+ {"web/package-lock.json", true},
23+ {"pnpm-lock.yaml", true},
24+ {"yarn.lock", true},
25+ {"Cargo.lock", true},
26+ {"bun.lockb", true},
27+ {"bun.lock", true},
28+ {"composer.lock", true},
29+ {"Gemfile.lock", true},
30+ {"poetry.lock", true},
31+ {"uv.lock", true},
32+ {"flake.lock", true},
33+ {"vendor/x.go", true},
34+ {"a/vendor/b/x.go", true},
35+ {"node_modules/a.js", true},
36+ {"web/node_modules/a.js", true},
37+ {"vendor.go", false},
38+ {"templ.go", false},
39+ {"main.go", false},
40+ {"go.mod", false},
41+ {"minify.js", false},
42+ {"myvendor/x.go", false},
43+ {"x.min.jsx", false},
44+ {"", false},
45+ }
46+ for _, tt := range tests {
47+ if got := IsGeneratedPath(tt.path); got != tt.want {
48+ t.Errorf("IsGeneratedPath(%q) = %v, want %v", tt.path, got, tt.want)
49+ }
50+ }
51+}
M internal/domain/git.go +69 -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 }
@@ -505,11 +505,37 @@ type FileDiff struct {
505 Additions, Deletions int505 Additions, Deletions int
506 // Similarity is a percentage for a renamed or copied file, 0 otherwise.506 // Similarity is a percentage for a renamed or copied file, 0 otherwise.
507 Similarity int507 Similarity int
508- // Truncated means the file exceeded a diff limit: Hunks is empty although508+ // Omitted tells why the hunks were not kept: Hunks is empty although the
509- // the file changed. Additions and Deletions stay accurate. A file outside509+ // file changed; Additions and Deletions stay accurate. A file outside the
510- // the DiffLimits window has no Hunks and is not Truncated.510+ // window is OmitNone with no Hunks.
511- Truncated bool511+ Omitted FileOmission
512- Hunks []Hunk512+ Hunks []Hunk
513+}
514+
515+// CommentPath is the path a comment designates for f, as Diff.HasLine reads it:
516+// the old path of a deleted file, the new path otherwise.
517+func (f FileDiff) CommentPath() string {
518+ if f.Status == FileDeleted {
519+ return f.OldPath
520+ }
521+ return f.NewPath
522+}
523+
524+// FileOmission tells why a changed file has no hunks.
525+type FileOmission uint8
526+
527+// File omissions.
528+const (
529+ OmitNone FileOmission = iota
530+ OmitGenerated // SkipGenerated and IsGeneratedPath: hunks not read
531+ OmitLarge // changed lines > MaxFileLines
532+ OmitBudget // did not fit in the remaining MaxTotalLines
533+ OmitUnavailable // retained text would exceed MaxTotalBytes
534+)
535+
536+// Collapsed reports a file the web view folds and can load on its own.
537+func (o FileOmission) Collapsed() bool {
538+ return o == OmitGenerated || o == OmitLarge || o == OmitBudget
513 }539 }
514 540
515 // Diff is the structured difference a commit introduces against its first541 // Diff is the structured difference a commit introduces against its first
@@ -518,9 +544,8 @@ type Diff struct {
518 // Files are ordered as Git reports them (by path). Files past544 // Files are ordered as Git reports them (by path). Files past
519 // DiffLimits.MaxFiles (when it is not 0) are left out.545 // DiffLimits.MaxFiles (when it is not 0) are left out.
520 Files []FileDiff546 Files []FileDiff
521- // Truncated is true when files were left out or a listed file is truncated.547+ // Truncated is true when files past DiffLimits.MaxFiles were left out. It
522- // Files listed without hunks because they are outside the DiffLimits window548+ // says nothing of the listed files: see FileDiff.Omitted.
523- // do not make it true.
524 Truncated bool549 Truncated bool
525 }550 }
526 551
@@ -537,33 +562,58 @@ type CommitDetail struct {
537 Diff Diff562 Diff Diff
538 }563 }
539 564
540-// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.565+// DiffLimits bound the size of a Diff. Line limits count additions plus
566+// deletions, not context. A file that does not fit is listed without hunks and
567+// with an Omitted reason.
541 type DiffLimits struct {568 type DiffLimits struct {
542 // MaxFiles is the number of files kept; 0 means no limit on the number of569 // MaxFiles is the number of files kept; 0 means no limit on the number of
543 // files (every changed file is listed).570 // files (every changed file is listed).
544 MaxFiles int571 MaxFiles int
545- // MaxFileLines is the most lines one file may have; a file over it is572+ // MaxFileLines is the most changed lines one file may have; a file over it is
546- // truncated.573+ // OmitLarge. 0 means no limit.
547 MaxFileLines int574 MaxFileLines int
548- // MaxTotalLines is the most lines of all the files. A file is truncated575+ // MaxTotalLines is the most changed lines of all the files. A file is
549- // when it does not fit in what remains; a smaller one after it may still576+ // OmitBudget when it does not fit in what remains; a smaller one after it
550- // fit.577+ // may still fit. 0 means no limit.
551 MaxTotalLines int578 MaxTotalLines int
552- // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds579+ // MaxTotalBytes is the most text, in bytes, of all the lines, including context. It bounds
553- // the memory of a diff whose lines are very long; files are truncated580+ // the memory of a diff whose lines are very long; a file whose
554- // like for MaxTotalLines.581+ // text does not fit is OmitUnavailable.
555 MaxTotalBytes int582 MaxTotalBytes int
556 // From and To select the files whose hunks are read: those with an index in583 // From and To select the files whose hunks are read: those with an index in
557 // [From, To), counted from 0 in the order of Diff.Files. The other files584 // [From, To), counted from 0 in the order of Diff.Files. The other files
558 // are listed (paths, status, modes, ids, Binary, Additions, Deletions,585 // are listed (paths, status, modes, ids, Binary, Additions, Deletions,
559- // Similarity) without hunks; they are not Truncated and use none of586+ // Similarity) without hunks; they are OmitNone and use none of
560 // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values587 // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values
561 // select every file. MaxFileLines applies to each file of the window.588 // select every file. MaxFileLines applies to each file of the window.
562 From, To int589 From, To int
590+ // MaxAloneLines is the most changed lines a file may have to be read at all;
591+ // a file over it is OmitUnavailable (it cannot be shown even alone), unlike
592+ // OmitLarge, which a read of that file alone can load. 0 means no limit.
593+ MaxAloneLines int
594+ // SkipGenerated omits the files of the window that IsGeneratedPath
595+ // recognizes (OmitGenerated), without reading their hunks.
596+ SkipGenerated bool
597+}
598+
599+// MaxOneFileLines is the most changed lines of a file read alone on the web
600+// (OneFileDiffLimits). A file over it is OmitUnavailable, so a short-lines
601+// file within the byte guard cannot produce millions of rows.
602+const MaxOneFileLines = 50000
603+
604+// OneFileDiffLimits reads the hunks of file i only, with no MaxFileLines cap;
605+// MaxAloneLines (MaxOneFileLines) and MaxTotalBytes are the guards. i must be at least 0: From -1 with To 0
606+// would mean the whole diff, so a negative i reads no file at all.
607+func OneFileDiffLimits(i int) DiffLimits {
608+ if i < 0 {
609+ return DiffLimits{From: 1, To: 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
610+ }
611+ return DiffLimits{From: i, To: i + 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
563 }612 }
564 613
565 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000614 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
566-// lines per file, 20,000 lines in total, plus 8 MiB of text.615+// changed lines per file, 20,000 changed lines in total, plus 8 MiB of text
616+// including context. Generated files are not skipped.
567 func DefaultDiffLimits() DiffLimits {617 func DefaultDiffLimits() DiffLimits {
568 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}618 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
569 }619 }
M internal/domain/mergerequest_test.go +1 -1
@@ -77,7 +77,7 @@ func hunkDiff() domain.Diff {
7777 {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{
7878 Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},
7979 }}},
80- {NewPath: "big.txt", Status: domain.FileAdded, Truncated: true},
80+ {NewPath: "big.txt", Status: domain.FileAdded, Omitted: domain.OmitLarge},
8181 {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{
8282 Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},
8383 }}},
@@ -77,7 +77,7 @@ func hunkDiff() domain.Diff {
77 {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{77 {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{
78 Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},78 Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},
79 }}},79 }}},
80- {NewPath: "big.txt", Status: domain.FileAdded, Truncated: true},80+ {NewPath: "big.txt", Status: domain.FileAdded, Omitted: domain.OmitLarge},
81 {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{81 {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{
82 Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},82 Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},
83 }}},83 }}},
M internal/ports/dto.go +20 -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
@@ -310,7 +312,22 @@ type GetMergeRequestOptions struct {
310312 }
311313
312314 // FileWindow is the range [From, To) of files whose hunks are read.
313-type FileWindow struct{ From, To int }
315+//
316+// Full asks for the one file From read in full: no line limit applies, only
317+// the byte guard (domain.OneFileDiffLimits). It requires To == From+1; a
318+// window with Full and any other To is domain.ErrInvalid.
319+type FileWindow struct {
320+ From, To int
321+ Full bool
322+}
323+
324+// GetCommitOptions say which diff BrowseService.GetCommit reads.
325+type GetCommitOptions struct {
326+ // Files, when set, reads the hunks of a window of the files, as
327+ // GetMergeRequestOptions.Files does. Unset (the zero value), the diff is
328+ // bounded by domain.DefaultDiffLimits.
329+ Files *FileWindow
330+}
314331
315332 // AddCommentParams describes the comment to add.
316333 type AddCommentParams struct {
@@ -298,8 +298,10 @@ type GetMergeRequestOptions struct {
298 SinceLastRead bool298 SinceLastRead bool
299 // Files, when set, lists every changed file, without the file-count299 // Files, when set, lists every changed file, without the file-count
300 // limit, and reads the hunks of the files in the window only, each within300 // limit, and reads the hunks of the files in the window only, each within
301- // the per-file and the total limits of domain.DefaultDiffLimits. Unset,301+ // the per-file and the total limits of domain.DefaultDiffLimits; generated
302- // the diff is bounded by domain.DefaultDiffLimits (the API contract).302+ // files are listed collapsed (domain.OmitGenerated). With Full, the single
303+ // file is read without the line limits. Unset, the diff is bounded by
304+ // domain.DefaultDiffLimits and generated files are read (the API contract).
303 Files *FileWindow305 Files *FileWindow
304 // Base and Head, when not zero, are the diff the caller shows: Get306 // Base and Head, when not zero, are the diff the caller shows: Get
305 // returns domain.ErrConflict when the request's diff base or head differ307 // returns domain.ErrConflict when the request's diff base or head differ
@@ -310,7 +312,22 @@ type GetMergeRequestOptions struct {
310 }312 }
311 313
312 // FileWindow is the range [From, To) of files whose hunks are read.314 // FileWindow is the range [From, To) of files whose hunks are read.
313-type FileWindow struct{ From, To int }315+//
316+// Full asks for the one file From read in full: no line limit applies, only
317+// the byte guard (domain.OneFileDiffLimits). It requires To == From+1; a
318+// window with Full and any other To is domain.ErrInvalid.
319+type FileWindow struct {
320+ From, To int
321+ Full bool
322+}
323+
324+// GetCommitOptions say which diff BrowseService.GetCommit reads.
325+type GetCommitOptions struct {
326+ // Files, when set, reads the hunks of a window of the files, as
327+ // GetMergeRequestOptions.Files does. Unset (the zero value), the diff is
328+ // bounded by domain.DefaultDiffLimits.
329+ Files *FileWindow
330+}
314 331
315 // AddCommentParams describes the comment to add.332 // AddCommentParams describes the comment to add.
316 type AddCommentParams struct {333 type AddCommentParams struct {
M internal/ports/mocks/BrowseService.go +18 -12
@@ -51,8 +51,8 @@ func (_m *BrowseService) EXPECT() *BrowseService_Expecter {
5151 }
5252
5353 // GetCommit provides a mock function for the type BrowseService
54-func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
55- ret := _mock.Called(ctx, p, repo, rev)
54+func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
55+ ret := _mock.Called(ctx, p, repo, rev, opts)
5656
5757 if len(ret) == 0 {
5858 panic("no return value specified for GetCommit")
@@ -60,16 +60,16 @@ func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, r
6060
6161 var r0 domain.CommitDetail
6262 var r1 error
63- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.CommitDetail, error)); ok {
64- return returnFunc(ctx, p, repo, rev)
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) (domain.CommitDetail, error)); ok {
64+ return returnFunc(ctx, p, repo, rev, opts)
6565 }
66- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.CommitDetail); ok {
67- r0 = returnFunc(ctx, p, repo, rev)
66+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) domain.CommitDetail); ok {
67+ r0 = returnFunc(ctx, p, repo, rev, opts)
6868 } else {
6969 r0 = ret.Get(0).(domain.CommitDetail)
7070 }
71- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok {
72- r1 = returnFunc(ctx, p, repo, rev)
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) error); ok {
72+ r1 = returnFunc(ctx, p, repo, rev, opts)
7373 } else {
7474 r1 = ret.Error(1)
7575 }
@@ -86,11 +86,12 @@ type BrowseService_GetCommit_Call struct {
8686 // - p domain.Principal
8787 // - repo domain.RepoName
8888 // - rev domain.Revision
89-func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any) *BrowseService_GetCommit_Call {
90- return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev)}
89+// - opts ports.GetCommitOptions
90+func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any, opts any) *BrowseService_GetCommit_Call {
91+ return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev, opts)}
9192 }
9293
93-func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_GetCommit_Call {
94+func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions)) *BrowseService_GetCommit_Call {
9495 _c.Call.Run(func(args mock.Arguments) {
9596 var arg0 context.Context
9697 if args[0] != nil {
@@ -108,11 +109,16 @@ func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p doma
108109 if args[3] != nil {
109110 arg3 = args[3].(domain.Revision)
110111 }
112+ var arg4 ports.GetCommitOptions
113+ if args[4] != nil {
114+ arg4 = args[4].(ports.GetCommitOptions)
115+ }
111116 run(
112117 arg0,
113118 arg1,
114119 arg2,
115120 arg3,
121+ arg4,
116122 )
117123 })
118124 return _c
@@ -123,7 +129,7 @@ func (_c *BrowseService_GetCommit_Call) Return(commitDetail domain.CommitDetail,
123129 return _c
124130 }
125131
126-func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {
132+func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {
127133 _c.Call.Return(run)
128134 return _c
129135 }
@@ -51,8 +51,8 @@ func (_m *BrowseService) EXPECT() *BrowseService_Expecter {
51 }51 }
52 52
53 // GetCommit provides a mock function for the type BrowseService53 // GetCommit provides a mock function for the type BrowseService
54-func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {54+func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
55- ret := _mock.Called(ctx, p, repo, rev)55+ ret := _mock.Called(ctx, p, repo, rev, opts)
56 56
57 if len(ret) == 0 {57 if len(ret) == 0 {
58 panic("no return value specified for GetCommit")58 panic("no return value specified for GetCommit")
@@ -60,16 +60,16 @@ func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, r
60 60
61 var r0 domain.CommitDetail61 var r0 domain.CommitDetail
62 var r1 error62 var r1 error
63- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.CommitDetail, error)); ok {63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) (domain.CommitDetail, error)); ok {
64- return returnFunc(ctx, p, repo, rev)64+ return returnFunc(ctx, p, repo, rev, opts)
65 }65 }
66- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.CommitDetail); ok {66+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) domain.CommitDetail); ok {
67- r0 = returnFunc(ctx, p, repo, rev)67+ r0 = returnFunc(ctx, p, repo, rev, opts)
68 } else {68 } else {
69 r0 = ret.Get(0).(domain.CommitDetail)69 r0 = ret.Get(0).(domain.CommitDetail)
70 }70 }
71- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok {71+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) error); ok {
72- r1 = returnFunc(ctx, p, repo, rev)72+ r1 = returnFunc(ctx, p, repo, rev, opts)
73 } else {73 } else {
74 r1 = ret.Error(1)74 r1 = ret.Error(1)
75 }75 }
@@ -86,11 +86,12 @@ type BrowseService_GetCommit_Call struct {
86 // - p domain.Principal86 // - p domain.Principal
87 // - repo domain.RepoName87 // - repo domain.RepoName
88 // - rev domain.Revision88 // - rev domain.Revision
89-func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any) *BrowseService_GetCommit_Call {89+// - opts ports.GetCommitOptions
90- return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev)}90+func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any, opts any) *BrowseService_GetCommit_Call {
91+ return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev, opts)}
91 }92 }
92 93
93-func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_GetCommit_Call {94+func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions)) *BrowseService_GetCommit_Call {
94 _c.Call.Run(func(args mock.Arguments) {95 _c.Call.Run(func(args mock.Arguments) {
95 var arg0 context.Context96 var arg0 context.Context
96 if args[0] != nil {97 if args[0] != nil {
@@ -108,11 +109,16 @@ func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p doma
108 if args[3] != nil {109 if args[3] != nil {
109 arg3 = args[3].(domain.Revision)110 arg3 = args[3].(domain.Revision)
110 }111 }
112+ var arg4 ports.GetCommitOptions
113+ if args[4] != nil {
114+ arg4 = args[4].(ports.GetCommitOptions)
115+ }
111 run(116 run(
112 arg0,117 arg0,
113 arg1,118 arg1,
114 arg2,119 arg2,
115 arg3,120 arg3,
121+ arg4,
116 )122 )
117 })123 })
118 return _c124 return _c
@@ -123,7 +129,7 @@ func (_c *BrowseService_GetCommit_Call) Return(commitDetail domain.CommitDetail,
123 return _c129 return _c
124 }130 }
125 131
126-func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {132+func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {
127 _c.Call.Return(run)133 _c.Call.Return(run)
128 return _c134 return _c
129 }135 }
M internal/ports/services.go +1 -1
@@ -95,7 +95,7 @@ type BrowseService interface {
9595 ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)
9696 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
9797 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)
98- GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)
98+ GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts GetCommitOptions) (domain.CommitDetail, error)
9999 // RepoStats counts the commits of the branch whose head is given, and
100100 // names the latest tag.
101101 RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error)
@@ -95,7 +95,7 @@ type BrowseService interface {
95 ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)95 ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)
96 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)96 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
97 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)97 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)
98- GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)98+ GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts GetCommitOptions) (domain.CommitDetail, error)
99 // RepoStats counts the commits of the branch whose head is given, and99 // RepoStats counts the commits of the branch whose head is given, and
100 // names the latest tag.100 // names the latest tag.
101 RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error)101 RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error)
M internal/readertest/conformance.go +2 -0
@@ -31,6 +31,8 @@ func Conformance(t *testing.T, mk Maker) {
3131 name string
3232 fn func(*testing.T, Maker)
3333 }{
34+ {"DiffChangedLineLimits", DiffChangedLineLimits},
35+ {"DiffOmissions", DiffOmissions},
3436 {"ListRefs", testListRefs},
3537 {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},
3638 {"DetachedHead", testDetachedHead},
@@ -31,6 +31,8 @@ func Conformance(t *testing.T, mk Maker) {
31 name string31 name string
32 fn func(*testing.T, Maker)32 fn func(*testing.T, Maker)
33 }{33 }{
34+ {"DiffChangedLineLimits", DiffChangedLineLimits},
35+ {"DiffOmissions", DiffOmissions},
34 {"ListRefs", testListRefs},36 {"ListRefs", testListRefs},
35 {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},37 {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},
36 {"DetachedHead", testDetachedHead},38 {"DetachedHead", testDetachedHead},
A internal/readertest/diff.go +185 -0
@@ -0,0 +1,185 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package readertest
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// DiffChangedLineLimits checks that context is retained without consuming the
18+// changed-line limits, but still consumes the byte budget.
19+func DiffChangedLineLimits(t *testing.T, mk Maker) {
20+ g, data := mk(t)
21+ f := NewFixture(t, data, "diff-context-budget")
22+ var before, after strings.Builder
23+ const hunks = 96
24+ for i := range hunks * 10 {
25+ line := fmt.Sprintf("context %04d", i)
26+ fmt.Fprintln(&before, line)
27+ if i%10 == 4 {
28+ line = fmt.Sprintf("changed %04d", i)
29+ }
30+ fmt.Fprintln(&after, line)
31+ }
32+ f.Write("many", before.String()).Commit("before")
33+ f.Write("many", after.String()).Commit("after")
34+ limits := domain.DiffLimits{MaxFileLines: hunks * 2, MaxTotalLines: hunks * 2, MaxTotalBytes: 1 << 20}
35+ d, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
36+ require.NoError(t, err)
37+ require.Len(t, d.Diff.Files, 1)
38+ file := d.Diff.Files[0]
39+ require.Equal(t, domain.OmitNone, file.Omitted)
40+ assert.False(t, d.Diff.Truncated)
41+ require.Len(t, file.Hunks, hunks)
42+ var changed, patchLines, textBytes, changedBytes int
43+ for _, h := range file.Hunks {
44+ for _, l := range h.Lines {
45+ patchLines++
46+ textBytes += len(l.Text)
47+ if l.Kind != domain.LineContext {
48+ changed++
49+ changedBytes += len(l.Text)
50+ }
51+ }
52+ }
53+ assert.Equal(t, hunks*2, changed)
54+ // Every hunk is read with no line limit too.
55+ free, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1 << 20})
56+ require.NoError(t, err)
57+ require.Len(t, free.Diff.Files[0].Hunks, hunks)
58+ assert.Greater(t, patchLines, limits.MaxFileLines)
59+ assert.Equal(t, hunks, file.Additions)
60+ assert.Equal(t, hunks, file.Deletions)
61+ // The exact byte boundary fits; one fewer byte must discard the file even
62+ // though every changed line's text alone easily fits.
63+ limits.MaxTotalBytes = textBytes
64+ d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
65+ require.NoError(t, err)
66+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
67+ limits.MaxTotalBytes--
68+ require.Greater(t, limits.MaxTotalBytes, changedBytes)
69+ d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
70+ require.NoError(t, err)
71+ assert.False(t, d.Diff.Truncated, "only MaxFiles sets Diff.Truncated")
72+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[0].Omitted)
73+ assert.Empty(t, d.Diff.Files[0].Hunks)
74+}
75+
76+// DiffOmissions checks the omission reasons: generated files skipped without
77+// reading their hunks, one file read without a line cap, and the MaxFiles
78+// overflow being the only thing that sets Diff.Truncated.
79+func DiffOmissions(t *testing.T, mk Maker) {
80+ g, data := mk(t)
81+ ctx := context.Background()
82+ f := NewFixture(t, data, "diff-omissions")
83+ f.Write("base.txt", "x\n").Commit("base")
84+ f.Write("a.txt", "small\n")
85+ // The generated file (515 bytes) would overflow the 300-byte budget below
86+ // if it were read: OmitGenerated, not OmitUnavailable, proves its hunks
87+ // are not read.
88+ f.Write("go.sum", strings.Repeat("h1:"+strings.Repeat("z", 100)+"\n", 5))
89+ f.Lines("z-big.txt", "line", 2500)
90+ f.Commit("change")
91+
92+ limits := domain.DiffLimits{MaxTotalBytes: 300, MaxFileLines: 2000, SkipGenerated: true}
93+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
94+ require.NoError(t, err)
95+ require.Len(t, d.Diff.Files, 3)
96+ byPath := map[string]domain.FileDiff{}
97+ for _, file := range d.Diff.Files {
98+ byPath[file.NewPath] = file
99+ }
100+ assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted)
101+ assert.Len(t, byPath["a.txt"].Hunks, 1)
102+ assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted)
103+ assert.Empty(t, byPath["go.sum"].Hunks)
104+ assert.Equal(t, 5, byPath["go.sum"].Additions)
105+ assert.Equal(t, domain.OmitLarge, byPath["z-big.txt"].Omitted)
106+ assert.Empty(t, byPath["z-big.txt"].Hunks)
107+ assert.False(t, d.Diff.Truncated)
108+
109+ // Read, go.sum overflows the bytes that a.txt, read before it, already
110+ // spent: it may fit on its own, so it is OmitBudget, loadable alone.
111+ limits.SkipGenerated = false
112+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
113+ require.NoError(t, err)
114+ sum := -1
115+ for i, file := range d.Diff.Files {
116+ if file.NewPath == "go.sum" {
117+ sum = i
118+ assert.Equal(t, domain.OmitBudget, file.Omitted, "earlier files spent bytes")
119+ assert.Empty(t, file.Hunks)
120+ }
121+ }
122+ require.GreaterOrEqual(t, sum, 0)
123+ // Alone, it is still over the bytes: OmitUnavailable, a dead end.
124+ alone := domain.DiffLimits{From: sum, To: sum + 1, MaxTotalBytes: 300}
125+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, alone)
126+ require.NoError(t, err)
127+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[sum].Omitted, "over the bytes on its own")
128+ assert.Empty(t, d.Diff.Files[sum].Hunks)
129+
130+ // OneFileDiffLimits reads a file over 2,000 changed lines, and only it.
131+ idx := -1
132+ for i, file := range d.Diff.Files {
133+ if file.NewPath == "z-big.txt" {
134+ idx = i
135+ }
136+ }
137+ require.GreaterOrEqual(t, idx, 0)
138+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(idx))
139+ require.NoError(t, err)
140+ for i, file := range d.Diff.Files {
141+ assert.Equal(t, domain.OmitNone, file.Omitted)
142+ if i == idx {
143+ require.Len(t, file.Hunks, 1)
144+ assert.Len(t, file.Hunks[0].Lines, 2500)
145+ } else {
146+ assert.Empty(t, file.Hunks)
147+ }
148+ }
149+ assert.False(t, d.Diff.Truncated)
150+
151+ // MaxFiles alone sets Diff.Truncated.
152+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxTotalBytes: 1 << 20})
153+ require.NoError(t, err)
154+ assert.Len(t, d.Diff.Files, 2)
155+ assert.True(t, d.Diff.Truncated)
156+ for _, file := range d.Diff.Files {
157+ assert.Equal(t, domain.OmitNone, file.Omitted)
158+ }
159+
160+ // A one-file read is capped at MaxOneFileLines changed lines: the cap
161+ // itself loads, one more line is OmitUnavailable (it cannot be loaded).
162+ f.Lines("cap.txt", "c", domain.MaxOneFileLines).Lines("over.txt", "o", domain.MaxOneFileLines+1).Commit("caps")
163+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1})
164+ require.NoError(t, err)
165+ capIdx, overIdx := -1, -1
166+ for i, file := range d.Diff.Files {
167+ switch file.NewPath {
168+ case "cap.txt":
169+ capIdx = i
170+ case "over.txt":
171+ overIdx = i
172+ }
173+ }
174+ require.GreaterOrEqual(t, capIdx, 0)
175+ require.GreaterOrEqual(t, overIdx, 0)
176+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(capIdx))
177+ require.NoError(t, err)
178+ assert.Equal(t, domain.OmitNone, d.Diff.Files[capIdx].Omitted)
179+ require.Len(t, d.Diff.Files[capIdx].Hunks, 1)
180+ assert.Len(t, d.Diff.Files[capIdx].Hunks[0].Lines, domain.MaxOneFileLines)
181+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(overIdx))
182+ require.NoError(t, err)
183+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[overIdx].Omitted)
184+ assert.Empty(t, d.Diff.Files[overIdx].Hunks)
185+}
@@ -0,0 +1,185 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package readertest
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// DiffChangedLineLimits checks that context is retained without consuming the
18+// changed-line limits, but still consumes the byte budget.
19+func DiffChangedLineLimits(t *testing.T, mk Maker) {
20+ g, data := mk(t)
21+ f := NewFixture(t, data, "diff-context-budget")
22+ var before, after strings.Builder
23+ const hunks = 96
24+ for i := range hunks * 10 {
25+ line := fmt.Sprintf("context %04d", i)
26+ fmt.Fprintln(&before, line)
27+ if i%10 == 4 {
28+ line = fmt.Sprintf("changed %04d", i)
29+ }
30+ fmt.Fprintln(&after, line)
31+ }
32+ f.Write("many", before.String()).Commit("before")
33+ f.Write("many", after.String()).Commit("after")
34+ limits := domain.DiffLimits{MaxFileLines: hunks * 2, MaxTotalLines: hunks * 2, MaxTotalBytes: 1 << 20}
35+ d, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
36+ require.NoError(t, err)
37+ require.Len(t, d.Diff.Files, 1)
38+ file := d.Diff.Files[0]
39+ require.Equal(t, domain.OmitNone, file.Omitted)
40+ assert.False(t, d.Diff.Truncated)
41+ require.Len(t, file.Hunks, hunks)
42+ var changed, patchLines, textBytes, changedBytes int
43+ for _, h := range file.Hunks {
44+ for _, l := range h.Lines {
45+ patchLines++
46+ textBytes += len(l.Text)
47+ if l.Kind != domain.LineContext {
48+ changed++
49+ changedBytes += len(l.Text)
50+ }
51+ }
52+ }
53+ assert.Equal(t, hunks*2, changed)
54+ // Every hunk is read with no line limit too.
55+ free, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1 << 20})
56+ require.NoError(t, err)
57+ require.Len(t, free.Diff.Files[0].Hunks, hunks)
58+ assert.Greater(t, patchLines, limits.MaxFileLines)
59+ assert.Equal(t, hunks, file.Additions)
60+ assert.Equal(t, hunks, file.Deletions)
61+ // The exact byte boundary fits; one fewer byte must discard the file even
62+ // though every changed line's text alone easily fits.
63+ limits.MaxTotalBytes = textBytes
64+ d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
65+ require.NoError(t, err)
66+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
67+ limits.MaxTotalBytes--
68+ require.Greater(t, limits.MaxTotalBytes, changedBytes)
69+ d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
70+ require.NoError(t, err)
71+ assert.False(t, d.Diff.Truncated, "only MaxFiles sets Diff.Truncated")
72+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[0].Omitted)
73+ assert.Empty(t, d.Diff.Files[0].Hunks)
74+}
75+
76+// DiffOmissions checks the omission reasons: generated files skipped without
77+// reading their hunks, one file read without a line cap, and the MaxFiles
78+// overflow being the only thing that sets Diff.Truncated.
79+func DiffOmissions(t *testing.T, mk Maker) {
80+ g, data := mk(t)
81+ ctx := context.Background()
82+ f := NewFixture(t, data, "diff-omissions")
83+ f.Write("base.txt", "x\n").Commit("base")
84+ f.Write("a.txt", "small\n")
85+ // The generated file (515 bytes) would overflow the 300-byte budget below
86+ // if it were read: OmitGenerated, not OmitUnavailable, proves its hunks
87+ // are not read.
88+ f.Write("go.sum", strings.Repeat("h1:"+strings.Repeat("z", 100)+"\n", 5))
89+ f.Lines("z-big.txt", "line", 2500)
90+ f.Commit("change")
91+
92+ limits := domain.DiffLimits{MaxTotalBytes: 300, MaxFileLines: 2000, SkipGenerated: true}
93+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
94+ require.NoError(t, err)
95+ require.Len(t, d.Diff.Files, 3)
96+ byPath := map[string]domain.FileDiff{}
97+ for _, file := range d.Diff.Files {
98+ byPath[file.NewPath] = file
99+ }
100+ assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted)
101+ assert.Len(t, byPath["a.txt"].Hunks, 1)
102+ assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted)
103+ assert.Empty(t, byPath["go.sum"].Hunks)
104+ assert.Equal(t, 5, byPath["go.sum"].Additions)
105+ assert.Equal(t, domain.OmitLarge, byPath["z-big.txt"].Omitted)
106+ assert.Empty(t, byPath["z-big.txt"].Hunks)
107+ assert.False(t, d.Diff.Truncated)
108+
109+ // Read, go.sum overflows the bytes that a.txt, read before it, already
110+ // spent: it may fit on its own, so it is OmitBudget, loadable alone.
111+ limits.SkipGenerated = false
112+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
113+ require.NoError(t, err)
114+ sum := -1
115+ for i, file := range d.Diff.Files {
116+ if file.NewPath == "go.sum" {
117+ sum = i
118+ assert.Equal(t, domain.OmitBudget, file.Omitted, "earlier files spent bytes")
119+ assert.Empty(t, file.Hunks)
120+ }
121+ }
122+ require.GreaterOrEqual(t, sum, 0)
123+ // Alone, it is still over the bytes: OmitUnavailable, a dead end.
124+ alone := domain.DiffLimits{From: sum, To: sum + 1, MaxTotalBytes: 300}
125+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, alone)
126+ require.NoError(t, err)
127+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[sum].Omitted, "over the bytes on its own")
128+ assert.Empty(t, d.Diff.Files[sum].Hunks)
129+
130+ // OneFileDiffLimits reads a file over 2,000 changed lines, and only it.
131+ idx := -1
132+ for i, file := range d.Diff.Files {
133+ if file.NewPath == "z-big.txt" {
134+ idx = i
135+ }
136+ }
137+ require.GreaterOrEqual(t, idx, 0)
138+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(idx))
139+ require.NoError(t, err)
140+ for i, file := range d.Diff.Files {
141+ assert.Equal(t, domain.OmitNone, file.Omitted)
142+ if i == idx {
143+ require.Len(t, file.Hunks, 1)
144+ assert.Len(t, file.Hunks[0].Lines, 2500)
145+ } else {
146+ assert.Empty(t, file.Hunks)
147+ }
148+ }
149+ assert.False(t, d.Diff.Truncated)
150+
151+ // MaxFiles alone sets Diff.Truncated.
152+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxTotalBytes: 1 << 20})
153+ require.NoError(t, err)
154+ assert.Len(t, d.Diff.Files, 2)
155+ assert.True(t, d.Diff.Truncated)
156+ for _, file := range d.Diff.Files {
157+ assert.Equal(t, domain.OmitNone, file.Omitted)
158+ }
159+
160+ // A one-file read is capped at MaxOneFileLines changed lines: the cap
161+ // itself loads, one more line is OmitUnavailable (it cannot be loaded).
162+ f.Lines("cap.txt", "c", domain.MaxOneFileLines).Lines("over.txt", "o", domain.MaxOneFileLines+1).Commit("caps")
163+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1})
164+ require.NoError(t, err)
165+ capIdx, overIdx := -1, -1
166+ for i, file := range d.Diff.Files {
167+ switch file.NewPath {
168+ case "cap.txt":
169+ capIdx = i
170+ case "over.txt":
171+ overIdx = i
172+ }
173+ }
174+ require.GreaterOrEqual(t, capIdx, 0)
175+ require.GreaterOrEqual(t, overIdx, 0)
176+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(capIdx))
177+ require.NoError(t, err)
178+ assert.Equal(t, domain.OmitNone, d.Diff.Files[capIdx].Omitted)
179+ require.Len(t, d.Diff.Files[capIdx].Hunks, 1)
180+ assert.Len(t, d.Diff.Files[capIdx].Hunks[0].Lines, domain.MaxOneFileLines)
181+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(overIdx))
182+ require.NoError(t, err)
183+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[overIdx].Omitted)
184+ assert.Empty(t, d.Diff.Files[overIdx].Hunks)
185+}
M internal/usecase/browse.go +8 -3
@@ -336,12 +336,17 @@ 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+ limits, err := windowLimits(opts.Files)
343+ if err != nil {
344+ return domain.CommitDetail{}, err
345+ }
341346 if _, err := b.authorize(ctx, p, repo); err != nil {
342347 return domain.CommitDetail{}, err
343348 }
344- d, err := b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())
349+ d, err := b.reader.GetCommit(ctx, repo, rev, limits)
345350 if err != nil {
346351 return domain.CommitDetail{}, err
347352 }
@@ -336,12 +336,17 @@ func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int,
336 }336 }
337 337
338 // GetCommit returns a commit with its statistics and its diff against the338 // GetCommit returns a commit with its statistics and its diff against the
339-// first parent, within the default diff limits.339+// first parent, within the default diff limits or, with opts.Files, the limits
340-func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {340+// of that file window. An invalid window gives domain.ErrInvalid.
341+func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
342+ limits, err := windowLimits(opts.Files)
343+ if err != nil {
344+ return domain.CommitDetail{}, err
345+ }
341 if _, err := b.authorize(ctx, p, repo); err != nil {346 if _, err := b.authorize(ctx, p, repo); err != nil {
342 return domain.CommitDetail{}, err347 return domain.CommitDetail{}, err
343 }348 }
344- d, err := b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())349+ d, err := b.reader.GetCommit(ctx, repo, rev, limits)
345 if err != nil {350 if err != nil {
346 return domain.CommitDetail{}, err351 return domain.CommitDetail{}, err
347 }352 }
M internal/usecase/browse_test.go +4 -4
@@ -104,7 +104,7 @@ func browseOps() []browseOp {
104104 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
105105 }},
106106 {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
107- _, err := b.GetCommit(ctx, p, repo, domain.Revision{})
107+ _, err := b.GetCommit(ctx, p, repo, domain.Revision{}, ports.GetCommitOptions{})
108108 return err
109109 }, func(r *mocks.RepoReader) {
110110 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
@@ -629,7 +629,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
629629 Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil)
630630 v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}).
631631 Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified})
632- d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})
632+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
633633 require.NoError(t, err)
634634 assert.Equal(t, verified, d.Commit.Verification)
635635 assert.Equal(t, 2, d.Stats.FilesChanged)
@@ -642,7 +642,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
642642 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
643643 require.NoError(t, err)
644644 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{})
645+ _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
646646 assert.ErrorIs(t, err, domain.ErrNotFound)
647647 })
648648
@@ -657,7 +657,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
657657 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
658658 require.NoError(t, err)
659659 assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification)
660- d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})
660+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
661661 require.NoError(t, err)
662662 assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification)
663663 })
@@ -104,7 +104,7 @@ func browseOps() []browseOp {
104 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)104 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
105 }},105 }},
106 {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {106 {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
107- _, err := b.GetCommit(ctx, p, repo, domain.Revision{})107+ _, err := b.GetCommit(ctx, p, repo, domain.Revision{}, ports.GetCommitOptions{})
108 return err108 return err
109 }, func(r *mocks.RepoReader) {109 }, func(r *mocks.RepoReader) {
110 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)110 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
@@ -629,7 +629,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
629 Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil)629 Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil)
630 v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}).630 v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}).
631 Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified})631 Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified})
632- d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})632+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
633 require.NoError(t, err)633 require.NoError(t, err)
634 assert.Equal(t, verified, d.Commit.Verification)634 assert.Equal(t, verified, d.Commit.Verification)
635 assert.Equal(t, 2, d.Stats.FilesChanged)635 assert.Equal(t, 2, d.Stats.FilesChanged)
@@ -642,7 +642,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
642 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})642 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
643 require.NoError(t, err)643 require.NoError(t, err)
644 f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound)644 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{})645+ _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
646 assert.ErrorIs(t, err, domain.ErrNotFound)646 assert.ErrorIs(t, err, domain.ErrNotFound)
647 })647 })
648 648
@@ -657,7 +657,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
657 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})657 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
658 require.NoError(t, err)658 require.NoError(t, err)
659 assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification)659 assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification)
660- d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})660+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
661 require.NoError(t, err)661 require.NoError(t, err)
662 assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification)662 assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification)
663 })663 })
M internal/usecase/ci_pull_test.go +1 -1
@@ -107,7 +107,7 @@ func TestFilePatch(t *testing.T) {
107107 assert.Equal(t, "@@ -1,2 +1,2 @@ func x()\n a\n-b\n+c\n\\ No newline at end of file\n@@ -9 +9 @@\n+z", got)
108108 _, ok = usecase.FilePatch(domain.FileDiff{Binary: true, Hunks: f.Hunks})
109109 assert.False(t, ok, "binary")
110- _, ok = usecase.FilePatch(domain.FileDiff{Truncated: true})
110+ _, ok = usecase.FilePatch(domain.FileDiff{Omitted: domain.OmitLarge})
111111 assert.False(t, ok, "truncated")
112112 _, ok = usecase.FilePatch(domain.FileDiff{Status: domain.FileRenamed})
113113 assert.False(t, ok, "a pure rename has no hunk")
@@ -107,7 +107,7 @@ func TestFilePatch(t *testing.T) {
107 assert.Equal(t, "@@ -1,2 +1,2 @@ func x()\n a\n-b\n+c\n\\ No newline at end of file\n@@ -9 +9 @@\n+z", got)107 assert.Equal(t, "@@ -1,2 +1,2 @@ func x()\n a\n-b\n+c\n\\ No newline at end of file\n@@ -9 +9 @@\n+z", got)
108 _, ok = usecase.FilePatch(domain.FileDiff{Binary: true, Hunks: f.Hunks})108 _, ok = usecase.FilePatch(domain.FileDiff{Binary: true, Hunks: f.Hunks})
109 assert.False(t, ok, "binary")109 assert.False(t, ok, "binary")
110- _, ok = usecase.FilePatch(domain.FileDiff{Truncated: true})110+ _, ok = usecase.FilePatch(domain.FileDiff{Omitted: domain.OmitLarge})
111 assert.False(t, ok, "truncated")111 assert.False(t, ok, "truncated")
112 _, ok = usecase.FilePatch(domain.FileDiff{Status: domain.FileRenamed})112 _, ok = usecase.FilePatch(domain.FileDiff{Status: domain.FileRenamed})
113 assert.False(t, ok, "a pure rename has no hunk")113 assert.False(t, ok, "a pure rename has no hunk")
M internal/usecase/ci_pullfiles.go +1 -1
@@ -212,7 +212,7 @@ func escapeURLPath(p string) string {
212212 // (hunk headers and lines, no file headers). Absent for a binary file, a pure
213213 // rename and a file too large for the diff limits, as on GitHub.
214214 func filePatch(f domain.FileDiff) (string, bool) {
215- if f.Binary || f.Truncated || len(f.Hunks) == 0 {
215+ if f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 {
216216 return "", false
217217 }
218218 var b strings.Builder
@@ -212,7 +212,7 @@ func escapeURLPath(p string) string {
212 // (hunk headers and lines, no file headers). Absent for a binary file, a pure212 // (hunk headers and lines, no file headers). Absent for a binary file, a pure
213 // rename and a file too large for the diff limits, as on GitHub.213 // rename and a file too large for the diff limits, as on GitHub.
214 func filePatch(f domain.FileDiff) (string, bool) {214 func filePatch(f domain.FileDiff) (string, bool) {
215- if f.Binary || f.Truncated || len(f.Hunks) == 0 {215+ if f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 {
216 return "", false216 return "", false
217 }217 }
218 var b strings.Builder218 var b strings.Builder
M internal/usecase/export_test.go +3 -0
@@ -217,3 +217,6 @@ func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) {
217217 func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {
218218 return s.popDue(retryItem{key: repo, due: due})
219219 }
220+
221+// WindowLimits exposes windowLimits to the tests.
222+var WindowLimits = windowLimits
@@ -217,3 +217,6 @@ func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) {
217 func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {217 func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {
218 return s.popDue(retryItem{key: repo, due: due})218 return s.popDue(retryItem{key: repo, due: due})
219 }219 }
220+
221+// WindowLimits exposes windowLimits to the tests.
222+var WindowLimits = windowLimits
M internal/usecase/mergerequests.go +32 -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 {
@@ -763,18 +780,22 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
763780 // file's hunks within the default total limits would reject a comment on a
764781 // file the earlier ones left no budget for. So a first read lists the files
765782 // 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.
783+ // second loads the one file in full (a Full window: the byte guard and
784+ // domain.MaxOneFileLines only, generated files included), as the web view
785+ // does when it loads a collapsed file: any line the page can show can be
786+ // commented on, and a file over those limits takes no line comment.
768787 list := domain.DefaultDiffLimits()
769788 list.MaxFiles, list.From = 0, math.MaxInt
770789 _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list)
771790 if err != nil {
772791 return err
773792 }
774- idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return diffFileName(f) == params.Path })
793+ idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return f.CommentPath() == params.Path })
775794 if idx >= 0 {
776- one := domain.DefaultDiffLimits()
777- one.MaxFiles, one.From, one.To = 0, idx, idx+1
795+ one, err := windowLimits(&ports.FileWindow{From: idx, To: idx + 1, Full: true})
796+ if err != nil {
797+ return err
798+ }
778799 if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil {
779800 return err
780801 }
@@ -788,14 +809,6 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
788809 return v.Err()
789810 }
790811
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-
799812 // ResolveComment marks a comment resolved or unresolved. The principal needs
800813 // mr:write. An unknown comment gives domain.ErrNotFound and a request that is
801814 // not open domain.ErrPreconditionFailed. Setting the state the comment already
@@ -493,6 +493,26 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain
493 return repo, mr, nil493 return repo, mr, nil
494 }494 }
495 495
496+// windowLimits is the diff limits of a read: nil gives
497+// domain.DefaultDiffLimits (the API contract, generated files read); a window
498+// lists every file (MaxFiles 0), reads From..To and skips generated files; a
499+// Full window reads the one file From with domain.OneFileDiffLimits. An invalid
500+// window (negative, To < From, Full without To == From+1) is domain.ErrInvalid.
501+func windowLimits(w *ports.FileWindow) (domain.DiffLimits, error) {
502+ if w == nil {
503+ return domain.DefaultDiffLimits(), nil
504+ }
505+ if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) || (w.Full && w.To != w.From+1) {
506+ return domain.DiffLimits{}, fmt.Errorf("%w: file window [%d, %d) full=%t", domain.ErrInvalid, w.From, w.To, w.Full)
507+ }
508+ if w.Full {
509+ return domain.OneFileDiffLimits(w.From), nil
510+ }
511+ l := domain.DefaultDiffLimits()
512+ l.MaxFiles, l.From, l.To, l.SkipGenerated = 0, w.From, w.To, true
513+ return l, nil
514+}
515+
496 // Get returns a merge request with its commits, its diff, its comments and the516 // Get returns a merge request with its commits, its diff, its comments and the
497 // read position. The diff compares the merge base of the target and the head517 // read position. The diff compares the merge base of the target and the head
498 // with the head ("target...head"), within domain.DefaultDiffLimits. With518 // with the head ("target...head"), within domain.DefaultDiffLimits. With
@@ -507,12 +527,9 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain
507 // diff is no longer the one the caller shows (before any diff is read);527 // diff is no longer the one the caller shows (before any diff is read);
508 // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid.528 // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid.
509 func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {529 func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
510- limits := domain.DefaultDiffLimits()530+ limits, err := windowLimits(opts.Files)
511- if w := opts.Files; w != nil {531+ if err != nil {
512- if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) {532+ return ports.MergeRequestDetail{}, err
513- return ports.MergeRequestDetail{}, fmt.Errorf("%w: file window [%d, %d)", domain.ErrInvalid, w.From, w.To)
514- }
515- limits.MaxFiles, limits.From, limits.To = 0, w.From, w.To
516 }533 }
517 _, mr, err := m.get(ctx, p, name, number)534 _, mr, err := m.get(ctx, p, name, number)
518 if err != nil {535 if err != nil {
@@ -763,18 +780,22 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
763 // file's hunks within the default total limits would reject a comment on a780 // file's hunks within the default total limits would reject a comment on a
764 // file the earlier ones left no budget for. So a first read lists the files781 // file the earlier ones left no budget for. So a first read lists the files
765 // with no hunks (From past the end: cheap, git is stopped at once), and a782 // 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 limit783+ // second loads the one file in full (a Full window: the byte guard and
767- // and total limits, as the web view does for a window holding it.784+ // domain.MaxOneFileLines only, generated files included), as the web view
785+ // does when it loads a collapsed file: any line the page can show can be
786+ // commented on, and a file over those limits takes no line comment.
768 list := domain.DefaultDiffLimits()787 list := domain.DefaultDiffLimits()
769 list.MaxFiles, list.From = 0, math.MaxInt788 list.MaxFiles, list.From = 0, math.MaxInt
770 _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list)789 _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list)
771 if err != nil {790 if err != nil {
772 return err791 return err
773 }792 }
774- idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return diffFileName(f) == params.Path })793+ idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return f.CommentPath() == params.Path })
775 if idx >= 0 {794 if idx >= 0 {
776- one := domain.DefaultDiffLimits()795+ one, err := windowLimits(&ports.FileWindow{From: idx, To: idx + 1, Full: true})
777- one.MaxFiles, one.From, one.To = 0, idx, idx+1796+ if err != nil {
797+ return err
798+ }
778 if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil {799 if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil {
779 return err800 return err
780 }801 }
@@ -788,14 +809,6 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
788 return v.Err()809 return v.Err()
789 }810 }
790 811
791-// diffFileName is the path a comment designates for f, as Diff.HasLine reads it.
792-func diffFileName(f domain.FileDiff) string {
793- if f.Status == domain.FileDeleted {
794- return f.OldPath
795- }
796- return f.NewPath
797-}
798-
799 // ResolveComment marks a comment resolved or unresolved. The principal needs812 // ResolveComment marks a comment resolved or unresolved. The principal needs
800 // mr:write. An unknown comment gives domain.ErrNotFound and a request that is813 // mr:write. An unknown comment gives domain.ErrNotFound and a request that is
801 // not open domain.ErrPreconditionFailed. Setting the state the comment already814 // not open domain.ErrPreconditionFailed. Setting the state the comment already
M internal/usecase/mergerequests_files_test.go +1 -1
@@ -63,7 +63,7 @@ func TestGetMergeRequestFilesWindow(t *testing.T) {
6363 for i, f := range d.Diff.Files {
6464 assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath)
6565 assert.Equal(t, 1, f.Additions)
66- assert.False(t, f.Truncated)
66+ assert.Equal(t, domain.OmitNone, f.Omitted)
6767 if i >= 305 && i < 310 {
6868 require.Len(t, f.Hunks, 1, f.NewPath)
6969 assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text)
@@ -63,7 +63,7 @@ func TestGetMergeRequestFilesWindow(t *testing.T) {
63 for i, f := range d.Diff.Files {63 for i, f := range d.Diff.Files {
64 assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath)64 assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath)
65 assert.Equal(t, 1, f.Additions)65 assert.Equal(t, 1, f.Additions)
66- assert.False(t, f.Truncated)66+ assert.Equal(t, domain.OmitNone, f.Omitted)
67 if i >= 305 && i < 310 {67 if i >= 305 && i < 310 {
68 require.Len(t, f.Hunks, 1, f.NewPath)68 require.Len(t, f.Hunks, 1, f.NewPath)
69 assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text)69 assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text)
A internal/usecase/window_test.go +166 -0
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/usecase"
16+)
17+
18+func TestWindowLimits(t *testing.T) {
19+ def := domain.DefaultDiffLimits()
20+ win := def
21+ win.MaxFiles, win.From, win.To, win.SkipGenerated = 0, 2, 5, true
22+ open := win
23+ open.From, open.To = 7, 0
24+
25+ tests := []struct {
26+ name string
27+ w *ports.FileWindow
28+ want domain.DiffLimits
29+ bad bool
30+ }{
31+ {name: "nil is the default", want: def},
32+ {name: "a window", w: &ports.FileWindow{From: 2, To: 5}, want: win},
33+ {name: "an open window", w: &ports.FileWindow{From: 7}, want: open},
34+ {name: "full", w: &ports.FileWindow{From: 3, To: 4, Full: true}, want: domain.OneFileDiffLimits(3)},
35+ {name: "negative from", w: &ports.FileWindow{From: -1}, bad: true},
36+ {name: "negative to", w: &ports.FileWindow{To: -1}, bad: true},
37+ {name: "to before from", w: &ports.FileWindow{From: 5, To: 3}, bad: true},
38+ {name: "full without to", w: &ports.FileWindow{From: 3, Full: true}, bad: true},
39+ {name: "full of two files", w: &ports.FileWindow{From: 3, To: 5, Full: true}, bad: true},
40+ {name: "full of no file", w: &ports.FileWindow{From: 3, To: 3, Full: true}, bad: true},
41+ }
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ got, err := usecase.WindowLimits(tt.w)
45+ if tt.bad {
46+ require.ErrorIs(t, err, domain.ErrInvalid)
47+ return
48+ }
49+ require.NoError(t, err)
50+ assert.Equal(t, tt.want, got)
51+ })
52+ }
53+}
54+
55+func TestGetMergeRequestFullAndGenerated(t *testing.T) {
56+ r := newMRRig(t)
57+ r.seed()
58+ r.sh("checkout", "-q", "-B", "feat", "main")
59+ r.commit(author, "a.txt", "one\n", "a")
60+ r.commit(author, "go.sum", "x y z\n", "sum")
61+ r.push("feat")
62+ n := r.open("feat").Number
63+
64+ t.Run("full with a wider window is invalid", func(t *testing.T) {
65+ _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}})
66+ require.ErrorIs(t, err, domain.ErrInvalid)
67+ })
68+
69+ t.Run("a windowed read collapses a generated file, a nil read does not", func(t *testing.T) {
70+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 10}})
71+ require.NoError(t, err)
72+ byPath := map[string]domain.FileDiff{}
73+ for _, f := range d.Diff.Files {
74+ byPath[f.NewPath] = f
75+ }
76+ assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted)
77+ assert.Empty(t, byPath["go.sum"].Hunks)
78+ assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted)
79+ assert.NotEmpty(t, byPath["a.txt"].Hunks)
80+
81+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{})
82+ require.NoError(t, err)
83+ for _, f := range d.Diff.Files {
84+ assert.Equal(t, domain.OmitNone, f.Omitted, f.NewPath)
85+ assert.NotEmpty(t, f.Hunks, f.NewPath)
86+ }
87+ })
88+}
89+
90+func bigContent(n int) string {
91+ var b strings.Builder
92+ for i := range n {
93+ fmt.Fprintf(&b, "line %d\n", i)
94+ }
95+ return b.String()
96+}
97+
98+func TestCheckLineBeyondFileLimit(t *testing.T) {
99+ r := newMRRig(t)
100+ r.seed()
101+ r.sh("checkout", "-q", "-B", "feat", "main")
102+ r.commit(author, "big.txt", bigContent(3000), "big")
103+ r.commit(author, "go.sum", "h1:x\nh1:y\n", "sum")
104+ r.push("feat")
105+ n := r.open("feat").Number
106+
107+ add := func(path string, line int) error {
108+ _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: path, Line: line, Side: domain.SideNew, Text: "c"})
109+ return err
110+ }
111+ require.NoError(t, add("big.txt", 1))
112+ require.NoError(t, add("big.txt", 2500))
113+ require.NoError(t, add("big.txt", 3000))
114+ require.ErrorIs(t, add("big.txt", 3001), domain.ErrInvalid)
115+ // A generated file is collapsed in the web windows, but loadable: its
116+ // lines can be commented on.
117+ require.NoError(t, add("go.sum", 2))
118+}
119+
120+func TestBrowseGetCommitFullWindow(t *testing.T) {
121+ r := newMRRig(t)
122+ r.seed()
123+ r.commit(author, "a-big.txt", bigContent(3000), "big")
124+ id := r.head()
125+ r.push("main")
126+ b := usecase.NewBrowse(r.store, r.git)
127+ rev := domain.RevisionOf(id)
128+
129+ d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{})
130+ require.NoError(t, err)
131+ require.Len(t, d.Diff.Files, 1)
132+ assert.Equal(t, domain.OmitLarge, d.Diff.Files[0].Omitted)
133+ assert.Empty(t, d.Diff.Files[0].Hunks)
134+
135+ d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 1, Full: true}})
136+ require.NoError(t, err)
137+ require.Len(t, d.Diff.Files, 1)
138+ f := d.Diff.Files[0]
139+ assert.Equal(t, domain.OmitNone, f.Omitted)
140+ require.NotEmpty(t, f.Hunks)
141+ assert.Equal(t, 3000, f.Additions)
142+
143+ _, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}})
144+ require.ErrorIs(t, err, domain.ErrInvalid)
145+}
146+
147+func TestBrowseGetCommitWindowCollapsesGenerated(t *testing.T) {
148+ r := newMRRig(t)
149+ r.seed()
150+ r.commit(author, "go.sum", "h1:x\n", "sum")
151+ id := r.head()
152+ r.push("main")
153+ b := usecase.NewBrowse(r.store, r.git)
154+ rev := domain.RevisionOf(id)
155+
156+ d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{})
157+ require.NoError(t, err)
158+ require.Len(t, d.Diff.Files, 1)
159+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "API reads keep generated files")
160+
161+ d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 25}})
162+ require.NoError(t, err)
163+ require.Len(t, d.Diff.Files, 1)
164+ assert.Equal(t, domain.OmitGenerated, d.Diff.Files[0].Omitted, "web windows collapse them")
165+ assert.Empty(t, d.Diff.Files[0].Hunks)
166+}
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/usecase"
16+)
17+
18+func TestWindowLimits(t *testing.T) {
19+ def := domain.DefaultDiffLimits()
20+ win := def
21+ win.MaxFiles, win.From, win.To, win.SkipGenerated = 0, 2, 5, true
22+ open := win
23+ open.From, open.To = 7, 0
24+
25+ tests := []struct {
26+ name string
27+ w *ports.FileWindow
28+ want domain.DiffLimits
29+ bad bool
30+ }{
31+ {name: "nil is the default", want: def},
32+ {name: "a window", w: &ports.FileWindow{From: 2, To: 5}, want: win},
33+ {name: "an open window", w: &ports.FileWindow{From: 7}, want: open},
34+ {name: "full", w: &ports.FileWindow{From: 3, To: 4, Full: true}, want: domain.OneFileDiffLimits(3)},
35+ {name: "negative from", w: &ports.FileWindow{From: -1}, bad: true},
36+ {name: "negative to", w: &ports.FileWindow{To: -1}, bad: true},
37+ {name: "to before from", w: &ports.FileWindow{From: 5, To: 3}, bad: true},
38+ {name: "full without to", w: &ports.FileWindow{From: 3, Full: true}, bad: true},
39+ {name: "full of two files", w: &ports.FileWindow{From: 3, To: 5, Full: true}, bad: true},
40+ {name: "full of no file", w: &ports.FileWindow{From: 3, To: 3, Full: true}, bad: true},
41+ }
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ got, err := usecase.WindowLimits(tt.w)
45+ if tt.bad {
46+ require.ErrorIs(t, err, domain.ErrInvalid)
47+ return
48+ }
49+ require.NoError(t, err)
50+ assert.Equal(t, tt.want, got)
51+ })
52+ }
53+}
54+
55+func TestGetMergeRequestFullAndGenerated(t *testing.T) {
56+ r := newMRRig(t)
57+ r.seed()
58+ r.sh("checkout", "-q", "-B", "feat", "main")
59+ r.commit(author, "a.txt", "one\n", "a")
60+ r.commit(author, "go.sum", "x y z\n", "sum")
61+ r.push("feat")
62+ n := r.open("feat").Number
63+
64+ t.Run("full with a wider window is invalid", func(t *testing.T) {
65+ _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}})
66+ require.ErrorIs(t, err, domain.ErrInvalid)
67+ })
68+
69+ t.Run("a windowed read collapses a generated file, a nil read does not", func(t *testing.T) {
70+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 10}})
71+ require.NoError(t, err)
72+ byPath := map[string]domain.FileDiff{}
73+ for _, f := range d.Diff.Files {
74+ byPath[f.NewPath] = f
75+ }
76+ assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted)
77+ assert.Empty(t, byPath["go.sum"].Hunks)
78+ assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted)
79+ assert.NotEmpty(t, byPath["a.txt"].Hunks)
80+
81+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{})
82+ require.NoError(t, err)
83+ for _, f := range d.Diff.Files {
84+ assert.Equal(t, domain.OmitNone, f.Omitted, f.NewPath)
85+ assert.NotEmpty(t, f.Hunks, f.NewPath)
86+ }
87+ })
88+}
89+
90+func bigContent(n int) string {
91+ var b strings.Builder
92+ for i := range n {
93+ fmt.Fprintf(&b, "line %d\n", i)
94+ }
95+ return b.String()
96+}
97+
98+func TestCheckLineBeyondFileLimit(t *testing.T) {
99+ r := newMRRig(t)
100+ r.seed()
101+ r.sh("checkout", "-q", "-B", "feat", "main")
102+ r.commit(author, "big.txt", bigContent(3000), "big")
103+ r.commit(author, "go.sum", "h1:x\nh1:y\n", "sum")
104+ r.push("feat")
105+ n := r.open("feat").Number
106+
107+ add := func(path string, line int) error {
108+ _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: path, Line: line, Side: domain.SideNew, Text: "c"})
109+ return err
110+ }
111+ require.NoError(t, add("big.txt", 1))
112+ require.NoError(t, add("big.txt", 2500))
113+ require.NoError(t, add("big.txt", 3000))
114+ require.ErrorIs(t, add("big.txt", 3001), domain.ErrInvalid)
115+ // A generated file is collapsed in the web windows, but loadable: its
116+ // lines can be commented on.
117+ require.NoError(t, add("go.sum", 2))
118+}
119+
120+func TestBrowseGetCommitFullWindow(t *testing.T) {
121+ r := newMRRig(t)
122+ r.seed()
123+ r.commit(author, "a-big.txt", bigContent(3000), "big")
124+ id := r.head()
125+ r.push("main")
126+ b := usecase.NewBrowse(r.store, r.git)
127+ rev := domain.RevisionOf(id)
128+
129+ d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{})
130+ require.NoError(t, err)
131+ require.Len(t, d.Diff.Files, 1)
132+ assert.Equal(t, domain.OmitLarge, d.Diff.Files[0].Omitted)
133+ assert.Empty(t, d.Diff.Files[0].Hunks)
134+
135+ d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 1, Full: true}})
136+ require.NoError(t, err)
137+ require.Len(t, d.Diff.Files, 1)
138+ f := d.Diff.Files[0]
139+ assert.Equal(t, domain.OmitNone, f.Omitted)
140+ require.NotEmpty(t, f.Hunks)
141+ assert.Equal(t, 3000, f.Additions)
142+
143+ _, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}})
144+ require.ErrorIs(t, err, domain.ErrInvalid)
145+}
146+
147+func TestBrowseGetCommitWindowCollapsesGenerated(t *testing.T) {
148+ r := newMRRig(t)
149+ r.seed()
150+ r.commit(author, "go.sum", "h1:x\n", "sum")
151+ id := r.head()
152+ r.push("main")
153+ b := usecase.NewBrowse(r.store, r.git)
154+ rev := domain.RevisionOf(id)
155+
156+ d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{})
157+ require.NoError(t, err)
158+ require.Len(t, d.Diff.Files, 1)
159+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "API reads keep generated files")
160+
161+ d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 25}})
162+ require.NoError(t, err)
163+ require.Len(t, d.Diff.Files, 1)
164+ assert.Equal(t, domain.OmitGenerated, d.Diff.Files[0].Omitted, "web windows collapse them")
165+ assert.Empty(t, d.Diff.Files[0].Hunks)
166+}

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close