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
@@ -1,7 +1,7 @@
11 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=
22 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3-bnema.dev/ermite-api v0.10.0 h1:7OzstzDvBl4KWnvwsrDOvTlUoWAIz6Fnhn0y9iN5YxQ=
4-bnema.dev/ermite-api v0.10.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
3+bnema.dev/ermite-api v0.11.0 h1:XgD8lBfI/HtnuNVvBkt+QyHu1nTItACZj53c3wY+Pvo=
4+bnema.dev/ermite-api v0.11.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
55 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=
66 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=
77 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=
@@ -1,7 +1,7 @@
1 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=1 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=
2 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=2 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3-bnema.dev/ermite-api v0.10.0 h1:7OzstzDvBl4KWnvwsrDOvTlUoWAIz6Fnhn0y9iN5YxQ=3+bnema.dev/ermite-api v0.11.0 h1:XgD8lBfI/HtnuNVvBkt+QyHu1nTItACZj53c3wY+Pvo=
4-bnema.dev/ermite-api v0.10.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=4+bnema.dev/ermite-api v0.11.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
5 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=5 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=
6 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=6 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=
7 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=7 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=
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 generated file: collapsed
M internal/adapters/web/diff_test.go +51 -2
@@ -99,7 +99,10 @@ func TestFileNotes(t *testing.T) {
9999 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
100100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
101101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
102- {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false},
102+ {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},
103+ {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true},
104+ {"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true},
105+ {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true},
103106 }
104107 for _, tt := range tests {
105108 t.Run(tt.name, func(t *testing.T) {
@@ -120,7 +123,7 @@ func TestDiffFilesPaths(t *testing.T) {
120123 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
121124 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
122125 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
123- {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000},
126+ {Status: domain.FileModified, NewPath: "big.go", Omitted: domain.OmitLarge, Additions: 3000},
124127 })
125128 assert.Equal(t, "a.go", fs[0].Path)
126129 assert.True(t, fs[0].Open)
@@ -191,3 +194,49 @@ func TestContextGaps(t *testing.T) {
191194 contextGaps(o, odd, "/r/context/c")
192195 assert.Nil(t, o[0].Hunks[0].Gap)
193196 }
197+
198+func TestDiffNote(t *testing.T) {
199+ t.Parallel()
200+ files := func(w ...domain.FileOmission) domain.Diff {
201+ var d domain.Diff
202+ for _, o := range w {
203+ d.Files = append(d.Files, domain.FileDiff{Omitted: o})
204+ }
205+ return d
206+ }
207+ assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note")
208+ assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.",
209+ diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable)))
210+}
211+
212+func TestCollapsedFileMarkup(t *testing.T) {
213+ t.Parallel()
214+ view := DiffView{ShowUnified: true}
215+ src := "/demo/commit/abc1234/files"
216+ for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} {
217+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}}
218+ fs := diffFiles(raw)
219+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
220+ out := renderToString(t, DiffFileView(fs[0], view))
221+ assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why)
222+ assert.Regexp(t, `<details `, out, "the file is a details element")
223+ assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
224+ assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
225+ assert.NotContains(t, out, "<table")
226+ }
227+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}}
228+ fs := diffFiles(raw)
229+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
230+ out := renderToString(t, DiffFileView(fs[0], view))
231+ assert.Contains(t, out, "too large to show in the browser")
232+ assert.NotContains(t, out, "load diff")
233+ assert.NotContains(t, out, "data-manual")
234+ assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`)
235+
236+ // Past the first window of a merge request the link keeps the window.
237+ raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}}
238+ fs = diffFiles(raw)
239+ collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25})
240+ assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src)
241+ assert.Equal(t, "?from=25&file=0#f1", fs[0].Href)
242+}
@@ -99,7 +99,10 @@ func TestFileNotes(t *testing.T) {
99 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},99 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
102- {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false},102+ {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},
103+ {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true},
104+ {"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true},
105+ {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true},
103 }106 }
104 for _, tt := range tests {107 for _, tt := range tests {
105 t.Run(tt.name, func(t *testing.T) {108 t.Run(tt.name, func(t *testing.T) {
@@ -120,7 +123,7 @@ func TestDiffFilesPaths(t *testing.T) {
120 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},123 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
121 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},124 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
122 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},125 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
123- {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000},126+ {Status: domain.FileModified, NewPath: "big.go", Omitted: domain.OmitLarge, Additions: 3000},
124 })127 })
125 assert.Equal(t, "a.go", fs[0].Path)128 assert.Equal(t, "a.go", fs[0].Path)
126 assert.True(t, fs[0].Open)129 assert.True(t, fs[0].Open)
@@ -191,3 +194,49 @@ func TestContextGaps(t *testing.T) {
191 contextGaps(o, odd, "/r/context/c")194 contextGaps(o, odd, "/r/context/c")
192 assert.Nil(t, o[0].Hunks[0].Gap)195 assert.Nil(t, o[0].Hunks[0].Gap)
193 }196 }
197+
198+func TestDiffNote(t *testing.T) {
199+ t.Parallel()
200+ files := func(w ...domain.FileOmission) domain.Diff {
201+ var d domain.Diff
202+ for _, o := range w {
203+ d.Files = append(d.Files, domain.FileDiff{Omitted: o})
204+ }
205+ return d
206+ }
207+ assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note")
208+ assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.",
209+ diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable)))
210+}
211+
212+func TestCollapsedFileMarkup(t *testing.T) {
213+ t.Parallel()
214+ view := DiffView{ShowUnified: true}
215+ src := "/demo/commit/abc1234/files"
216+ for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} {
217+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}}
218+ fs := diffFiles(raw)
219+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
220+ out := renderToString(t, DiffFileView(fs[0], view))
221+ assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why)
222+ assert.Regexp(t, `<details `, out, "the file is a details element")
223+ assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
224+ assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
225+ assert.NotContains(t, out, "<table")
226+ }
227+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}}
228+ fs := diffFiles(raw)
229+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
230+ out := renderToString(t, DiffFileView(fs[0], view))
231+ assert.Contains(t, out, "too large to show in the browser")
232+ assert.NotContains(t, out, "load diff")
233+ assert.NotContains(t, out, "data-manual")
234+ assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`)
235+
236+ // Past the first window of a merge request the link keeps the window.
237+ raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}}
238+ fs = diffFiles(raw)
239+ collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25})
240+ assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src)
241+ assert.Equal(t, "?from=25&file=0#f1", fs[0].Href)
242+}
A internal/adapters/web/jstest/lazy.test.mjs +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