detached 55fcc8f

#14 feat(diff): collapse generated and large files with on-demand load

state
merged · 55fcc8f · fast-forward
branch
feat/diff-large-files → main
opened
bnema ·
updated

Web diffs no longer cut files arbitrarily.

  • Every changed file is listed on MR, commit and compare pages. Generated files (lockfiles, vendor/, node_modules/, minified or generated code) and files over 2,000 changed lines render collapsed, with no rows.
  • "load diff" fetches that one file in place (one request, one file read, no scroll jump). Without JS, ?file=i reloads the page with the file spliced in.
  • One-file loads are capped at 8 MiB and 50,000 changed lines; beyond, the file shows a raw link and takes no line comment. Line comments accept any line the page can load.
  • Only changed lines count against the diff line limits (context lines no longer do).
  • API/CLI: files gain omitted (large, budget, unavailable) via ermite-api v0.11.0; truncated keeps its meaning.
  • static/js/lazy.js holds the shared lazy loader for MR, commit and compare pages.

Depends on !13: will be rebased after it merges (conflicts expected in mr.go, mr.js and goldens).

Checks: go build/vet/test, node --test (170), templ -check, Chromium check of load diff, scroll, focus, Enter, no-JS and idle mutations.

CommitSubjectAuthorDateSignatureStatus
55fcc8frefactor(diff): move the commit page window and the one-file splice into the usecasesbriceGPG Verified
d00be2edocs(diff): name every cause of an unavailable file in the APIbriceGPG Verified
88a1a56refactor(web): share the fragment layout rule and document the API omission reasonsbriceGPG Verified
de918e2docs(diff): say that files over the one-file cap take no line commentbriceGPG Verified
4af5505fix(diff): cap one-file loads at 50,000 changed lines and harden the web splicebriceGPG Verified
eb480cbfix(web): keep collapsed files closed in loaded windows and ignore held keysbriceGPG Verified
81bd47ffeat(web): collapse generated and large diff files with on-demand loadbriceGPG Verified
1b58f08test(diff): comment on a generated file and collapse it in commit windowsbriceGPG Verified
2a79463feat(diff): share one window policy for MR, commit and comment readsbriceGPG Verified
b1b81dffix(diff): omit a file over the byte budget of earlier files as budgetbriceGPG Verified
0e23da4feat(diff): tell why a diff file has no hunksbriceGPG Verified
7fe3b18build(deps): use ermite-api v0.11.0 for the file omission reasonbriceGPG Verified
e9b7a51test(diff): drop the duplicate changed-line limit testbriceGPG Verified
03ad5a8fix(diff): count only changed lines against the diff line limitsbriceGPG Verified
1e5691efeat(domain): classify generated and vendored files by pathbriceGPG Verified

Checks 8

61 files · +2371 -675
8/8
M docs/merge-requests.md +8 -0
@@ -63,6 +63,14 @@ In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `c
6363
6464 In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.
6565
66+## Large diffs on the web
67+
68+The merge request, commit and compare pages list every changed file. Generated files (lockfiles, `vendor/`, `node_modules/`, minified or generated code) and large files (over 2,000 changed lines) are collapsed: the header and the counts show, the lines do not. "load diff" fetches that one file in place, in full, and you can comment on its lines. Without scripts the link reloads the page with that file loaded. After the first 25 files of a merge request, the other files load as you scroll.
69+
70+A commit page reads the hunks of its first 300 files with the page; the other files are listed collapsed and load one at a time. A file that cannot be shown in the browser, over 8 MiB of text or over 50,000 changed lines when loaded alone, has no "load diff": the page links to its raw blob instead, and its lines take no comment.
71+
72+The API and `mr show --diff --json` keep their limits (300 files, 2,000 changed lines per file, 20,000 in total, 8 MiB). A file read without its hunks has `truncated` set and `omitted` giving the reason: `large`, `budget` (the earlier files used the limits) or `unavailable` (over 8 MiB of text, or a line over 64 KiB, even alone). Files past the 300th are not listed and `diff_truncated` is set.
73+
6674 ## Notes
6775
6876 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
@@ -63,6 +63,14 @@ In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `c
63 63
64 In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.64 In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.
65 65
66+## Large diffs on the web
67+
68+The merge request, commit and compare pages list every changed file. Generated files (lockfiles, `vendor/`, `node_modules/`, minified or generated code) and large files (over 2,000 changed lines) are collapsed: the header and the counts show, the lines do not. "load diff" fetches that one file in place, in full, and you can comment on its lines. Without scripts the link reloads the page with that file loaded. After the first 25 files of a merge request, the other files load as you scroll.
69+
70+A commit page reads the hunks of its first 300 files with the page; the other files are listed collapsed and load one at a time. A file that cannot be shown in the browser, over 8 MiB of text or over 50,000 changed lines when loaded alone, has no "load diff": the page links to its raw blob instead, and its lines take no comment.
71+
72+The API and `mr show --diff --json` keep their limits (300 files, 2,000 changed lines per file, 20,000 in total, 8 MiB). A file read without its hunks has `truncated` set and `omitted` giving the reason: `large`, `budget` (the earlier files used the limits) or `unavailable` (over 8 MiB of text, or a line over 64 KiB, even alone). Files past the 300th are not listed and `diff_truncated` is set.
73+
66 ## Notes74 ## Notes
67 75
68 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.76 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.10.0
16+ bnema.dev/ermite-api v0.11.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.10.016+ bnema.dev/ermite-api v0.11.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/gitcli/diff.go +38 -30
@@ -88,8 +88,6 @@ type rawEntry struct {
8888 }
8989
9090 // readDiff parses the output of diff-tree --raw --numstat -p.
91-//
92-//hexcheck:ignore suspicious-business-logic-in-adapter streams raw, numstat and patch sections; domain.DiffBudget decides window and hunk eligibility and accounts retained lines and bytes; adapter only skips sections and stops the process
9391 func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {
9492 entries, err := readRaw(r)
9593 if err != nil {
@@ -115,7 +113,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
115113 // rename, mode change) have a section without hunks.
116114 lastRead := -1
117115 for i := from; i < to; i++ {
118- if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) {
116+ if read, _ := budget.Admit(entries[i].file.Binary, entries[i].file.CommentPath(), entries[i].added+entries[i].del); read {
119117 lastRead = i
120118 }
121119 }
@@ -134,19 +132,24 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
134132 sections = 2
135133 }
136134 inWindow := i >= from && i < to
135+ read, why := false, domain.OmitNone
136+ if inWindow {
137+ read, why = pr.budget.Admit(e.file.Binary, e.file.CommentPath(), e.added+e.del)
138+ }
137139 switch {
138- case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del):
140+ case !inWindow:
139141 // Nothing to show.
140- case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del):
141- e.file.Truncated = true
142+ case !read:
143+ e.file.Omitted = why
142144 default:
143145 before := pr.budget
144- hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines())
146+ pr.budget.StartFile()
147+ hunks, _, why, err := pr.file(sections, pr.budget.FileLines())
145148 if err != nil {
146149 return domain.Diff{}, domain.DiffStats{}, err
147150 }
148- e.file.Hunks, e.file.Truncated = hunks, truncated
149- if truncated {
151+ e.file.Hunks, e.file.Omitted = hunks, why
152+ if why != domain.OmitNone {
150153 pr.budget = before // nothing of this file is kept
151154 }
152155 sections = 0
@@ -156,7 +159,6 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
156159 return domain.Diff{}, domain.DiffStats{}, err
157160 }
158161 }
159- diff.Truncated = diff.Truncated || e.file.Truncated
160162 diff.Files = append(diff.Files, e.file)
161163 }
162164 if lastRead < len(entries)-1 {
@@ -379,55 +381,56 @@ func (p *patchReader) skip(n int) error {
379381 }
380382
381383 // file reads the sections of one file and returns its hunks, the number of
382-// hunk lines, and whether it exceeded max lines or had a line over
383-// maxDiffLine. When truncated, the rest of the file is skipped.
384-func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {
384+// changed lines, and why it was omitted: it exceeded max changed lines or the
385+// budget, or had a line over maxDiffLine. When omitted, the rest of the file is
386+// skipped.
387+func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
385388 for done := range sections {
386389 ok, err := p.enterSection()
387390 if err != nil {
388- return nil, 0, false, err
391+ return nil, 0, domain.OmitNone, err
389392 }
390393 if !ok {
391394 break
392395 }
393- h, n, trunc, err := p.section(max - lines)
396+ h, n, why, err := p.section(max - lines)
394397 if err != nil {
395- return nil, 0, false, err
398+ return nil, 0, domain.OmitNone, err
396399 }
397400 hunks = append(hunks, h...)
398401 lines += n
399- if trunc {
402+ if why != domain.OmitNone {
400403 // Leave the stream at the start of the next file.
401- return nil, lines, true, p.skip(sections - done - 1)
404+ return nil, lines, why, p.skip(sections - done - 1)
402405 }
403406 }
404- return hunks, lines, false, nil
407+ return hunks, lines, domain.OmitNone, nil
405408 }
406409
407410 // section reads the hunks of the current section up to the next marker, which
408-// stays pending. It gives up (truncated) after more than max lines.
409-func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {
411+// stays pending. It gives up (with a reason) after more than max changed lines.
412+func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
410413 var cur *domain.Hunk
411414 var oldN, newN int
412415 for {
413416 line, ok, err := p.line()
414417 if err != nil {
415- return nil, 0, false, err
418+ return nil, 0, domain.OmitNone, err
416419 }
417420 if !ok {
418- return hunks, lines, false, nil
421+ return hunks, lines, domain.OmitNone, nil
419422 }
420423 if bytes.HasPrefix(line, []byte(sectionMarker)) {
421424 p.unread(line)
422- return hunks, lines, false, nil
425+ return hunks, lines, domain.OmitNone, nil
423426 }
424427 if p.cut {
425- return nil, lines, true, nil
428+ return nil, lines, domain.OmitUnavailable, nil
426429 }
427430 if bytes.HasPrefix(line, []byte("@@ ")) {
428431 h, err := parseHunkHeader(string(line))
429432 if err != nil {
430- return nil, 0, false, err
433+ return nil, 0, domain.OmitNone, err
431434 }
432435 hunks = append(hunks, h)
433436 cur = &hunks[len(hunks)-1]
@@ -459,10 +462,15 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
459462 default:
460463 continue
461464 }
462- lines++
463- fits := p.budget.ChargeLine(len(text))
464- if lines > max || !fits {
465- return nil, lines, true, nil
465+ changed := l.Kind == domain.LineAddition || l.Kind == domain.LineDeletion
466+ if changed {
467+ lines++
468+ }
469+ if why := p.budget.ChargeLine(len(text), changed); why != domain.OmitNone {
470+ return nil, lines, why, nil
471+ }
472+ if lines > max {
473+ return nil, lines, domain.OmitLarge, nil
466474 }
467475 cur.Lines = append(cur.Lines, l)
468476 }
@@ -88,8 +88,6 @@ type rawEntry struct {
88 }88 }
89 89
90 // readDiff parses the output of diff-tree --raw --numstat -p.90 // readDiff parses the output of diff-tree --raw --numstat -p.
91-//
92-//hexcheck:ignore suspicious-business-logic-in-adapter streams raw, numstat and patch sections; domain.DiffBudget decides window and hunk eligibility and accounts retained lines and bytes; adapter only skips sections and stops the process
93 func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {91 func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {
94 entries, err := readRaw(r)92 entries, err := readRaw(r)
95 if err != nil {93 if err != nil {
@@ -115,7 +113,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
115 // rename, mode change) have a section without hunks.113 // rename, mode change) have a section without hunks.
116 lastRead := -1114 lastRead := -1
117 for i := from; i < to; i++ {115 for i := from; i < to; i++ {
118- if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) {116+ if read, _ := budget.Admit(entries[i].file.Binary, entries[i].file.CommentPath(), entries[i].added+entries[i].del); read {
119 lastRead = i117 lastRead = i
120 }118 }
121 }119 }
@@ -134,19 +132,24 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
134 sections = 2132 sections = 2
135 }133 }
136 inWindow := i >= from && i < to134 inWindow := i >= from && i < to
135+ read, why := false, domain.OmitNone
136+ if inWindow {
137+ read, why = pr.budget.Admit(e.file.Binary, e.file.CommentPath(), e.added+e.del)
138+ }
137 switch {139 switch {
138- case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del):140+ case !inWindow:
139 // Nothing to show.141 // Nothing to show.
140- case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del):142+ case !read:
141- e.file.Truncated = true143+ e.file.Omitted = why
142 default:144 default:
143 before := pr.budget145 before := pr.budget
144- hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines())146+ pr.budget.StartFile()
147+ hunks, _, why, err := pr.file(sections, pr.budget.FileLines())
145 if err != nil {148 if err != nil {
146 return domain.Diff{}, domain.DiffStats{}, err149 return domain.Diff{}, domain.DiffStats{}, err
147 }150 }
148- e.file.Hunks, e.file.Truncated = hunks, truncated151+ e.file.Hunks, e.file.Omitted = hunks, why
149- if truncated {152+ if why != domain.OmitNone {
150 pr.budget = before // nothing of this file is kept153 pr.budget = before // nothing of this file is kept
151 }154 }
152 sections = 0155 sections = 0
@@ -156,7 +159,6 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
156 return domain.Diff{}, domain.DiffStats{}, err159 return domain.Diff{}, domain.DiffStats{}, err
157 }160 }
158 }161 }
159- diff.Truncated = diff.Truncated || e.file.Truncated
160 diff.Files = append(diff.Files, e.file)162 diff.Files = append(diff.Files, e.file)
161 }163 }
162 if lastRead < len(entries)-1 {164 if lastRead < len(entries)-1 {
@@ -379,55 +381,56 @@ func (p *patchReader) skip(n int) error {
379 }381 }
380 382
381 // file reads the sections of one file and returns its hunks, the number of383 // file reads the sections of one file and returns its hunks, the number of
382-// hunk lines, and whether it exceeded max lines or had a line over384+// changed lines, and why it was omitted: it exceeded max changed lines or the
383-// maxDiffLine. When truncated, the rest of the file is skipped.385+// budget, or had a line over maxDiffLine. When omitted, the rest of the file is
384-func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {386+// skipped.
387+func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
385 for done := range sections {388 for done := range sections {
386 ok, err := p.enterSection()389 ok, err := p.enterSection()
387 if err != nil {390 if err != nil {
388- return nil, 0, false, err391+ return nil, 0, domain.OmitNone, err
389 }392 }
390 if !ok {393 if !ok {
391 break394 break
392 }395 }
393- h, n, trunc, err := p.section(max - lines)396+ h, n, why, err := p.section(max - lines)
394 if err != nil {397 if err != nil {
395- return nil, 0, false, err398+ return nil, 0, domain.OmitNone, err
396 }399 }
397 hunks = append(hunks, h...)400 hunks = append(hunks, h...)
398 lines += n401 lines += n
399- if trunc {402+ if why != domain.OmitNone {
400 // Leave the stream at the start of the next file.403 // Leave the stream at the start of the next file.
401- return nil, lines, true, p.skip(sections - done - 1)404+ return nil, lines, why, p.skip(sections - done - 1)
402 }405 }
403 }406 }
404- return hunks, lines, false, nil407+ return hunks, lines, domain.OmitNone, nil
405 }408 }
406 409
407 // section reads the hunks of the current section up to the next marker, which410 // section reads the hunks of the current section up to the next marker, which
408-// stays pending. It gives up (truncated) after more than max lines.411+// stays pending. It gives up (with a reason) after more than max changed lines.
409-func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {412+func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) {
410 var cur *domain.Hunk413 var cur *domain.Hunk
411 var oldN, newN int414 var oldN, newN int
412 for {415 for {
413 line, ok, err := p.line()416 line, ok, err := p.line()
414 if err != nil {417 if err != nil {
415- return nil, 0, false, err418+ return nil, 0, domain.OmitNone, err
416 }419 }
417 if !ok {420 if !ok {
418- return hunks, lines, false, nil421+ return hunks, lines, domain.OmitNone, nil
419 }422 }
420 if bytes.HasPrefix(line, []byte(sectionMarker)) {423 if bytes.HasPrefix(line, []byte(sectionMarker)) {
421 p.unread(line)424 p.unread(line)
422- return hunks, lines, false, nil425+ return hunks, lines, domain.OmitNone, nil
423 }426 }
424 if p.cut {427 if p.cut {
425- return nil, lines, true, nil428+ return nil, lines, domain.OmitUnavailable, nil
426 }429 }
427 if bytes.HasPrefix(line, []byte("@@ ")) {430 if bytes.HasPrefix(line, []byte("@@ ")) {
428 h, err := parseHunkHeader(string(line))431 h, err := parseHunkHeader(string(line))
429 if err != nil {432 if err != nil {
430- return nil, 0, false, err433+ return nil, 0, domain.OmitNone, err
431 }434 }
432 hunks = append(hunks, h)435 hunks = append(hunks, h)
433 cur = &hunks[len(hunks)-1]436 cur = &hunks[len(hunks)-1]
@@ -459,10 +462,15 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
459 default:462 default:
460 continue463 continue
461 }464 }
462- lines++465+ changed := l.Kind == domain.LineAddition || l.Kind == domain.LineDeletion
463- fits := p.budget.ChargeLine(len(text))466+ if changed {
464- if lines > max || !fits {467+ lines++
465- return nil, lines, true, nil468+ }
469+ if why := p.budget.ChargeLine(len(text), changed); why != domain.OmitNone {
470+ return nil, lines, why, nil
471+ }
472+ if lines > max {
473+ return nil, lines, domain.OmitLarge, nil
466 }474 }
467 cur.Lines = append(cur.Lines, l)475 cur.Lines = append(cur.Lines, l)
468 }476 }
M internal/adapters/gitcli/diff_test.go +40 -38
@@ -177,7 +177,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
177177 assert.Equal(t, domain.FileModified, bin.Status)
178178 assert.True(t, bin.Binary)
179179 assert.Empty(t, bin.Hunks)
180- assert.False(t, bin.Truncated)
180+ assert.Equal(t, domain.OmitNone, bin.Omitted)
181181
182182 mode := files["d/f>d/f"]
183183 assert.Equal(t, domain.FileModified, mode.Status)
@@ -185,7 +185,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
185185 assert.Equal(t, uint32(0o100755), mode.NewMode)
186186 assert.Empty(t, mode.Hunks)
187187 assert.False(t, mode.Binary)
188- assert.False(t, mode.Truncated)
188+ assert.Equal(t, domain.OmitNone, mode.Omitted)
189189
190190 del := files["README.md>"]
191191 assert.Equal(t, domain.FileDeleted, del.Status)
@@ -315,22 +315,22 @@ func TestDiffLimits(t *testing.T) {
315315 require.NoError(t, err)
316316 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")
317317
318- assert.False(t, exact.Truncated)
318+ assert.Equal(t, domain.OmitNone, exact.Omitted)
319319 require.Len(t, exact.Hunks, 1)
320320 assert.Len(t, exact.Hunks[0].Lines, 2000)
321- assert.True(t, over.Truncated)
321+ assert.NotEqual(t, domain.OmitNone, over.Omitted)
322322 assert.Empty(t, over.Hunks)
323323 assert.Equal(t, 2001, over.Additions, "counts stay accurate")
324- assert.False(t, small.Truncated)
324+ assert.Equal(t, domain.OmitNone, small.Omitted)
325325 assert.Len(t, small.Hunks, 1)
326- assert.True(t, d.Diff.Truncated)
326+ assert.False(t, d.Diff.Truncated)
327327 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)
328328 })
329329
330- t.Run("context counts towards the file limit", func(t *testing.T) {
330+ t.Run("context does not count towards the file limit", func(t *testing.T) {
331331 f := readertest.NewFixture(t, data, "limit-context")
332332 f.Lines("big", "l", 3000).Commit("v1")
333- // Every 7th line changes: ~428 changed lines, but the hunks merge
333+ // Every 7th line changes: 429 additions plus 429 deletions, but the hunks merge
334334 // into one that spans the file with its context.
335335 var b strings.Builder
336336 for i := range 3000 {
@@ -346,11 +346,13 @@ func TestDiffLimits(t *testing.T) {
346346 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
347347 require.NoError(t, err)
348348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
349- assert.True(t, big.Truncated)
350- assert.Empty(t, big.Hunks)
349+ assert.Equal(t, domain.OmitNone, big.Omitted)
350+ require.Len(t, big.Hunks, 1)
351+ assert.Len(t, big.Hunks[0].Lines, 3000+429)
352+ assert.False(t, d.Diff.Truncated)
351353 assert.Equal(t, 429, big.Additions)
352354 assert.Equal(t, 429, big.Deletions)
353- assert.False(t, tiny.Truncated, "the file after a truncated one is still read")
355+ assert.Equal(t, domain.OmitNone, tiny.Omitted, "the following file still fits the changed-line budget")
354356 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))
355357 assert.Equal(t, id, d.Commit.ID.String())
356358 })
@@ -376,13 +378,13 @@ func TestDiffLimits(t *testing.T) {
376378 }
377379 assert.LessOrEqual(t, total, 20000)
378380 for i := range 10 {
379- assert.False(t, find(d.Diff, fmt.Sprintf("f%02d", i)).Truncated)
381+ assert.Equal(t, domain.OmitNone, find(d.Diff, fmt.Sprintf("f%02d", i)).Omitted)
380382 }
381- assert.True(t, find(d.Diff, "f10").Truncated, "1,900 lines do not fit in the 1,000 left")
382- assert.False(t, find(d.Diff, "g-small").Truncated)
383- assert.True(t, find(d.Diff, "h-big").Truncated)
384- assert.False(t, find(d.Diff, "i-small").Truncated)
385- assert.True(t, d.Diff.Truncated)
383+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "f10").Omitted, "1,900 lines do not fit in the 1,000 left")
384+ assert.Equal(t, domain.OmitNone, find(d.Diff, "g-small").Omitted)
385+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "h-big").Omitted)
386+ assert.Equal(t, domain.OmitNone, find(d.Diff, "i-small").Omitted)
387+ assert.False(t, d.Diff.Truncated)
386388 assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)
387389 assert.Equal(t, 14, d.Stats.FilesChanged)
388390 })
@@ -401,7 +403,7 @@ func TestDiffLimits(t *testing.T) {
401403 assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)
402404 assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)
403405 for _, file := range d.Diff.Files {
404- assert.False(t, file.Truncated)
406+ assert.Equal(t, domain.OmitNone, file.Omitted)
405407 assert.Len(t, file.Hunks, 1)
406408 }
407409 })
@@ -424,16 +426,16 @@ func TestDiffLimits(t *testing.T) {
424426 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})
425427 require.NoError(t, err)
426428 require.Len(t, d.Diff.Files, 2)
427- assert.False(t, d.Diff.Files[0].Truncated)
428- assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left")
429+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
430+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "10 lines do not fit in the 5 left")
429431 assert.Equal(t, 3, d.Stats.FilesChanged)
430432
431433 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})
432434 require.NoError(t, err)
433435 require.Len(t, d.Diff.Files, 3)
434- assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50")
436+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "30 bytes of text fit in 50")
435437 for _, file := range d.Diff.Files[1:] {
436- assert.True(t, file.Truncated, "the byte budget has 20 bytes left for 30")
438+ assert.NotEqual(t, domain.OmitNone, file.Omitted, "the byte budget has 20 bytes left for 30")
437439 assert.Empty(t, file.Hunks)
438440 }
439441 })
@@ -444,9 +446,9 @@ func TestDiffLimits(t *testing.T) {
444446 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
445447 require.NoError(t, err)
446448 long, short := find(d.Diff, "long"), find(d.Diff, "short")
447- assert.True(t, long.Truncated)
449+ assert.NotEqual(t, domain.OmitNone, long.Omitted)
448450 assert.Empty(t, long.Hunks)
449- assert.False(t, short.Truncated)
451+ assert.Equal(t, domain.OmitNone, short.Omitted)
450452 assert.Len(t, short.Hunks, 1)
451453 })
452454
@@ -458,10 +460,10 @@ func TestDiffLimits(t *testing.T) {
458460 id := f.Commit("v2")
459461 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
460462 require.NoError(t, err)
461- assert.True(t, find(d.Diff, "a-big").Truncated)
463+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "a-big").Omitted)
462464 assert.True(t, find(d.Diff, "b-bin").Binary)
463- assert.False(t, find(d.Diff, "b-bin").Truncated)
464- assert.False(t, find(d.Diff, "c-mode").Truncated)
465+ assert.Equal(t, domain.OmitNone, find(d.Diff, "b-bin").Omitted)
466+ assert.Equal(t, domain.OmitNone, find(d.Diff, "c-mode").Omitted)
465467 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)
466468 // What is kept matches git.
467469 sections := showSections(f, id)
@@ -480,8 +482,8 @@ func TestGetCommitStopsGitEarly(t *testing.T) {
480482 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
481483 require.NoError(t, err)
482484 require.Len(t, d.Diff.Files, 2)
483- assert.False(t, d.Diff.Files[0].Truncated)
484- assert.True(t, d.Diff.Files[1].Truncated)
485+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
486+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted)
485487 assert.Equal(t, 100005, d.Stats.Additions)
486488 }
487489
@@ -580,23 +582,23 @@ func TestDiffLimitsWindow(t *testing.T) {
580582 for i, file := range d.Diff.Files {
581583 switch i {
582584 case 3:
583- assert.False(t, file.Truncated)
585+ assert.Equal(t, domain.OmitNone, file.Omitted)
584586 assert.Len(t, file.Hunks, 1, "10 lines fit in 15")
585587 case 4:
586- assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left")
588+ assert.NotEqual(t, domain.OmitNone, file.Omitted, "the second file of the window does not fit in the 5 lines left")
587589 default:
588- assert.False(t, file.Truncated)
590+ assert.Equal(t, domain.OmitNone, file.Omitted)
589591 assert.Empty(t, file.Hunks)
590592 assert.Equal(t, 10, file.Additions, "counts stay accurate")
591593 }
592594 }
593- assert.True(t, d.Diff.Truncated, "a file of the window is truncated")
595+ assert.False(t, d.Diff.Truncated, "an omitted file does not set Diff.Truncated")
594596
595597 limits.From, limits.To = 0, 0
596598 limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file
597599 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
598600 require.NoError(t, err)
599- assert.True(t, d.Diff.Files[0].Truncated)
601+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[0].Omitted)
600602 })
601603
602604 t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {
@@ -608,14 +610,14 @@ func TestDiffLimitsWindow(t *testing.T) {
608610 require.NoError(t, err)
609611 require.Len(t, d.Diff.Files, 2)
610612 assert.Len(t, d.Diff.Files[0].Hunks, 1)
611- assert.False(t, d.Diff.Files[1].Truncated)
613+ assert.Equal(t, domain.OmitNone, d.Diff.Files[1].Omitted)
612614 assert.Empty(t, d.Diff.Files[1].Hunks)
613615 assert.False(t, d.Diff.Truncated)
614616 limits.From, limits.To = 1, 2
615617 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
616618 require.NoError(t, err)
617- assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit")
618- assert.True(t, d.Diff.Truncated)
619+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "in the window, over the per-file limit")
620+ assert.False(t, d.Diff.Truncated)
619621 })
620622
621623 t.Run("MaxFiles 0 lists every file", func(t *testing.T) {
@@ -650,7 +652,7 @@ func TestDiffLimitsWindow(t *testing.T) {
650652 } else {
651653 assert.Empty(t, file.Hunks)
652654 }
653- assert.False(t, file.Truncated)
655+ assert.Equal(t, domain.OmitNone, file.Omitted)
654656 }
655657
656658 // The default limits still cut at 300.
@@ -177,7 +177,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
177 assert.Equal(t, domain.FileModified, bin.Status)177 assert.Equal(t, domain.FileModified, bin.Status)
178 assert.True(t, bin.Binary)178 assert.True(t, bin.Binary)
179 assert.Empty(t, bin.Hunks)179 assert.Empty(t, bin.Hunks)
180- assert.False(t, bin.Truncated)180+ assert.Equal(t, domain.OmitNone, bin.Omitted)
181 181
182 mode := files["d/f>d/f"]182 mode := files["d/f>d/f"]
183 assert.Equal(t, domain.FileModified, mode.Status)183 assert.Equal(t, domain.FileModified, mode.Status)
@@ -185,7 +185,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
185 assert.Equal(t, uint32(0o100755), mode.NewMode)185 assert.Equal(t, uint32(0o100755), mode.NewMode)
186 assert.Empty(t, mode.Hunks)186 assert.Empty(t, mode.Hunks)
187 assert.False(t, mode.Binary)187 assert.False(t, mode.Binary)
188- assert.False(t, mode.Truncated)188+ assert.Equal(t, domain.OmitNone, mode.Omitted)
189 189
190 del := files["README.md>"]190 del := files["README.md>"]
191 assert.Equal(t, domain.FileDeleted, del.Status)191 assert.Equal(t, domain.FileDeleted, del.Status)
@@ -315,22 +315,22 @@ func TestDiffLimits(t *testing.T) {
315 require.NoError(t, err)315 require.NoError(t, err)
316 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")316 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")
317 317
318- assert.False(t, exact.Truncated)318+ assert.Equal(t, domain.OmitNone, exact.Omitted)
319 require.Len(t, exact.Hunks, 1)319 require.Len(t, exact.Hunks, 1)
320 assert.Len(t, exact.Hunks[0].Lines, 2000)320 assert.Len(t, exact.Hunks[0].Lines, 2000)
321- assert.True(t, over.Truncated)321+ assert.NotEqual(t, domain.OmitNone, over.Omitted)
322 assert.Empty(t, over.Hunks)322 assert.Empty(t, over.Hunks)
323 assert.Equal(t, 2001, over.Additions, "counts stay accurate")323 assert.Equal(t, 2001, over.Additions, "counts stay accurate")
324- assert.False(t, small.Truncated)324+ assert.Equal(t, domain.OmitNone, small.Omitted)
325 assert.Len(t, small.Hunks, 1)325 assert.Len(t, small.Hunks, 1)
326- assert.True(t, d.Diff.Truncated)326+ assert.False(t, d.Diff.Truncated)
327 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)327 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)
328 })328 })
329 329
330- t.Run("context counts towards the file limit", func(t *testing.T) {330+ t.Run("context does not count towards the file limit", func(t *testing.T) {
331 f := readertest.NewFixture(t, data, "limit-context")331 f := readertest.NewFixture(t, data, "limit-context")
332 f.Lines("big", "l", 3000).Commit("v1")332 f.Lines("big", "l", 3000).Commit("v1")
333- // Every 7th line changes: ~428 changed lines, but the hunks merge333+ // Every 7th line changes: 429 additions plus 429 deletions, but the hunks merge
334 // into one that spans the file with its context.334 // into one that spans the file with its context.
335 var b strings.Builder335 var b strings.Builder
336 for i := range 3000 {336 for i := range 3000 {
@@ -346,11 +346,13 @@ func TestDiffLimits(t *testing.T) {
346 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)346 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
347 require.NoError(t, err)347 require.NoError(t, err)
348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
349- assert.True(t, big.Truncated)349+ assert.Equal(t, domain.OmitNone, big.Omitted)
350- assert.Empty(t, big.Hunks)350+ require.Len(t, big.Hunks, 1)
351+ assert.Len(t, big.Hunks[0].Lines, 3000+429)
352+ assert.False(t, d.Diff.Truncated)
351 assert.Equal(t, 429, big.Additions)353 assert.Equal(t, 429, big.Additions)
352 assert.Equal(t, 429, big.Deletions)354 assert.Equal(t, 429, big.Deletions)
353- assert.False(t, tiny.Truncated, "the file after a truncated one is still read")355+ assert.Equal(t, domain.OmitNone, tiny.Omitted, "the following file still fits the changed-line budget")
354 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))356 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))
355 assert.Equal(t, id, d.Commit.ID.String())357 assert.Equal(t, id, d.Commit.ID.String())
356 })358 })
@@ -376,13 +378,13 @@ func TestDiffLimits(t *testing.T) {
376 }378 }
377 assert.LessOrEqual(t, total, 20000)379 assert.LessOrEqual(t, total, 20000)
378 for i := range 10 {380 for i := range 10 {
379- assert.False(t, find(d.Diff, fmt.Sprintf("f%02d", i)).Truncated)381+ assert.Equal(t, domain.OmitNone, find(d.Diff, fmt.Sprintf("f%02d", i)).Omitted)
380 }382 }
381- assert.True(t, find(d.Diff, "f10").Truncated, "1,900 lines do not fit in the 1,000 left")383+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "f10").Omitted, "1,900 lines do not fit in the 1,000 left")
382- assert.False(t, find(d.Diff, "g-small").Truncated)384+ assert.Equal(t, domain.OmitNone, find(d.Diff, "g-small").Omitted)
383- assert.True(t, find(d.Diff, "h-big").Truncated)385+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "h-big").Omitted)
384- assert.False(t, find(d.Diff, "i-small").Truncated)386+ assert.Equal(t, domain.OmitNone, find(d.Diff, "i-small").Omitted)
385- assert.True(t, d.Diff.Truncated)387+ assert.False(t, d.Diff.Truncated)
386 assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)388 assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions)
387 assert.Equal(t, 14, d.Stats.FilesChanged)389 assert.Equal(t, 14, d.Stats.FilesChanged)
388 })390 })
@@ -401,7 +403,7 @@ func TestDiffLimits(t *testing.T) {
401 assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)403 assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath)
402 assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)404 assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath)
403 for _, file := range d.Diff.Files {405 for _, file := range d.Diff.Files {
404- assert.False(t, file.Truncated)406+ assert.Equal(t, domain.OmitNone, file.Omitted)
405 assert.Len(t, file.Hunks, 1)407 assert.Len(t, file.Hunks, 1)
406 }408 }
407 })409 })
@@ -424,16 +426,16 @@ func TestDiffLimits(t *testing.T) {
424 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})426 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})
425 require.NoError(t, err)427 require.NoError(t, err)
426 require.Len(t, d.Diff.Files, 2)428 require.Len(t, d.Diff.Files, 2)
427- assert.False(t, d.Diff.Files[0].Truncated)429+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
428- assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left")430+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "10 lines do not fit in the 5 left")
429 assert.Equal(t, 3, d.Stats.FilesChanged)431 assert.Equal(t, 3, d.Stats.FilesChanged)
430 432
431 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})433 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})
432 require.NoError(t, err)434 require.NoError(t, err)
433 require.Len(t, d.Diff.Files, 3)435 require.Len(t, d.Diff.Files, 3)
434- assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50")436+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "30 bytes of text fit in 50")
435 for _, file := range d.Diff.Files[1:] {437 for _, file := range d.Diff.Files[1:] {
436- assert.True(t, file.Truncated, "the byte budget has 20 bytes left for 30")438+ assert.NotEqual(t, domain.OmitNone, file.Omitted, "the byte budget has 20 bytes left for 30")
437 assert.Empty(t, file.Hunks)439 assert.Empty(t, file.Hunks)
438 }440 }
439 })441 })
@@ -444,9 +446,9 @@ func TestDiffLimits(t *testing.T) {
444 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)446 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
445 require.NoError(t, err)447 require.NoError(t, err)
446 long, short := find(d.Diff, "long"), find(d.Diff, "short")448 long, short := find(d.Diff, "long"), find(d.Diff, "short")
447- assert.True(t, long.Truncated)449+ assert.NotEqual(t, domain.OmitNone, long.Omitted)
448 assert.Empty(t, long.Hunks)450 assert.Empty(t, long.Hunks)
449- assert.False(t, short.Truncated)451+ assert.Equal(t, domain.OmitNone, short.Omitted)
450 assert.Len(t, short.Hunks, 1)452 assert.Len(t, short.Hunks, 1)
451 })453 })
452 454
@@ -458,10 +460,10 @@ func TestDiffLimits(t *testing.T) {
458 id := f.Commit("v2")460 id := f.Commit("v2")
459 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)461 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
460 require.NoError(t, err)462 require.NoError(t, err)
461- assert.True(t, find(d.Diff, "a-big").Truncated)463+ assert.NotEqual(t, domain.OmitNone, find(d.Diff, "a-big").Omitted)
462 assert.True(t, find(d.Diff, "b-bin").Binary)464 assert.True(t, find(d.Diff, "b-bin").Binary)
463- assert.False(t, find(d.Diff, "b-bin").Truncated)465+ assert.Equal(t, domain.OmitNone, find(d.Diff, "b-bin").Omitted)
464- assert.False(t, find(d.Diff, "c-mode").Truncated)466+ assert.Equal(t, domain.OmitNone, find(d.Diff, "c-mode").Omitted)
465 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)467 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)
466 // What is kept matches git.468 // What is kept matches git.
467 sections := showSections(f, id)469 sections := showSections(f, id)
@@ -480,8 +482,8 @@ func TestGetCommitStopsGitEarly(t *testing.T) {
480 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())482 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
481 require.NoError(t, err)483 require.NoError(t, err)
482 require.Len(t, d.Diff.Files, 2)484 require.Len(t, d.Diff.Files, 2)
483- assert.False(t, d.Diff.Files[0].Truncated)485+ assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted)
484- assert.True(t, d.Diff.Files[1].Truncated)486+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted)
485 assert.Equal(t, 100005, d.Stats.Additions)487 assert.Equal(t, 100005, d.Stats.Additions)
486 }488 }
487 489
@@ -580,23 +582,23 @@ func TestDiffLimitsWindow(t *testing.T) {
580 for i, file := range d.Diff.Files {582 for i, file := range d.Diff.Files {
581 switch i {583 switch i {
582 case 3:584 case 3:
583- assert.False(t, file.Truncated)585+ assert.Equal(t, domain.OmitNone, file.Omitted)
584 assert.Len(t, file.Hunks, 1, "10 lines fit in 15")586 assert.Len(t, file.Hunks, 1, "10 lines fit in 15")
585 case 4:587 case 4:
586- assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left")588+ assert.NotEqual(t, domain.OmitNone, file.Omitted, "the second file of the window does not fit in the 5 lines left")
587 default:589 default:
588- assert.False(t, file.Truncated)590+ assert.Equal(t, domain.OmitNone, file.Omitted)
589 assert.Empty(t, file.Hunks)591 assert.Empty(t, file.Hunks)
590 assert.Equal(t, 10, file.Additions, "counts stay accurate")592 assert.Equal(t, 10, file.Additions, "counts stay accurate")
591 }593 }
592 }594 }
593- assert.True(t, d.Diff.Truncated, "a file of the window is truncated")595+ assert.False(t, d.Diff.Truncated, "an omitted file does not set Diff.Truncated")
594 596
595 limits.From, limits.To = 0, 0597 limits.From, limits.To = 0, 0
596 limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file598 limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file
597 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)599 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
598 require.NoError(t, err)600 require.NoError(t, err)
599- assert.True(t, d.Diff.Files[0].Truncated)601+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[0].Omitted)
600 })602 })
601 603
602 t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {604 t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) {
@@ -608,14 +610,14 @@ func TestDiffLimitsWindow(t *testing.T) {
608 require.NoError(t, err)610 require.NoError(t, err)
609 require.Len(t, d.Diff.Files, 2)611 require.Len(t, d.Diff.Files, 2)
610 assert.Len(t, d.Diff.Files[0].Hunks, 1)612 assert.Len(t, d.Diff.Files[0].Hunks, 1)
611- assert.False(t, d.Diff.Files[1].Truncated)613+ assert.Equal(t, domain.OmitNone, d.Diff.Files[1].Omitted)
612 assert.Empty(t, d.Diff.Files[1].Hunks)614 assert.Empty(t, d.Diff.Files[1].Hunks)
613 assert.False(t, d.Diff.Truncated)615 assert.False(t, d.Diff.Truncated)
614 limits.From, limits.To = 1, 2616 limits.From, limits.To = 1, 2
615 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)617 d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits)
616 require.NoError(t, err)618 require.NoError(t, err)
617- assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit")619+ assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "in the window, over the per-file limit")
618- assert.True(t, d.Diff.Truncated)620+ assert.False(t, d.Diff.Truncated)
619 })621 })
620 622
621 t.Run("MaxFiles 0 lists every file", func(t *testing.T) {623 t.Run("MaxFiles 0 lists every file", func(t *testing.T) {
@@ -650,7 +652,7 @@ func TestDiffLimitsWindow(t *testing.T) {
650 } else {652 } else {
651 assert.Empty(t, file.Hunks)653 assert.Empty(t, file.Hunks)
652 }654 }
653- assert.False(t, file.Truncated)655+ assert.Equal(t, domain.OmitNone, file.Omitted)
654 }656 }
655 657
656 // The default limits still cut at 300.658 // The default limits still cut at 300.
M internal/adapters/rpc/convert.go +17 -1
@@ -394,7 +394,8 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
394394 Additions: int32(f.Additions),
395395 Deletions: int32(f.Deletions),
396396 Similarity: int32(f.Similarity),
397- Truncated: f.Truncated,
397+ Truncated: f.Omitted != domain.OmitNone,
398+ Omitted: fileOmissionToPB(f.Omitted),
398399 }
399400 for _, h := range f.Hunks {
400401 out.Hunks = append(out.Hunks, hunkToPB(h))
@@ -402,6 +403,20 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
402403 return out
403404 }
404405
406+func fileOmissionToPB(o domain.FileOmission) pb.FileOmission {
407+ switch o {
408+ case domain.OmitGenerated:
409+ return pb.FileOmission_FILE_OMISSION_GENERATED
410+ case domain.OmitLarge:
411+ return pb.FileOmission_FILE_OMISSION_LARGE
412+ case domain.OmitBudget:
413+ return pb.FileOmission_FILE_OMISSION_BUDGET
414+ case domain.OmitUnavailable:
415+ return pb.FileOmission_FILE_OMISSION_UNAVAILABLE
416+ }
417+ return pb.FileOmission_FILE_OMISSION_UNSPECIFIED
418+}
419+
405420 func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
406421 return &pb.DiffStats{
407422 FilesChanged: int32(s.FilesChanged),
@@ -413,6 +428,7 @@ func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
413428 func diffToPB(d domain.Diff) *pb.Diff {
414429 out := &pb.Diff{Truncated: d.Truncated}
415430 for _, f := range d.Files {
431+ out.Truncated = out.Truncated || f.Omitted != domain.OmitNone
416432 out.Files = append(out.Files, fileDiffToPB(f))
417433 }
418434 return out
@@ -394,7 +394,8 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
394 Additions: int32(f.Additions),394 Additions: int32(f.Additions),
395 Deletions: int32(f.Deletions),395 Deletions: int32(f.Deletions),
396 Similarity: int32(f.Similarity),396 Similarity: int32(f.Similarity),
397- Truncated: f.Truncated,397+ Truncated: f.Omitted != domain.OmitNone,
398+ Omitted: fileOmissionToPB(f.Omitted),
398 }399 }
399 for _, h := range f.Hunks {400 for _, h := range f.Hunks {
400 out.Hunks = append(out.Hunks, hunkToPB(h))401 out.Hunks = append(out.Hunks, hunkToPB(h))
@@ -402,6 +403,20 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
402 return out403 return out
403 }404 }
404 405
406+func fileOmissionToPB(o domain.FileOmission) pb.FileOmission {
407+ switch o {
408+ case domain.OmitGenerated:
409+ return pb.FileOmission_FILE_OMISSION_GENERATED
410+ case domain.OmitLarge:
411+ return pb.FileOmission_FILE_OMISSION_LARGE
412+ case domain.OmitBudget:
413+ return pb.FileOmission_FILE_OMISSION_BUDGET
414+ case domain.OmitUnavailable:
415+ return pb.FileOmission_FILE_OMISSION_UNAVAILABLE
416+ }
417+ return pb.FileOmission_FILE_OMISSION_UNSPECIFIED
418+}
419+
405 func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {420 func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
406 return &pb.DiffStats{421 return &pb.DiffStats{
407 FilesChanged: int32(s.FilesChanged),422 FilesChanged: int32(s.FilesChanged),
@@ -413,6 +428,7 @@ func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
413 func diffToPB(d domain.Diff) *pb.Diff {428 func diffToPB(d domain.Diff) *pb.Diff {
414 out := &pb.Diff{Truncated: d.Truncated}429 out := &pb.Diff{Truncated: d.Truncated}
415 for _, f := range d.Files {430 for _, f := range d.Files {
431+ out.Truncated = out.Truncated || f.Omitted != domain.OmitNone
416 out.Files = append(out.Files, fileDiffToPB(f))432 out.Files = append(out.Files, fileDiffToPB(f))
417 }433 }
418 return out434 return out
M internal/adapters/rpc/convert_test.go +18 -0
@@ -398,3 +398,21 @@ func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) {
398398 assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())
399399 }
400400 }
401+
402+func TestFileDiffOmissionMapping(t *testing.T) {
403+ for o, want := range map[domain.FileOmission]pb.FileOmission{
404+ domain.OmitNone: pb.FileOmission_FILE_OMISSION_UNSPECIFIED,
405+ domain.OmitGenerated: pb.FileOmission_FILE_OMISSION_GENERATED,
406+ domain.OmitLarge: pb.FileOmission_FILE_OMISSION_LARGE,
407+ domain.OmitBudget: pb.FileOmission_FILE_OMISSION_BUDGET,
408+ domain.OmitUnavailable: pb.FileOmission_FILE_OMISSION_UNAVAILABLE,
409+ } {
410+ f := fileDiffToPB(domain.FileDiff{NewPath: "a", Omitted: o})
411+ assert.Equal(t, want, f.GetOmitted())
412+ assert.Equal(t, o != domain.OmitNone, f.GetTruncated())
413+ d := diffToPB(domain.Diff{Files: []domain.FileDiff{{Omitted: o}}})
414+ assert.Equal(t, o != domain.OmitNone, d.GetTruncated())
415+ }
416+ assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated())
417+ assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated())
418+}
@@ -398,3 +398,21 @@ func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) {
398 assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())398 assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())
399 }399 }
400 }400 }
401+
402+func TestFileDiffOmissionMapping(t *testing.T) {
403+ for o, want := range map[domain.FileOmission]pb.FileOmission{
404+ domain.OmitNone: pb.FileOmission_FILE_OMISSION_UNSPECIFIED,
405+ domain.OmitGenerated: pb.FileOmission_FILE_OMISSION_GENERATED,
406+ domain.OmitLarge: pb.FileOmission_FILE_OMISSION_LARGE,
407+ domain.OmitBudget: pb.FileOmission_FILE_OMISSION_BUDGET,
408+ domain.OmitUnavailable: pb.FileOmission_FILE_OMISSION_UNAVAILABLE,
409+ } {
410+ f := fileDiffToPB(domain.FileDiff{NewPath: "a", Omitted: o})
411+ assert.Equal(t, want, f.GetOmitted())
412+ assert.Equal(t, o != domain.OmitNone, f.GetTruncated())
413+ d := diffToPB(domain.Diff{Files: []domain.FileDiff{{Omitted: o}}})
414+ assert.Equal(t, o != domain.OmitNone, d.GetTruncated())
415+ }
416+ assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated())
417+ assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated())
418+}
M internal/adapters/rpc/git.go +1 -1
@@ -180,7 +180,7 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC
180180 if err != nil {
181181 return nil, connectError(ctx, err)
182182 }
183- detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)
183+ detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev, ports.GetCommitOptions{})
184184 if err != nil {
185185 return nil, connectError(ctx, err)
186186 }
@@ -180,7 +180,7 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC
180 if err != nil {180 if err != nil {
181 return nil, connectError(ctx, err)181 return nil, connectError(ctx, err)
182 }182 }
183- detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)183+ detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev, ports.GetCommitOptions{})
184 if err != nil {184 if err != nil {
185 return nil, connectError(ctx, err)185 return nil, connectError(ctx, err)
186 }186 }
M internal/adapters/rpc/git_test.go +1 -1
@@ -157,7 +157,7 @@ func TestGitServiceGetCommitAndResolveRevision(t *testing.T) {
157157 t.Run("GetCommit not found", func(t *testing.T) {
158158 t.Parallel()
159159 browse := mocks.NewBrowseService(t)
160- browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
160+ browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
161161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))
162162 requireCode(t, connect.CodeNotFound, err)
163163 })
@@ -157,7 +157,7 @@ func TestGitServiceGetCommitAndResolveRevision(t *testing.T) {
157 t.Run("GetCommit not found", func(t *testing.T) {157 t.Run("GetCommit not found", func(t *testing.T) {
158 t.Parallel()158 t.Parallel()
159 browse := mocks.NewBrowseService(t)159 browse := mocks.NewBrowseService(t)
160- browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()160+ browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))
162 requireCode(t, connect.CodeNotFound, err)162 requireCode(t, connect.CodeNotFound, err)
163 })163 })
M internal/adapters/web/assets.go +2 -1
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
2424 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2525 // the content, so they can be cached for ever.
2626 type Assets struct {
27- CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string
27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, Lazy, MR, IssueForm, PowWorker, Icon string
2828 // Manifest is the web app manifest (install on a phone's home screen),
2929 // AppleIcon the home screen icon iOS reads from a link instead.
3030 Manifest, AppleIcon string
@@ -64,6 +64,7 @@ var scripts = []struct {
6464 {"files.js", func(a *Assets, u string) { a.Files = u }},
6565 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
6666 {"log.js", func(a *Assets, u string) { a.Log = u }},
67+ {"lazy.js", func(a *Assets, u string) { a.Lazy = u }},
6768 {"mr.js", func(a *Assets, u string) { a.MR = u }},
6869 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
6970 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
24 // Assets are the URLs of the assets a page loads. Their names carry a hash of24 // Assets are the URLs of the assets a page loads. Their names carry a hash of
25 // the content, so they can be cached for ever.25 // the content, so they can be cached for ever.
26 type Assets struct {26 type Assets struct {
27- CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, Lazy, MR, IssueForm, PowWorker, Icon string
28 // Manifest is the web app manifest (install on a phone's home screen),28 // Manifest is the web app manifest (install on a phone's home screen),
29 // AppleIcon the home screen icon iOS reads from a link instead.29 // AppleIcon the home screen icon iOS reads from a link instead.
30 Manifest, AppleIcon string30 Manifest, AppleIcon string
@@ -64,6 +64,7 @@ var scripts = []struct {
64 {"files.js", func(a *Assets, u string) { a.Files = u }},64 {"files.js", func(a *Assets, u string) { a.Files = u }},
65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
66 {"log.js", func(a *Assets, u string) { a.Log = u }},66 {"log.js", func(a *Assets, u string) { a.Log = u }},
67+ {"lazy.js", func(a *Assets, u string) { a.Lazy = u }},
67 {"mr.js", func(a *Assets, u string) { a.MR = u }},68 {"mr.js", func(a *Assets, u string) { a.MR = u }},
68 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},69 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
69 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},70 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
M internal/adapters/web/browse.go +2 -2
@@ -45,12 +45,12 @@ func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path st
4545 }
4646
4747 // getCommit reads a commit with its diff.
48-func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
48+func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
4949 r, err := domain.ParseRevision(rev)
5050 if err != nil {
5151 return domain.CommitDetail{}, err
5252 }
53- return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r)
53+ return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r, opts)
5454 }
5555
5656 // openBlobAt opens the file path of the commit rev. The caller closes the
@@ -45,12 +45,12 @@ func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path st
45 }45 }
46 46
47 // getCommit reads a commit with its diff.47 // getCommit reads a commit with its diff.
48-func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) {48+func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
49 r, err := domain.ParseRevision(rev)49 r, err := domain.ParseRevision(rev)
50 if err != nil {50 if err != nil {
51 return domain.CommitDetail{}, err51 return domain.CommitDetail{}, err
52 }52 }
53- return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r)53+ return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r, opts)
54 }54 }
55 55
56 // openBlobAt opens the file path of the commit rev. The caller closes the56 // openBlobAt opens the file path of the commit rev. The caller closes the
M internal/adapters/web/commit.go +57 -16
@@ -12,6 +12,7 @@ import (
1212 "github.com/a-h/templ"
1313
1414 "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
1516 )
1617
1718 // bothViewsLines is the largest diff, in lines, that is sent in both the
@@ -105,7 +106,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
105106 if err != nil {
106107 return nil, 0, err
107108 }
108- res, err := s.getCommit(ctx, d.repo.Name, sha)
109+ res, err := s.commitDiff(ctx, r, d.repo.Name, sha)
109110 if err != nil {
110111 return nil, 0, err
111112 }
@@ -128,6 +129,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
128129 Verification: c.Verification,
129130 }
130131 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
132+ collapseFiles(p.Files, res.Diff.Files, commitLinks(d.name(), cid))
131133 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
132134 p.Committer = &cm
133135 }
@@ -145,7 +147,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
145147 p.Refs = append(p.Refs, "tag: "+t.ShortName)
146148 }
147149 }
148- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
150+ p.Note = diffNote(res.Diff)
149151 p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
150152
151153 lines, invisible := diffLines(p.Files)
@@ -166,6 +168,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
166168 {Label: p.Short, Current: true},
167169 }
168170 l.HasRail = true
171+ l.Lazy = true
169172 return Commit(l, p), http.StatusOK, nil
170173 }
171174
@@ -186,23 +189,61 @@ func commitBody(c domain.Commit) string {
186189 return messageText(strings.TrimSpace(body))
187190 }
188191
189-// diffNote says what is missing from an incomplete diff, "" for a complete one.
190-func diffNote(d domain.Diff, shown, total int) string {
191- if !d.Truncated {
192+// diffNote says what is missing from an incomplete diff, "" for a complete one:
193+// files too large for the browser at all. Collapsed files say so on their own
194+// line and load on their own; every changed file is listed.
195+func diffNote(d domain.Diff) string {
196+ gone := 0
197+ for _, f := range d.Files {
198+ if f.Omitted == domain.OmitUnavailable {
199+ gone++
200+ }
201+ }
202+ if gone == 0 {
192203 return ""
193204 }
194- var parts []string
195- if shown < total {
196- parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
205+ return "this diff is incomplete: " + plural(gone, "file is", "files are") + " too large to show. the totals above count everything; clone the repository to read the rest."
206+}
207+
208+// commitDiff reads a commit for its page (and the compare page): every file
209+// listed, the hunks of the first files read, generated files and files past
210+// the window collapsed (ports.GetCommitOptions.Page). With ?file=i (the link
211+// of a page without scripts) file i is read in full and replaces its entry.
212+func (s *Server) commitDiff(ctx context.Context, r *http.Request, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
213+ return s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Page: true, Load: fileParam(r)})
214+}
215+
216+// commitLinks are the addresses a commit page loads its collapsed files from.
217+func commitLinks(repo, rev string) fileLinks {
218+ return fileLinks{src: repoHref(repo, "commit", rev, "files"), repo: repo, rev: rev}
219+}
220+
221+// commitFiles is GET /<repo>/commit/<sha>/files?one=1&from=i[&view=&layouts=]:
222+// the one file i of the commit with its hunks, in full, as the one
223+// details.dfile the page swaps for its placeholder.
224+func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
225+ sha := r.PathValue("sha")
226+ i, err := parseOne(r)
227+ if err != nil || !isCommitID(sha) {
228+ return nil, 0, errNotFound
229+ }
230+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
231+ if err != nil {
232+ return nil, 0, err
197233 }
198- cut := 0
199- for _, f := range d.Files {
200- if f.Truncated {
201- cut++
202- }
234+ res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
235+ if err != nil {
236+ return nil, 0, err
203237 }
204- if cut > 0 {
205- parts = append(parts, plural(cut, "file is", "files are")+" too large to show")
238+ if i >= len(res.Diff.Files) {
239+ return nil, 0, errNotFound
206240 }
207- return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."
241+ cid := res.Commit.ID.String()
242+ raw := res.Diff.Files[i : i+1]
243+ files := s.hl.files(ctx, raw)
244+ files[0].ID = "f" + itoa(i+1)
245+ contextGaps(files, raw, repoHref(d.name(), "context", cid))
246+ files[0].Raw = rawLink(raw[0], commitLinks(d.name(), cid))
247+ lines, _ := diffLines(files)
248+ return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil
208249 }
@@ -12,6 +12,7 @@ import (
12 "github.com/a-h/templ"12 "github.com/a-h/templ"
13 13
14 "bnema.dev/ermite/internal/domain"14 "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
15 )16 )
16 17
17 // bothViewsLines is the largest diff, in lines, that is sent in both the18 // bothViewsLines is the largest diff, in lines, that is sent in both the
@@ -105,7 +106,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
105 if err != nil {106 if err != nil {
106 return nil, 0, err107 return nil, 0, err
107 }108 }
108- res, err := s.getCommit(ctx, d.repo.Name, sha)109+ res, err := s.commitDiff(ctx, r, d.repo.Name, sha)
109 if err != nil {110 if err != nil {
110 return nil, 0, err111 return nil, 0, err
111 }112 }
@@ -128,6 +129,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
128 Verification: c.Verification,129 Verification: c.Verification,
129 }130 }
130 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))131 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
132+ collapseFiles(p.Files, res.Diff.Files, commitLinks(d.name(), cid))
131 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {133 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
132 p.Committer = &cm134 p.Committer = &cm
133 }135 }
@@ -145,7 +147,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
145 p.Refs = append(p.Refs, "tag: "+t.ShortName)147 p.Refs = append(p.Refs, "tag: "+t.ShortName)
146 }148 }
147 }149 }
148- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)150+ p.Note = diffNote(res.Diff)
149 p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))151 p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
150 152
151 lines, invisible := diffLines(p.Files)153 lines, invisible := diffLines(p.Files)
@@ -166,6 +168,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
166 {Label: p.Short, Current: true},168 {Label: p.Short, Current: true},
167 }169 }
168 l.HasRail = true170 l.HasRail = true
171+ l.Lazy = true
169 return Commit(l, p), http.StatusOK, nil172 return Commit(l, p), http.StatusOK, nil
170 }173 }
171 174
@@ -186,23 +189,61 @@ func commitBody(c domain.Commit) string {
186 return messageText(strings.TrimSpace(body))189 return messageText(strings.TrimSpace(body))
187 }190 }
188 191
189-// diffNote says what is missing from an incomplete diff, "" for a complete one.192+// diffNote says what is missing from an incomplete diff, "" for a complete one:
190-func diffNote(d domain.Diff, shown, total int) string {193+// files too large for the browser at all. Collapsed files say so on their own
191- if !d.Truncated {194+// line and load on their own; every changed file is listed.
195+func diffNote(d domain.Diff) string {
196+ gone := 0
197+ for _, f := range d.Files {
198+ if f.Omitted == domain.OmitUnavailable {
199+ gone++
200+ }
201+ }
202+ if gone == 0 {
192 return ""203 return ""
193 }204 }
194- var parts []string205+ return "this diff is incomplete: " + plural(gone, "file is", "files are") + " too large to show. the totals above count everything; clone the repository to read the rest."
195- if shown < total {206+}
196- parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")207+
208+// commitDiff reads a commit for its page (and the compare page): every file
209+// listed, the hunks of the first files read, generated files and files past
210+// the window collapsed (ports.GetCommitOptions.Page). With ?file=i (the link
211+// of a page without scripts) file i is read in full and replaces its entry.
212+func (s *Server) commitDiff(ctx context.Context, r *http.Request, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
213+ return s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Page: true, Load: fileParam(r)})
214+}
215+
216+// commitLinks are the addresses a commit page loads its collapsed files from.
217+func commitLinks(repo, rev string) fileLinks {
218+ return fileLinks{src: repoHref(repo, "commit", rev, "files"), repo: repo, rev: rev}
219+}
220+
221+// commitFiles is GET /<repo>/commit/<sha>/files?one=1&from=i[&view=&layouts=]:
222+// the one file i of the commit with its hunks, in full, as the one
223+// details.dfile the page swaps for its placeholder.
224+func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
225+ sha := r.PathValue("sha")
226+ i, err := parseOne(r)
227+ if err != nil || !isCommitID(sha) {
228+ return nil, 0, errNotFound
229+ }
230+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
231+ if err != nil {
232+ return nil, 0, err
197 }233 }
198- cut := 0234+ res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
199- for _, f := range d.Files {235+ if err != nil {
200- if f.Truncated {236+ return nil, 0, err
201- cut++
202- }
203 }237 }
204- if cut > 0 {238+ if i >= len(res.Diff.Files) {
205- parts = append(parts, plural(cut, "file is", "files are")+" too large to show")239+ return nil, 0, errNotFound
206 }240 }
207- return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."241+ cid := res.Commit.ID.String()
242+ raw := res.Diff.Files[i : i+1]
243+ files := s.hl.files(ctx, raw)
244+ files[0].ID = "f" + itoa(i+1)
245+ contextGaps(files, raw, repoHref(d.name(), "context", cid))
246+ files[0].Raw = rawLink(raw[0], commitLinks(d.name(), cid))
247+ lines, _ := diffLines(files)
248+ return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil
208 }249 }
M internal/adapters/web/compare.go +4 -2
@@ -76,15 +76,16 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
7676
7777 // The exact diff: one commit on top of the tip of the target.
7878 if exact && !behind && len(ahead) == 1 {
79- res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String())
79+ res, err := s.commitDiff(ctx, r, d.repo.Name, ahead[0].ID.String())
8080 if err != nil {
8181 return nil, 0, err
8282 }
8383 p.HasDiff = true
8484 p.Files = s.hl.files(ctx, res.Diff.Files)
8585 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
86+ collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String()))
8687 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
87- if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
88+ if n := diffNote(res.Diff); n != "" {
8889 p.Note = n
8990 }
9091 lines, invisible := diffLines(p.Files)
@@ -131,6 +132,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
131132 Crumb{Label: p.Source, Current: true},
132133 )
133134 l.HasRail = true
135+ l.Lazy = true
134136 return Compare(l, p), http.StatusOK, nil
135137 }
136138
@@ -76,15 +76,16 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
76 76
77 // The exact diff: one commit on top of the tip of the target.77 // The exact diff: one commit on top of the tip of the target.
78 if exact && !behind && len(ahead) == 1 {78 if exact && !behind && len(ahead) == 1 {
79- res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String())79+ res, err := s.commitDiff(ctx, r, d.repo.Name, ahead[0].ID.String())
80 if err != nil {80 if err != nil {
81 return nil, 0, err81 return nil, 0, err
82 }82 }
83 p.HasDiff = true83 p.HasDiff = true
84 p.Files = s.hl.files(ctx, res.Diff.Files)84 p.Files = s.hl.files(ctx, res.Diff.Files)
85 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))85 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
86+ collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String()))
86 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions87 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
87- if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {88+ if n := diffNote(res.Diff); n != "" {
88 p.Note = n89 p.Note = n
89 }90 }
90 lines, invisible := diffLines(p.Files)91 lines, invisible := diffLines(p.Files)
@@ -131,6 +132,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
131 Crumb{Label: p.Source, Current: true},132 Crumb{Label: p.Source, Current: true},
132 )133 )
133 l.HasRail = true134 l.HasRail = true
135+ l.Lazy = true
134 return Compare(l, p), http.StatusOK, nil136 return Compare(l, p), http.StatusOK, nil
135 }137 }
136 138
M internal/adapters/web/diff.go +109 -5
@@ -7,8 +7,10 @@ import (
77 "fmt"
88 "html"
99 "io"
10+ "net/http"
1011 "net/url"
1112 "slices"
13+ "strconv"
1214 "strings"
1315 "unicode/utf8"
1416
@@ -123,6 +125,14 @@ type DiffFile struct {
123125 Note string
124126 Open bool
125127 Hunks []DiffHunk
128+ // Manual is true for a collapsed file (generated, large, over the page
129+ // budget): no hunks, a placeholder with a "load diff" control that reads
130+ // that one file (Src, empty when it cannot be loaded in place; Href is the
131+ // page without scripts).
132+ Manual bool
133+ Href string
134+ // Raw is the address of the raw blob of a file too large to show.
135+ Raw string
126136 // Digits is the width of the largest line number of the file, at least 2
127137 // and at most 7: the gutter is as wide as the file needs (class lnw-N).
128138 Digits int
@@ -163,7 +173,7 @@ const diffContextLines = 3
163173 // them: an added or a deleted file shows every line already.
164174 func contextGaps(out []DiffFile, files []domain.FileDiff, base string) {
165175 for i, f := range files {
166- if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
176+ if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
167177 continue
168178 }
169179 switch f.Status {
@@ -370,8 +380,9 @@ func diffFiles(files []domain.FileDiff) []DiffFile {
370380 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
371381 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
372382 // A file that is shown in full opens; a file with nothing to read
373- // (binary, truncated) stays closed under its note.
374- Open: len(f.Hunks) > 0 && !f.Truncated,
383+ // (binary, omitted) stays closed under its note.
384+ Open: len(f.Hunks) > 0 && f.Omitted == domain.OmitNone,
385+ Manual: f.Omitted.Collapsed(),
375386 }
376387 df.Key = f.NewPath
377388 switch f.Status {
@@ -406,8 +417,14 @@ func fileNote(f domain.FileDiff) string {
406417 switch {
407418 case f.Binary:
408419 parts = append(parts, "binary file: no text diff")
409- case f.Truncated:
410- parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))
420+ case f.Omitted == domain.OmitGenerated:
421+ parts = append(parts, "generated file: collapsed")
422+ case f.Omitted == domain.OmitLarge:
423+ parts = append(parts, fmt.Sprintf("large diff (%d lines changed): collapsed", f.Additions+f.Deletions))
424+ case f.Omitted == domain.OmitBudget:
425+ parts = append(parts, "collapsed to keep the page light")
426+ case f.Omitted == domain.OmitUnavailable:
427+ parts = append(parts, "too large to show in the browser")
411428 }
412429 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
413430 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))
@@ -449,3 +466,90 @@ func codeLine(ln DiffLine) templ.Component {
449466 return err
450467 })
451468 }
469+
470+// fileLinks says where the collapsed files of a diff are loaded from.
471+type fileLinks struct {
472+ // src is the address of the fragment route of the diff ("" when files
473+ // cannot be loaded in place); one file is src plus one=1&from=i.
474+ src string
475+ // from is the window of the page, kept in the links of a page without
476+ // scripts.
477+ from int
478+ // repo and rev name the raw blob of a file that cannot be shown.
479+ repo, rev string
480+}
481+
482+// oneFileSrc is the address of the fragment holding file i alone.
483+func oneFileSrc(src string, i int) string {
484+ sep := "?"
485+ if strings.Contains(src, "?") {
486+ sep = "&"
487+ }
488+ return src + sep + "one=1&from=" + itoa(i)
489+}
490+
491+// collapseFiles gives the collapsed files of a diff their load address, and
492+// the files too large to show a link to their raw blob. Nothing is read.
493+//
494+//hexcheck:ignore suspicious-business-logic-in-adapter only maps each file's domain omission (Omitted.Collapsed) to a load link, a ?file= anchor or a raw link; it decides nothing and reads nothing
495+func collapseFiles(out []DiffFile, files []domain.FileDiff, l fileLinks) {
496+ for i, f := range files {
497+ if i >= len(out) {
498+ break
499+ }
500+ switch {
501+ case f.Omitted.Collapsed():
502+ if l.src != "" {
503+ out[i].Src = oneFileSrc(l.src, i)
504+ }
505+ q := "?file=" + itoa(i)
506+ if l.from > 0 {
507+ q = "?from=" + itoa(l.from) + "&file=" + itoa(i)
508+ }
509+ out[i].Href = q + "#" + out[i].ID
510+ default:
511+ out[i].Raw = rawLink(f, l)
512+ }
513+ }
514+}
515+
516+// rawLink is the address of the raw blob of a file too large to show, "" for
517+// any other file.
518+func rawLink(f domain.FileDiff, l fileLinks) string {
519+ if f.Omitted != domain.OmitUnavailable || f.Status == domain.FileDeleted || l.rev == "" {
520+ return ""
521+ }
522+ return repoHref(l.repo, "raw", l.rev, f.NewPath)
523+}
524+
525+// fileParam reads ?file= of a page without scripts: the index of a collapsed
526+// file to read in full; nil when absent or malformed.
527+func fileParam(r *http.Request) *int {
528+ s := r.URL.Query().Get("file")
529+ i, err := strconv.Atoi(s)
530+ if err != nil || i < 0 || strconv.Itoa(i) != s {
531+ return nil
532+ }
533+ return &i
534+}
535+
536+// fragmentView lays out a one-file fragment like the page that asks for it:
537+// ?view= is the layout shown and ?layouts= says whether the page holds both.
538+func fragmentView(r *http.Request, lines int) DiffView {
539+ v := newDiffView(r, lines, "", "")
540+ v.pageLayouts(r)
541+ return v
542+}
543+
544+// pageLayouts makes a fragment send the layouts of the page that asks for
545+// it: ?layouts=both when the page holds both and switches them itself,
546+// ?layouts=one when it holds only the shown one.
547+func (v *DiffView) pageLayouts(r *http.Request) {
548+ switch r.URL.Query().Get("layouts") {
549+ case "both":
550+ v.Both = true
551+ case "one":
552+ v.Both = false
553+ }
554+ v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
555+}
@@ -7,8 +7,10 @@ import (
7 "fmt"7 "fmt"
8 "html"8 "html"
9 "io"9 "io"
10+ "net/http"
10 "net/url"11 "net/url"
11 "slices"12 "slices"
13+ "strconv"
12 "strings"14 "strings"
13 "unicode/utf8"15 "unicode/utf8"
14 16
@@ -123,6 +125,14 @@ type DiffFile struct {
123 Note string125 Note string
124 Open bool126 Open bool
125 Hunks []DiffHunk127 Hunks []DiffHunk
128+ // Manual is true for a collapsed file (generated, large, over the page
129+ // budget): no hunks, a placeholder with a "load diff" control that reads
130+ // that one file (Src, empty when it cannot be loaded in place; Href is the
131+ // page without scripts).
132+ Manual bool
133+ Href string
134+ // Raw is the address of the raw blob of a file too large to show.
135+ Raw string
126 // Digits is the width of the largest line number of the file, at least 2136 // Digits is the width of the largest line number of the file, at least 2
127 // and at most 7: the gutter is as wide as the file needs (class lnw-N).137 // and at most 7: the gutter is as wide as the file needs (class lnw-N).
128 Digits int138 Digits int
@@ -163,7 +173,7 @@ const diffContextLines = 3
163 // them: an added or a deleted file shows every line already.173 // them: an added or a deleted file shows every line already.
164 func contextGaps(out []DiffFile, files []domain.FileDiff, base string) {174 func contextGaps(out []DiffFile, files []domain.FileDiff, base string) {
165 for i, f := range files {175 for i, f := range files {
166- if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {176+ if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
167 continue177 continue
168 }178 }
169 switch f.Status {179 switch f.Status {
@@ -370,8 +380,9 @@ func diffFiles(files []domain.FileDiff) []DiffFile {
370 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),380 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
371 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,381 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
372 // A file that is shown in full opens; a file with nothing to read382 // A file that is shown in full opens; a file with nothing to read
373- // (binary, truncated) stays closed under its note.383+ // (binary, omitted) stays closed under its note.
374- Open: len(f.Hunks) > 0 && !f.Truncated,384+ Open: len(f.Hunks) > 0 && f.Omitted == domain.OmitNone,
385+ Manual: f.Omitted.Collapsed(),
375 }386 }
376 df.Key = f.NewPath387 df.Key = f.NewPath
377 switch f.Status {388 switch f.Status {
@@ -406,8 +417,14 @@ func fileNote(f domain.FileDiff) string {
406 switch {417 switch {
407 case f.Binary:418 case f.Binary:
408 parts = append(parts, "binary file: no text diff")419 parts = append(parts, "binary file: no text diff")
409- case f.Truncated:420+ case f.Omitted == domain.OmitGenerated:
410- parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))421+ parts = append(parts, "generated file: collapsed")
422+ case f.Omitted == domain.OmitLarge:
423+ parts = append(parts, fmt.Sprintf("large diff (%d lines changed): collapsed", f.Additions+f.Deletions))
424+ case f.Omitted == domain.OmitBudget:
425+ parts = append(parts, "collapsed to keep the page light")
426+ case f.Omitted == domain.OmitUnavailable:
427+ parts = append(parts, "too large to show in the browser")
411 }428 }
412 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {429 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
413 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))430 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))
@@ -449,3 +466,90 @@ func codeLine(ln DiffLine) templ.Component {
449 return err466 return err
450 })467 })
451 }468 }
469+
470+// fileLinks says where the collapsed files of a diff are loaded from.
471+type fileLinks struct {
472+ // src is the address of the fragment route of the diff ("" when files
473+ // cannot be loaded in place); one file is src plus one=1&from=i.
474+ src string
475+ // from is the window of the page, kept in the links of a page without
476+ // scripts.
477+ from int
478+ // repo and rev name the raw blob of a file that cannot be shown.
479+ repo, rev string
480+}
481+
482+// oneFileSrc is the address of the fragment holding file i alone.
483+func oneFileSrc(src string, i int) string {
484+ sep := "?"
485+ if strings.Contains(src, "?") {
486+ sep = "&"
487+ }
488+ return src + sep + "one=1&from=" + itoa(i)
489+}
490+
491+// collapseFiles gives the collapsed files of a diff their load address, and
492+// the files too large to show a link to their raw blob. Nothing is read.
493+//
494+//hexcheck:ignore suspicious-business-logic-in-adapter only maps each file's domain omission (Omitted.Collapsed) to a load link, a ?file= anchor or a raw link; it decides nothing and reads nothing
495+func collapseFiles(out []DiffFile, files []domain.FileDiff, l fileLinks) {
496+ for i, f := range files {
497+ if i >= len(out) {
498+ break
499+ }
500+ switch {
501+ case f.Omitted.Collapsed():
502+ if l.src != "" {
503+ out[i].Src = oneFileSrc(l.src, i)
504+ }
505+ q := "?file=" + itoa(i)
506+ if l.from > 0 {
507+ q = "?from=" + itoa(l.from) + "&file=" + itoa(i)
508+ }
509+ out[i].Href = q + "#" + out[i].ID
510+ default:
511+ out[i].Raw = rawLink(f, l)
512+ }
513+ }
514+}
515+
516+// rawLink is the address of the raw blob of a file too large to show, "" for
517+// any other file.
518+func rawLink(f domain.FileDiff, l fileLinks) string {
519+ if f.Omitted != domain.OmitUnavailable || f.Status == domain.FileDeleted || l.rev == "" {
520+ return ""
521+ }
522+ return repoHref(l.repo, "raw", l.rev, f.NewPath)
523+}
524+
525+// fileParam reads ?file= of a page without scripts: the index of a collapsed
526+// file to read in full; nil when absent or malformed.
527+func fileParam(r *http.Request) *int {
528+ s := r.URL.Query().Get("file")
529+ i, err := strconv.Atoi(s)
530+ if err != nil || i < 0 || strconv.Itoa(i) != s {
531+ return nil
532+ }
533+ return &i
534+}
535+
536+// fragmentView lays out a one-file fragment like the page that asks for it:
537+// ?view= is the layout shown and ?layouts= says whether the page holds both.
538+func fragmentView(r *http.Request, lines int) DiffView {
539+ v := newDiffView(r, lines, "", "")
540+ v.pageLayouts(r)
541+ return v
542+}
543+
544+// pageLayouts makes a fragment send the layouts of the page that asks for
545+// it: ?layouts=both when the page holds both and switches them itself,
546+// ?layouts=one when it holds only the shown one.
547+func (v *DiffView) pageLayouts(r *http.Request) {
548+ switch r.URL.Query().Get("layouts") {
549+ case "both":
550+ v.Both = true
551+ case "one":
552+ v.Both = false
553+ }
554+ v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
555+}
M internal/adapters/web/diff.templ +10 -1
@@ -237,12 +237,19 @@ templ DiffFileView(f DiffFile, p DiffView) {
237237 }
238238 </summary>
239239 if f.Lazy {
240- // A placeholder: mr.js fetches the window of the file when it comes
240+ // A placeholder: lazy.js fetches the window of the file when it comes
241241 // near the screen and swaps the files of the window in. Without
242242 // scripts the link opens the page of that window.
243243 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
244244 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
245245 </div>
246+ } else if f.Manual {
247+ // A collapsed file (generated, large, over the page budget): no
248+ // rows. "load diff" reads this one file and swaps it in place
249+ // (lazy.js); without scripts the link reloads the page with the file.
250+ <div class="dlazy" data-src={ f.Src } data-manual>
251+ <a href={ templ.SafeURL(f.Href) }>load diff</a>
252+ </div>
246253 } else if len(f.Hunks) > 0 {
247254 <div class="dscroll">
248255 if p.ShowUnified {
@@ -306,6 +313,8 @@ templ DiffFileView(f DiffFile, p DiffView) {
306313 </table>
307314 }
308315 </div>
316+ } else if f.Raw != "" {
317+ <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p>
309318 } else {
310319 <p class="note pad">
311320 if f.Note != "" {
@@ -237,12 +237,19 @@ templ DiffFileView(f DiffFile, p DiffView) {
237 }237 }
238 </summary>238 </summary>
239 if f.Lazy {239 if f.Lazy {
240- // A placeholder: mr.js fetches the window of the file when it comes240+ // A placeholder: lazy.js fetches the window of the file when it comes
241 // near the screen and swaps the files of the window in. Without241 // near the screen and swaps the files of the window in. Without
242 // scripts the link opens the page of that window.242 // scripts the link opens the page of that window.
243 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>243 <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }>
244 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>244 <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a>
245 </div>245 </div>
246+ } else if f.Manual {
247+ // A collapsed file (generated, large, over the page budget): no
248+ // rows. "load diff" reads this one file and swaps it in place
249+ // (lazy.js); without scripts the link reloads the page with the file.
250+ <div class="dlazy" data-src={ f.Src } data-manual>
251+ <a href={ templ.SafeURL(f.Href) }>load diff</a>
252+ </div>
246 } else if len(f.Hunks) > 0 {253 } else if len(f.Hunks) > 0 {
247 <div class="dscroll">254 <div class="dscroll">
248 if p.ShowUnified {255 if p.ShowUnified {
@@ -306,6 +313,8 @@ templ DiffFileView(f DiffFile, p DiffView) {
306 </table>313 </table>
307 }314 }
308 </div>315 </div>
316+ } else if f.Raw != "" {
317+ <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p>
309 } else {318 } else {
310 <p class="note pad">319 <p class="note pad">
311 if f.Note != "" {320 if f.Note != "" {
M internal/adapters/web/diff_highlight.go +2 -2
@@ -48,7 +48,7 @@ var maxDiffHLTotalBytes = 256 << 10
4848
4949 const (
5050 // maxDiffHLFileBytes is the most text of one file of a diff that is
51- // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 lines a
51+ // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 changed lines a
5252 // file; this keeps a file of very long lines out.
5353 maxDiffHLFileBytes = 128 << 10
5454 // maxDiffHLLine is the longest line that is lexed, in bytes: a minified
@@ -446,7 +446,7 @@ func (c *hlCache) files(ctx context.Context, files []domain.FileDiff) []DiffFile
446446
447447 left := maxDiffHLTotalBytes
448448 for i, f := range files {
449- if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
449+ if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
450450 continue
451451 }
452452 if ctx.Err() != nil {
@@ -48,7 +48,7 @@ var maxDiffHLTotalBytes = 256 << 10
48 48
49 const (49 const (
50 // maxDiffHLFileBytes is the most text of one file of a diff that is50 // maxDiffHLFileBytes is the most text of one file of a diff that is
51- // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 lines a51+ // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 changed lines a
52 // file; this keeps a file of very long lines out.52 // file; this keeps a file of very long lines out.
53 maxDiffHLFileBytes = 128 << 1053 maxDiffHLFileBytes = 128 << 10
54 // maxDiffHLLine is the longest line that is lexed, in bytes: a minified54 // maxDiffHLLine is the longest line that is lexed, in bytes: a minified
@@ -446,7 +446,7 @@ func (c *hlCache) files(ctx context.Context, files []domain.FileDiff) []DiffFile
446 446
447 left := maxDiffHLTotalBytes447 left := maxDiffHLTotalBytes
448 for i, f := range files {448 for i, f := range files {
449- if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {449+ if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
450 continue450 continue
451 }451 }
452 if ctx.Err() != nil {452 if ctx.Err() != nil {
M internal/adapters/web/diff_highlight_test.go +2 -2
@@ -162,7 +162,7 @@ func TestHighlightSkips(t *testing.T) {
162162 binary := hunkOf("a.go", "+package a")
163163 binary.Binary = true
164164 truncated := hunkOf("a.go", "+package a")
165- truncated.Truncated = true
165+ truncated.Omitted = domain.OmitLarge
166166 for name, fd := range map[string]domain.FileDiff{
167167 "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"),
168168 "plain text": hunkOf("notes.txt", "+package a"),
@@ -273,7 +273,7 @@ func goSource(n int) []string {
273273 }
274274
275275 // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place
276-// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a
276+// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 changed lines a
277277 // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.
278278 func largeDiff(n int) []domain.FileDiff {
279279 var files []domain.FileDiff
@@ -162,7 +162,7 @@ func TestHighlightSkips(t *testing.T) {
162 binary := hunkOf("a.go", "+package a")162 binary := hunkOf("a.go", "+package a")
163 binary.Binary = true163 binary.Binary = true
164 truncated := hunkOf("a.go", "+package a")164 truncated := hunkOf("a.go", "+package a")
165- truncated.Truncated = true165+ truncated.Omitted = domain.OmitLarge
166 for name, fd := range map[string]domain.FileDiff{166 for name, fd := range map[string]domain.FileDiff{
167 "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"),167 "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"),
168 "plain text": hunkOf("notes.txt", "+package a"),168 "plain text": hunkOf("notes.txt", "+package a"),
@@ -273,7 +273,7 @@ func goSource(n int) []string {
273 }273 }
274 274
275 // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place275 // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place
276-// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a276+// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 changed lines a
277 // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.277 // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.
278 func largeDiff(n int) []domain.FileDiff {278 func largeDiff(n int) []domain.FileDiff {
279 var files []domain.FileDiff279 var files []domain.FileDiff
M internal/adapters/web/diff_templ.go +235 -173 generated file: collapsed
M internal/adapters/web/diff_test.go +51 -2
@@ -99,7 +99,10 @@ func TestFileNotes(t *testing.T) {
9999 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
100100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
101101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
102- {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false},
102+ {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},
103+ {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true},
104+ {"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true},
105+ {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true},
103106 }
104107 for _, tt := range tests {
105108 t.Run(tt.name, func(t *testing.T) {
@@ -120,7 +123,7 @@ func TestDiffFilesPaths(t *testing.T) {
120123 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
121124 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
122125 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
123- {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000},
126+ {Status: domain.FileModified, NewPath: "big.go", Omitted: domain.OmitLarge, Additions: 3000},
124127 })
125128 assert.Equal(t, "a.go", fs[0].Path)
126129 assert.True(t, fs[0].Open)
@@ -191,3 +194,49 @@ func TestContextGaps(t *testing.T) {
191194 contextGaps(o, odd, "/r/context/c")
192195 assert.Nil(t, o[0].Hunks[0].Gap)
193196 }
197+
198+func TestDiffNote(t *testing.T) {
199+ t.Parallel()
200+ files := func(w ...domain.FileOmission) domain.Diff {
201+ var d domain.Diff
202+ for _, o := range w {
203+ d.Files = append(d.Files, domain.FileDiff{Omitted: o})
204+ }
205+ return d
206+ }
207+ assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note")
208+ assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.",
209+ diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable)))
210+}
211+
212+func TestCollapsedFileMarkup(t *testing.T) {
213+ t.Parallel()
214+ view := DiffView{ShowUnified: true}
215+ src := "/demo/commit/abc1234/files"
216+ for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} {
217+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}}
218+ fs := diffFiles(raw)
219+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
220+ out := renderToString(t, DiffFileView(fs[0], view))
221+ assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why)
222+ assert.Regexp(t, `<details `, out, "the file is a details element")
223+ assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
224+ assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
225+ assert.NotContains(t, out, "<table")
226+ }
227+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}}
228+ fs := diffFiles(raw)
229+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
230+ out := renderToString(t, DiffFileView(fs[0], view))
231+ assert.Contains(t, out, "too large to show in the browser")
232+ assert.NotContains(t, out, "load diff")
233+ assert.NotContains(t, out, "data-manual")
234+ assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`)
235+
236+ // Past the first window of a merge request the link keeps the window.
237+ raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}}
238+ fs = diffFiles(raw)
239+ collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25})
240+ assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src)
241+ assert.Equal(t, "?from=25&file=0#f1", fs[0].Href)
242+}
@@ -99,7 +99,10 @@ func TestFileNotes(t *testing.T) {
99 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},99 {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true},
100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},100 {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true},
101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},101 {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true},
102- {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false},102+ {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},
103+ {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true},
104+ {"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true},
105+ {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true},
103 }106 }
104 for _, tt := range tests {107 for _, tt := range tests {
105 t.Run(tt.name, func(t *testing.T) {108 t.Run(tt.name, func(t *testing.T) {
@@ -120,7 +123,7 @@ func TestDiffFilesPaths(t *testing.T) {
120 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},123 {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}},
121 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},124 {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1},
122 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},125 {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"},
123- {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000},126+ {Status: domain.FileModified, NewPath: "big.go", Omitted: domain.OmitLarge, Additions: 3000},
124 })127 })
125 assert.Equal(t, "a.go", fs[0].Path)128 assert.Equal(t, "a.go", fs[0].Path)
126 assert.True(t, fs[0].Open)129 assert.True(t, fs[0].Open)
@@ -191,3 +194,49 @@ func TestContextGaps(t *testing.T) {
191 contextGaps(o, odd, "/r/context/c")194 contextGaps(o, odd, "/r/context/c")
192 assert.Nil(t, o[0].Hunks[0].Gap)195 assert.Nil(t, o[0].Hunks[0].Gap)
193 }196 }
197+
198+func TestDiffNote(t *testing.T) {
199+ t.Parallel()
200+ files := func(w ...domain.FileOmission) domain.Diff {
201+ var d domain.Diff
202+ for _, o := range w {
203+ d.Files = append(d.Files, domain.FileDiff{Omitted: o})
204+ }
205+ return d
206+ }
207+ assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note")
208+ assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.",
209+ diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable)))
210+}
211+
212+func TestCollapsedFileMarkup(t *testing.T) {
213+ t.Parallel()
214+ view := DiffView{ShowUnified: true}
215+ src := "/demo/commit/abc1234/files"
216+ for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} {
217+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}}
218+ fs := diffFiles(raw)
219+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
220+ out := renderToString(t, DiffFileView(fs[0], view))
221+ assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why)
222+ assert.Regexp(t, `<details `, out, "the file is a details element")
223+ assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
224+ assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
225+ assert.NotContains(t, out, "<table")
226+ }
227+ raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}}
228+ fs := diffFiles(raw)
229+ collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
230+ out := renderToString(t, DiffFileView(fs[0], view))
231+ assert.Contains(t, out, "too large to show in the browser")
232+ assert.NotContains(t, out, "load diff")
233+ assert.NotContains(t, out, "data-manual")
234+ assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`)
235+
236+ // Past the first window of a merge request the link keeps the window.
237+ raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}}
238+ fs = diffFiles(raw)
239+ collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25})
240+ assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src)
241+ assert.Equal(t, "?from=25&file=0#f1", fs[0].Href)
242+}
A internal/adapters/web/jstest/lazy.test.mjs +248 -0
@@ -0,0 +1,248 @@
1+// Tests of the loader of the files of a diff (static/js/lazy.js). Run with
2+// node --test 'internal/adapters/web/jstest/*.test.mjs'
3+import assert from 'node:assert/strict';
4+import { test } from 'node:test';
5+import { readFileSync } from 'node:fs';
6+
7+const SRC = readFileSync(new URL('../static/js/lazy.js', import.meta.url), 'utf8');
8+
9+globalThis.window = globalThis;
10+const saved = { document: globalThis.document, fetch: globalThis.fetch, IO: globalThis.IntersectionObserver };
11+
12+// A placeholder: an element with a dataset; its link finds it back.
13+function placeholder(src, manual) {
14+ const details = { id: 'f1', querySelector: () => null };
15+ const ph = { dataset: manual ? { src, manual: '' } : { src }, setAttribute() {}, removeAttribute() {}, closest: (s) => (s === 'details' ? details : null) };
16+ const link = { closest: (s) => (s === '.dlazy' ? ph : s === '.dlazy[data-manual] > a' && manual ? link : null) };
17+ return { ph, link, details };
18+}
19+
20+async function setup(phs, io = true, opts = {}) {
21+ const observed = [];
22+ const urls = [];
23+ if (io) globalThis.IntersectionObserver = class { observe(p) { observed.push(p); } unobserve() {} disconnect() {} };
24+ else delete globalThis.IntersectionObserver;
25+ const reply = opts.reply || { status: 200, ok: true, body: '' };
26+ globalThis.fetch = async (u) => { urls.push(u); return { status: reply.status, ok: reply.ok, text: async () => reply.body }; };
27+ globalThis.document = {
28+ readyState: 'complete',
29+ querySelector: (s) => (s === '#diffs' ? { dataset: {} } : null),
30+ querySelectorAll: (s) => (s.includes(':not([data-manual])') ? phs.filter((p) => !('manual' in p.dataset)) : phs),
31+ getElementById: opts.getElementById || (() => null),
32+ createElement: () => ({ content: { querySelectorAll: () => opts.files || [] } }),
33+ addEventListener() {},
34+ };
35+ delete globalThis.ermiteLazy;
36+ (0, eval)(SRC);
37+ return { observed, urls };
38+}
39+const settle = () => new Promise((r) => setTimeout(r, 0));
40+const restore = () => {
41+ delete globalThis.ermiteLazy;
42+ for (const [k, v] of [['document', saved.document], ['fetch', saved.fetch], ['IntersectionObserver', saved.IO]]) {
43+ if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
44+ }
45+};
46+
47+test('manual placeholders are not observed', async () => {
48+ const a = placeholder('/r/commit/abc/files?one=1&from=0', true);
49+ const b = placeholder('/r/merge-requests/1/files?from=25', false);
50+ try {
51+ const { observed, urls } = await setup([a.ph, b.ph]);
52+ assert.deepEqual(observed, [b.ph]);
53+ assert.deepEqual(urls, []);
54+ } finally { await settle(); restore(); }
55+});
56+
57+test('without IntersectionObserver only the automatic placeholders load', async () => {
58+ const a = placeholder('/m?one=1&from=0', true);
59+ const b = placeholder('/w?from=25', false);
60+ try {
61+ const { urls } = await setup([a.ph, b.ph], false);
62+ assert.deepEqual(urls, ['/w?from=25']);
63+ } finally { await settle(); restore(); }
64+});
65+
66+test('a click on "load diff" loads that one file and does not navigate', async () => {
67+ const a = placeholder('/r/commit/abc/files?one=1&from=3', true);
68+ try {
69+ const { urls } = await setup([a.ph]);
70+ let prevented = false;
71+ const used = window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { prevented = true; } });
72+ assert.equal(used, true);
73+ assert.equal(prevented, true);
74+ assert.deepEqual(urls, ['/r/commit/abc/files?one=1&from=3']);
75+ } finally { await settle(); restore(); }
76+});
77+
78+test('Enter and Space on the link load it; other keys and modified clicks do not', async () => {
79+ const a = placeholder('/f?one=1&from=1', true);
80+ try {
81+ const { urls } = await setup([a.ph]);
82+ const key = (k, extra = {}) => window.ermiteLazy.onKey({ target: a.link, key: k, preventDefault() {}, ...extra });
83+ assert.equal(key('a'), false);
84+ assert.equal(key('Enter', { ctrlKey: true }), false);
85+ assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, ctrlKey: true, preventDefault() {} }), false);
86+ assert.equal(urls.length, 0);
87+ assert.equal(key('Enter'), true);
88+ assert.equal(key(' '), true);
89+ assert.equal(urls.length, 1, 'a file is fetched once');
90+ } finally { await settle(); restore(); }
91+});
92+
93+test('a manual placeholder without an address is a plain link', async () => {
94+ const a = placeholder('', true);
95+ a.ph.dataset.src = '';
96+ try {
97+ await setup([a.ph]);
98+ assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { assert.fail(); } }), false);
99+ } finally { await settle(); restore(); }
100+});
101+
102+test('onSwap runs after a window came in', async () => {
103+ const b = placeholder('/w?from=25', false);
104+ try {
105+ await setup([b.ph]);
106+ let n = 0;
107+ window.ermiteLazy.onSwap(() => n++);
108+ await window.ermiteLazy.load(b.ph);
109+ assert.equal(n, 1);
110+ } finally { await settle(); restore(); }
111+});
112+
113+test('reset drops the response of a load in flight and lets the same window load again', async () => {
114+ const b = placeholder('/w?from=25', false);
115+ try {
116+ await setup([b.ph]);
117+ let release;
118+ const gate = new Promise((r) => { release = r; });
119+ let fetches = 0;
120+ globalThis.fetch = async () => { fetches++; await gate; return { status: 200, ok: true, text: async () => '' }; };
121+ let n = 0;
122+ window.ermiteLazy.onSwap(() => n++);
123+ const first = window.ermiteLazy.load(b.ph);
124+ window.ermiteLazy.reset();
125+ release();
126+ await first;
127+ assert.equal(n, 0, 'the old generation does not swap');
128+ await window.ermiteLazy.load(b.ph);
129+ assert.equal(fetches, 2);
130+ assert.equal(n, 1);
131+ } finally { await settle(); restore(); }
132+});
133+
134+test('reset clears the stale state; watch disconnects the previous observer', async () => {
135+ const b = placeholder('/w?from=25', false);
136+ try {
137+ await setup([b.ph]);
138+ const made = [];
139+ globalThis.IntersectionObserver = class {
140+ constructor() { this.off = false; made.push(this); }
141+ observe() {} unobserve() {} disconnect() { this.off = true; }
142+ };
143+ globalThis.fetch = async () => ({ status: 409, ok: false, text: async () => 'moved' });
144+ await window.ermiteLazy.load(b.ph);
145+ const urls = [];
146+ globalThis.fetch = async (u) => { urls.push(u); return { status: 200, ok: true, text: async () => '' }; };
147+ await window.ermiteLazy.load(b.ph);
148+ assert.deepEqual(urls, [], 'stale: nothing loads');
149+ window.ermiteLazy.watch();
150+ window.ermiteLazy.watch();
151+ assert.equal(made.length, 2);
152+ assert.ok(made[0].off && !made[1].off);
153+ window.ermiteLazy.reset();
154+ assert.ok(made[1].off);
155+ await window.ermiteLazy.load(b.ph);
156+ assert.equal(urls.length, 1);
157+ } finally { await settle(); restore(); }
158+});
159+
160+
161+// A file of the page (old) or of a fragment (new), with the queries swap makes.
162+function fileEl(id, { lazy = false, manual = false, open = false } = {}) {
163+ const f = { id, open, queried: [] };
164+ f.querySelector = (sel) => {
165+ if (sel === '.dlazy[data-manual]') return manual ? {} : null;
166+ if (sel === '.dlazy') return lazy || manual ? {} : null;
167+ if (sel === '.ln a') return f.line || null;
168+ return null;
169+ };
170+ f.querySelectorAll = () => [];
171+ return f;
172+}
173+
174+test('a loaded manual file opens and takes the focus without scrolling', async () => {
175+ const a = placeholder('/f?one=1&from=0', true);
176+ const old = fileEl('f1', { manual: true });
177+ const fresh = fileEl('f1');
178+ const focused = [];
179+ fresh.line = { focus: (o) => focused.push(o) };
180+ let current = old;
181+ old.replaceWith = (n) => { current = n; };
182+ try {
183+ await setup([a.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => current });
184+ window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() {} });
185+ await settle(); await settle();
186+ assert.equal(current, fresh);
187+ assert.equal(fresh.open, true);
188+ assert.deepEqual(focused, [{ preventScroll: true }]);
189+ } finally { await settle(); restore(); }
190+});
191+
192+test('a window that still holds a collapsed file keeps it closed', async () => {
193+ const w = placeholder('/w?from=25', false);
194+ const old = fileEl('f26', { lazy: true, open: true });
195+ const fresh = fileEl('f26', { manual: true, open: true });
196+ let replaced = null;
197+ old.replaceWith = (n) => { replaced = n; };
198+ try {
199+ await setup([w.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => old });
200+ await window.ermiteLazy.load(w.ph);
201+ assert.equal(replaced, fresh);
202+ assert.equal(fresh.open, false);
203+ } finally { await settle(); restore(); }
204+});
205+
206+test('a 409 makes the page stale: no later load fetches', async () => {
207+ const a = placeholder('/f?one=1&from=0', true);
208+ const b = placeholder('/f?one=1&from=1', true);
209+ try {
210+ const { urls } = await setup([a.ph, b.ph], true, { reply: { status: 409, ok: false, body: '<p>moved</p>' } });
211+ await window.ermiteLazy.load(a.ph);
212+ assert.equal(a.ph.innerHTML, '<p>moved</p>');
213+ assert.equal(urls.length, 1);
214+ await window.ermiteLazy.load(b.ph);
215+ assert.equal(urls.length, 1, 'stale: nothing is fetched');
216+ } finally { await settle(); restore(); }
217+});
218+
219+test('a failed load clears the loading mark and can be retried', async () => {
220+ const a = placeholder('/f?one=1&from=0', true);
221+ const marks = new Set();
222+ a.ph.setAttribute = (n) => marks.add(n);
223+ a.ph.removeAttribute = (n) => marks.delete(n);
224+ try {
225+ const { urls } = await setup([a.ph], true, { reply: { status: 500, ok: false, body: 'boom' } });
226+ const p = window.ermiteLazy.load(a.ph);
227+ assert.ok(marks.has('data-loading'));
228+ await p;
229+ assert.equal(marks.has('data-loading'), false);
230+ await window.ermiteLazy.load(a.ph);
231+ assert.equal(urls.length, 2, 'a retry fetches again');
232+ } finally { await settle(); restore(); }
233+});
234+
235+test('Space is prevented on the link; a held key is ignored', async () => {
236+ const a = placeholder('/f?one=1&from=1', true);
237+ try {
238+ const { urls } = await setup([a.ph]);
239+ let prevented = 0;
240+ const ev = (extra) => ({ target: a.link, key: ' ', preventDefault() { prevented++; }, ...extra });
241+ assert.equal(window.ermiteLazy.onKey(ev({ repeat: true })), false);
242+ assert.equal(prevented, 0);
243+ assert.equal(urls.length, 0);
244+ assert.equal(window.ermiteLazy.onKey(ev({})), true);
245+ assert.equal(prevented, 1, 'Space does not scroll the page');
246+ assert.equal(urls.length, 1);
247+ } finally { await settle(); restore(); }
248+});
@@ -0,0 +1,248 @@
1+// Tests of the loader of the files of a diff (static/js/lazy.js). Run with
2+// node --test 'internal/adapters/web/jstest/*.test.mjs'
3+import assert from 'node:assert/strict';
4+import { test } from 'node:test';
5+import { readFileSync } from 'node:fs';
6+
7+const SRC = readFileSync(new URL('../static/js/lazy.js', import.meta.url), 'utf8');
8+
9+globalThis.window = globalThis;
10+const saved = { document: globalThis.document, fetch: globalThis.fetch, IO: globalThis.IntersectionObserver };
11+
12+// A placeholder: an element with a dataset; its link finds it back.
13+function placeholder(src, manual) {
14+ const details = { id: 'f1', querySelector: () => null };
15+ const ph = { dataset: manual ? { src, manual: '' } : { src }, setAttribute() {}, removeAttribute() {}, closest: (s) => (s === 'details' ? details : null) };
16+ const link = { closest: (s) => (s === '.dlazy' ? ph : s === '.dlazy[data-manual] > a' && manual ? link : null) };
17+ return { ph, link, details };
18+}
19+
20+async function setup(phs, io = true, opts = {}) {
21+ const observed = [];
22+ const urls = [];
23+ if (io) globalThis.IntersectionObserver = class { observe(p) { observed.push(p); } unobserve() {} disconnect() {} };
24+ else delete globalThis.IntersectionObserver;
25+ const reply = opts.reply || { status: 200, ok: true, body: '' };
26+ globalThis.fetch = async (u) => { urls.push(u); return { status: reply.status, ok: reply.ok, text: async () => reply.body }; };
27+ globalThis.document = {
28+ readyState: 'complete',
29+ querySelector: (s) => (s === '#diffs' ? { dataset: {} } : null),
30+ querySelectorAll: (s) => (s.includes(':not([data-manual])') ? phs.filter((p) => !('manual' in p.dataset)) : phs),
31+ getElementById: opts.getElementById || (() => null),
32+ createElement: () => ({ content: { querySelectorAll: () => opts.files || [] } }),
33+ addEventListener() {},
34+ };
35+ delete globalThis.ermiteLazy;
36+ (0, eval)(SRC);
37+ return { observed, urls };
38+}
39+const settle = () => new Promise((r) => setTimeout(r, 0));
40+const restore = () => {
41+ delete globalThis.ermiteLazy;
42+ for (const [k, v] of [['document', saved.document], ['fetch', saved.fetch], ['IntersectionObserver', saved.IO]]) {
43+ if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
44+ }
45+};
46+
47+test('manual placeholders are not observed', async () => {
48+ const a = placeholder('/r/commit/abc/files?one=1&from=0', true);
49+ const b = placeholder('/r/merge-requests/1/files?from=25', false);
50+ try {
51+ const { observed, urls } = await setup([a.ph, b.ph]);
52+ assert.deepEqual(observed, [b.ph]);
53+ assert.deepEqual(urls, []);
54+ } finally { await settle(); restore(); }
55+});
56+
57+test('without IntersectionObserver only the automatic placeholders load', async () => {
58+ const a = placeholder('/m?one=1&from=0', true);
59+ const b = placeholder('/w?from=25', false);
60+ try {
61+ const { urls } = await setup([a.ph, b.ph], false);
62+ assert.deepEqual(urls, ['/w?from=25']);
63+ } finally { await settle(); restore(); }
64+});
65+
66+test('a click on "load diff" loads that one file and does not navigate', async () => {
67+ const a = placeholder('/r/commit/abc/files?one=1&from=3', true);
68+ try {
69+ const { urls } = await setup([a.ph]);
70+ let prevented = false;
71+ const used = window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { prevented = true; } });
72+ assert.equal(used, true);
73+ assert.equal(prevented, true);
74+ assert.deepEqual(urls, ['/r/commit/abc/files?one=1&from=3']);
75+ } finally { await settle(); restore(); }
76+});
77+
78+test('Enter and Space on the link load it; other keys and modified clicks do not', async () => {
79+ const a = placeholder('/f?one=1&from=1', true);
80+ try {
81+ const { urls } = await setup([a.ph]);
82+ const key = (k, extra = {}) => window.ermiteLazy.onKey({ target: a.link, key: k, preventDefault() {}, ...extra });
83+ assert.equal(key('a'), false);
84+ assert.equal(key('Enter', { ctrlKey: true }), false);
85+ assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, ctrlKey: true, preventDefault() {} }), false);
86+ assert.equal(urls.length, 0);
87+ assert.equal(key('Enter'), true);
88+ assert.equal(key(' '), true);
89+ assert.equal(urls.length, 1, 'a file is fetched once');
90+ } finally { await settle(); restore(); }
91+});
92+
93+test('a manual placeholder without an address is a plain link', async () => {
94+ const a = placeholder('', true);
95+ a.ph.dataset.src = '';
96+ try {
97+ await setup([a.ph]);
98+ assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { assert.fail(); } }), false);
99+ } finally { await settle(); restore(); }
100+});
101+
102+test('onSwap runs after a window came in', async () => {
103+ const b = placeholder('/w?from=25', false);
104+ try {
105+ await setup([b.ph]);
106+ let n = 0;
107+ window.ermiteLazy.onSwap(() => n++);
108+ await window.ermiteLazy.load(b.ph);
109+ assert.equal(n, 1);
110+ } finally { await settle(); restore(); }
111+});
112+
113+test('reset drops the response of a load in flight and lets the same window load again', async () => {
114+ const b = placeholder('/w?from=25', false);
115+ try {
116+ await setup([b.ph]);
117+ let release;
118+ const gate = new Promise((r) => { release = r; });
119+ let fetches = 0;
120+ globalThis.fetch = async () => { fetches++; await gate; return { status: 200, ok: true, text: async () => '' }; };
121+ let n = 0;
122+ window.ermiteLazy.onSwap(() => n++);
123+ const first = window.ermiteLazy.load(b.ph);
124+ window.ermiteLazy.reset();
125+ release();
126+ await first;
127+ assert.equal(n, 0, 'the old generation does not swap');
128+ await window.ermiteLazy.load(b.ph);
129+ assert.equal(fetches, 2);
130+ assert.equal(n, 1);
131+ } finally { await settle(); restore(); }
132+});
133+
134+test('reset clears the stale state; watch disconnects the previous observer', async () => {
135+ const b = placeholder('/w?from=25', false);
136+ try {
137+ await setup([b.ph]);
138+ const made = [];
139+ globalThis.IntersectionObserver = class {
140+ constructor() { this.off = false; made.push(this); }
141+ observe() {} unobserve() {} disconnect() { this.off = true; }
142+ };
143+ globalThis.fetch = async () => ({ status: 409, ok: false, text: async () => 'moved' });
144+ await window.ermiteLazy.load(b.ph);
145+ const urls = [];
146+ globalThis.fetch = async (u) => { urls.push(u); return { status: 200, ok: true, text: async () => '' }; };
147+ await window.ermiteLazy.load(b.ph);
148+ assert.deepEqual(urls, [], 'stale: nothing loads');
149+ window.ermiteLazy.watch();
150+ window.ermiteLazy.watch();
151+ assert.equal(made.length, 2);
152+ assert.ok(made[0].off && !made[1].off);
153+ window.ermiteLazy.reset();
154+ assert.ok(made[1].off);
155+ await window.ermiteLazy.load(b.ph);
156+ assert.equal(urls.length, 1);
157+ } finally { await settle(); restore(); }
158+});
159+
160+
161+// A file of the page (old) or of a fragment (new), with the queries swap makes.
162+function fileEl(id, { lazy = false, manual = false, open = false } = {}) {
163+ const f = { id, open, queried: [] };
164+ f.querySelector = (sel) => {
165+ if (sel === '.dlazy[data-manual]') return manual ? {} : null;
166+ if (sel === '.dlazy') return lazy || manual ? {} : null;
167+ if (sel === '.ln a') return f.line || null;
168+ return null;
169+ };
170+ f.querySelectorAll = () => [];
171+ return f;
172+}
173+
174+test('a loaded manual file opens and takes the focus without scrolling', async () => {
175+ const a = placeholder('/f?one=1&from=0', true);
176+ const old = fileEl('f1', { manual: true });
177+ const fresh = fileEl('f1');
178+ const focused = [];
179+ fresh.line = { focus: (o) => focused.push(o) };
180+ let current = old;
181+ old.replaceWith = (n) => { current = n; };
182+ try {
183+ await setup([a.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => current });
184+ window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() {} });
185+ await settle(); await settle();
186+ assert.equal(current, fresh);
187+ assert.equal(fresh.open, true);
188+ assert.deepEqual(focused, [{ preventScroll: true }]);
189+ } finally { await settle(); restore(); }
190+});
191+
192+test('a window that still holds a collapsed file keeps it closed', async () => {
193+ const w = placeholder('/w?from=25', false);
194+ const old = fileEl('f26', { lazy: true, open: true });
195+ const fresh = fileEl('f26', { manual: true, open: true });
196+ let replaced = null;
197+ old.replaceWith = (n) => { replaced = n; };
198+ try {
199+ await setup([w.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => old });
200+ await window.ermiteLazy.load(w.ph);
201+ assert.equal(replaced, fresh);
202+ assert.equal(fresh.open, false);
203+ } finally { await settle(); restore(); }
204+});
205+
206+test('a 409 makes the page stale: no later load fetches', async () => {
207+ const a = placeholder('/f?one=1&from=0', true);
208+ const b = placeholder('/f?one=1&from=1', true);
209+ try {
210+ const { urls } = await setup([a.ph, b.ph], true, { reply: { status: 409, ok: false, body: '<p>moved</p>' } });
211+ await window.ermiteLazy.load(a.ph);
212+ assert.equal(a.ph.innerHTML, '<p>moved</p>');
213+ assert.equal(urls.length, 1);
214+ await window.ermiteLazy.load(b.ph);
215+ assert.equal(urls.length, 1, 'stale: nothing is fetched');
216+ } finally { await settle(); restore(); }
217+});
218+
219+test('a failed load clears the loading mark and can be retried', async () => {
220+ const a = placeholder('/f?one=1&from=0', true);
221+ const marks = new Set();
222+ a.ph.setAttribute = (n) => marks.add(n);
223+ a.ph.removeAttribute = (n) => marks.delete(n);
224+ try {
225+ const { urls } = await setup([a.ph], true, { reply: { status: 500, ok: false, body: 'boom' } });
226+ const p = window.ermiteLazy.load(a.ph);
227+ assert.ok(marks.has('data-loading'));
228+ await p;
229+ assert.equal(marks.has('data-loading'), false);
230+ await window.ermiteLazy.load(a.ph);
231+ assert.equal(urls.length, 2, 'a retry fetches again');
232+ } finally { await settle(); restore(); }
233+});
234+
235+test('Space is prevented on the link; a held key is ignored', async () => {
236+ const a = placeholder('/f?one=1&from=1', true);
237+ try {
238+ const { urls } = await setup([a.ph]);
239+ let prevented = 0;
240+ const ev = (extra) => ({ target: a.link, key: ' ', preventDefault() { prevented++; }, ...extra });
241+ assert.equal(window.ermiteLazy.onKey(ev({ repeat: true })), false);
242+ assert.equal(prevented, 0);
243+ assert.equal(urls.length, 0);
244+ assert.equal(window.ermiteLazy.onKey(ev({})), true);
245+ assert.equal(prevented, 1, 'Space does not scroll the page');
246+ assert.equal(urls.length, 1);
247+ } finally { await settle(); restore(); }
248+});
M internal/adapters/web/jstest/mr.test.mjs +1 -0
@@ -5,6 +5,7 @@ import { test } from 'node:test';
55
66 globalThis.window = globalThis;
77 globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };
8+await import('../static/js/lazy.js');
89 await import('../static/js/mr.js');
910 const C = window.ermiteMR.core;
1011
@@ -5,6 +5,7 @@ import { test } from 'node:test';
5 5
6 globalThis.window = globalThis;6 globalThis.window = globalThis;
7 globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };7 globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] };
8+await import('../static/js/lazy.js');
8 await import('../static/js/mr.js');9 await import('../static/js/mr.js');
9 const C = window.ermiteMR.core;10 const C = window.ermiteMR.core;
10 11
M internal/adapters/web/mr.go +8 -2
@@ -562,6 +562,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
562562 }
563563 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
564564 SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow},
565+ Load: fileParam(r), // a page without scripts: ?file=i reads that collapsed file in full
565566 })
566567 if err != nil {
567568 return nil, 0, err
@@ -590,11 +591,16 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
590591 Files: s.hl.files(ctx, res.Diff.Files),
591592 }
592593 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
594+ src := ""
595+ if !res.DiffBaseID.IsZero() {
596+ src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
597+ }
598+ collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
593599 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
594600 return nil, 0, err
595601 }
596602
597- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
603+ p.Note = diffNote(res.Diff)
598604 countNotes(p.Files, res.Comments)
599605 if !res.DiffBaseID.IsZero() {
600606 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
@@ -649,7 +655,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
649655 p.Live = true
650656 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
651657 l.HasRail = true
652- l.MR = true
658+ l.MR, l.Lazy = true, true
653659 return MergeRequest(l, p), http.StatusOK, nil
654660 }
655661
@@ -562,6 +562,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
562 }562 }
563 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{563 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
564 SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow},564 SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow},
565+ Load: fileParam(r), // a page without scripts: ?file=i reads that collapsed file in full
565 })566 })
566 if err != nil {567 if err != nil {
567 return nil, 0, err568 return nil, 0, err
@@ -590,11 +591,16 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
590 Files: s.hl.files(ctx, res.Diff.Files),591 Files: s.hl.files(ctx, res.Diff.Files),
591 }592 }
592 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))593 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
594+ src := ""
595+ if !res.DiffBaseID.IsZero() {
596+ src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)
597+ }
598+ collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head})
593 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {599 if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
594 return nil, 0, err600 return nil, 0, err
595 }601 }
596 602
597- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)603+ p.Note = diffNote(res.Diff)
598 countNotes(p.Files, res.Comments)604 countNotes(p.Files, res.Comments)
599 if !res.DiffBaseID.IsZero() {605 if !res.DiffBaseID.IsZero() {
600 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))606 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
@@ -649,7 +655,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
649 p.Live = true655 p.Live = true
650 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))656 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
651 l.HasRail = true657 l.HasRail = true
652- l.MR = true658+ l.MR, l.Lazy = true, true
653 return MergeRequest(l, p), http.StatusOK, nil659 return MergeRequest(l, p), http.StatusOK, nil
654 }660 }
655 661
M internal/adapters/web/mr_files.go +36 -10
@@ -73,6 +73,18 @@ func parseFrom(r *http.Request) (int, error) {
7373 return n, nil
7474 }
7575
76+// parseOne reads ?one=1&from=i of a one-file fragment: the index i of the
77+// file, any non-negative number.
78+func parseOne(r *http.Request) (int, error) {
79+ q := r.URL.Query()
80+ s := q.Get("from")
81+ n, err := strconv.Atoi(s)
82+ if q.Get("one") != "1" || err != nil || n < 0 || strconv.Itoa(n) != s {
83+ return 0, errNotFound
84+ }
85+ return n, nil
86+}
87+
7688 // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows
7789 // a placeholder that loads their window. src is the URL of /files with the
7890 // base and the head of the diff; self the page, for the links of a page
@@ -167,7 +179,13 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
167179 if since && !SignedIn(ctx) {
168180 return nil, 0, errNotFound
169181 }
170- from, err := parseFrom(r)
182+ one := q.Get("one") == "1"
183+ var from int
184+ if one {
185+ from, err = parseOne(r)
186+ } else {
187+ from, err = parseFrom(r)
188+ }
171189 if err != nil {
172190 return nil, 0, err
173191 }
@@ -180,9 +198,12 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
180198 if err != nil {
181199 return nil, 0, err
182200 }
201+ win := &ports.FileWindow{From: from, To: from + mrWindow}
202+ if one {
203+ win = &ports.FileWindow{From: from, To: from + 1, Full: true}
204+ }
183205 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
184- SinceLastRead: since, DiffOnly: true, Base: base, Head: head,
185- Files: &ports.FileWindow{From: from, To: from + mrWindow},
206+ SinceLastRead: since, DiffOnly: true, Base: base, Head: head, Files: win,
186207 })
187208 if errors.Is(err, domain.ErrConflict) {
188209 return MRFilesMoved(), http.StatusConflict, nil
@@ -198,8 +219,19 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
198219 canWrite := signed && m.State == domain.MRStateOpen && !since
199220 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200221 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
222+ links := fileLinks{src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String()}
223+ if one {
224+ // A one-file read holds file from alone, at index from: the load
225+ // addresses of a window do not apply, only the raw link.
226+ p.Files[from].Raw = rawLink(res.Diff.Files[from], links)
227+ } else {
228+ collapseFiles(p.Files, res.Diff.Files, links)
229+ }
201230 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232+ if one {
233+ window = p.Files[from : from+1]
234+ }
203235 // The layouts follow the page: it sends ?view= (the layout shown) and
204236 // ?layouts=both when it holds both layouts and switches them itself, so
205237 // that every window of the page renders the same way.
@@ -209,12 +241,6 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
209241 }
210242 lines, _ := diffLines(window)
211243 v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
212- switch r.URL.Query().Get("layouts") {
213- case "both":
214- v.Both = true
215- case "one":
216- v.Both = false
217- }
218- v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
244+ v.pageLayouts(r)
219245 return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil
220246 }
@@ -73,6 +73,18 @@ func parseFrom(r *http.Request) (int, error) {
73 return n, nil73 return n, nil
74 }74 }
75 75
76+// parseOne reads ?one=1&from=i of a one-file fragment: the index i of the
77+// file, any non-negative number.
78+func parseOne(r *http.Request) (int, error) {
79+ q := r.URL.Query()
80+ s := q.Get("from")
81+ n, err := strconv.Atoi(s)
82+ if q.Get("one") != "1" || err != nil || n < 0 || strconv.Itoa(n) != s {
83+ return 0, errNotFound
84+ }
85+ return n, nil
86+}
87+
76 // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows88 // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows
77 // a placeholder that loads their window. src is the URL of /files with the89 // a placeholder that loads their window. src is the URL of /files with the
78 // base and the head of the diff; self the page, for the links of a page90 // base and the head of the diff; self the page, for the links of a page
@@ -167,7 +179,13 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
167 if since && !SignedIn(ctx) {179 if since && !SignedIn(ctx) {
168 return nil, 0, errNotFound180 return nil, 0, errNotFound
169 }181 }
170- from, err := parseFrom(r)182+ one := q.Get("one") == "1"
183+ var from int
184+ if one {
185+ from, err = parseOne(r)
186+ } else {
187+ from, err = parseFrom(r)
188+ }
171 if err != nil {189 if err != nil {
172 return nil, 0, err190 return nil, 0, err
173 }191 }
@@ -180,9 +198,12 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
180 if err != nil {198 if err != nil {
181 return nil, 0, err199 return nil, 0, err
182 }200 }
201+ win := &ports.FileWindow{From: from, To: from + mrWindow}
202+ if one {
203+ win = &ports.FileWindow{From: from, To: from + 1, Full: true}
204+ }
183 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{205 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
184- SinceLastRead: since, DiffOnly: true, Base: base, Head: head,206+ SinceLastRead: since, DiffOnly: true, Base: base, Head: head, Files: win,
185- Files: &ports.FileWindow{From: from, To: from + mrWindow},
186 })207 })
187 if errors.Is(err, domain.ErrConflict) {208 if errors.Is(err, domain.ErrConflict) {
188 return MRFilesMoved(), http.StatusConflict, nil209 return MRFilesMoved(), http.StatusConflict, nil
@@ -198,8 +219,19 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
198 canWrite := signed && m.State == domain.MRStateOpen && !since219 canWrite := signed && m.State == domain.MRStateOpen && !since
199 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}220 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))221 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
222+ links := fileLinks{src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String()}
223+ if one {
224+ // A one-file read holds file from alone, at index from: the load
225+ // addresses of a window do not apply, only the raw link.
226+ p.Files[from].Raw = rawLink(res.Diff.Files[from], links)
227+ } else {
228+ collapseFiles(p.Files, res.Diff.Files, links)
229+ }
201 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)230 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202 window := p.Files[from:min(from+mrWindow, len(p.Files))]231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232+ if one {
233+ window = p.Files[from : from+1]
234+ }
203 // The layouts follow the page: it sends ?view= (the layout shown) and235 // The layouts follow the page: it sends ?view= (the layout shown) and
204 // ?layouts=both when it holds both layouts and switches them itself, so236 // ?layouts=both when it holds both layouts and switches them itself, so
205 // that every window of the page renders the same way.237 // that every window of the page renders the same way.
@@ -209,12 +241,6 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
209 }241 }
210 lines, _ := diffLines(window)242 lines, _ := diffLines(window)
211 v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))243 v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
212- switch r.URL.Query().Get("layouts") {244+ v.pageLayouts(r)
213- case "both":
214- v.Both = true
215- case "one":
216- v.Both = false
217- }
218- v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
219 return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil245 return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil
220 }246 }
M internal/adapters/web/shell.templ +3 -0
@@ -30,6 +30,9 @@ templ Shell(l Layout, rail templ.Component) {
3030 <script src={ l.Assets.Files } defer></script>
3131 <script src={ l.Assets.Passkey } defer></script>
3232 <script src={ l.Assets.Log } defer></script>
33+ if l.Lazy {
34+ <script src={ l.Assets.Lazy } defer></script>
35+ }
3336 if l.MR {
3437 <script src={ l.Assets.MR } defer></script>
3538 }
@@ -30,6 +30,9 @@ templ Shell(l Layout, rail templ.Component) {
30 <script src={ l.Assets.Files } defer></script>30 <script src={ l.Assets.Files } defer></script>
31 <script src={ l.Assets.Passkey } defer></script>31 <script src={ l.Assets.Passkey } defer></script>
32 <script src={ l.Assets.Log } defer></script>32 <script src={ l.Assets.Log } defer></script>
33+ if l.Lazy {
34+ <script src={ l.Assets.Lazy } defer></script>
35+ }
33 if l.MR {36 if l.MR {
34 <script src={ l.Assets.MR } defer></script>37 <script src={ l.Assets.MR } defer></script>
35 }38 }
M internal/adapters/web/shell_templ.go +199 -180 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +3 -1
A internal/adapters/web/static/js/lazy.js +170 -0
M internal/adapters/web/static/js/mr.js +8 -91
M internal/adapters/web/view.go +3 -0
M internal/adapters/web/web.go +1 -0
M internal/app/cli_mr.go +21 -10
M internal/app/cli_mr_test.go +15 -0
M internal/app/testdata/web/commit-big.html +6 -3
M internal/app/testdata/web/commit-binary.html +2 -0
M internal/app/testdata/web/commit-merge.html +2 -0
M internal/app/testdata/web/commit-modify-split.html +2 -0
M internal/app/testdata/web/commit-modify.html +2 -0
M internal/app/testdata/web/commit-rename.html +2 -0
M internal/app/testdata/web/commit-root.html +2 -0
M internal/app/testdata/web/commit-trojan.html +2 -0
M internal/app/web_commit_test.go +63 -5
M internal/app/web_mr_test.go +77 -0
M internal/domain/budget.go +60 -11
M internal/domain/budget_test.go +136 -17
A internal/domain/generated.go +46 -0
A internal/domain/generated_test.go +51 -0
M internal/domain/git.go +84 -19
M internal/domain/mergerequest_test.go +1 -1
M internal/ports/dto.go +33 -3
M internal/ports/mocks/BrowseService.go +18 -12
M internal/ports/services.go +1 -1
M internal/readertest/conformance.go +2 -0
A internal/readertest/diff.go +185 -0
M internal/usecase/browse.go +27 -3
M internal/usecase/browse_test.go +37 -4
M internal/usecase/ci_pull_test.go +1 -1
M internal/usecase/ci_pullfiles.go +1 -1
M internal/usecase/export_test.go +3 -0
M internal/usecase/mergerequests.go +42 -19
M internal/usecase/mergerequests_files_test.go +1 -1
A internal/usecase/window_test.go +166 -0

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close