detached 4af5505

fix(diff): cap one-file loads at 50,000 changed lines and harden the web splice

commit
4af55054493e29eaf3c625427ed13ead87c8346b
author
brice · · 2026-10-20 08:17 UTC
committer
brice · · 2026-10-20 09:16 UTC
parent
eb480cb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +246 -46
M docs/merge-requests.md +1 -1
@@ -67,7 +67,7 @@ In the web merge box a mode that is not possible is disabled with its reason, an
6767
6868 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.
6969
70-A file over 8 MiB of text cannot be shown in the browser: the page links to its raw blob. A commit page lists at most 300 files; clone the repository to read the rest.
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.
7171
7272 ## Notes
7373
@@ -67,7 +67,7 @@ In the web merge box a mode that is not possible is disabled with its reason, an
67 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.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 69
70-A file over 8 MiB of text cannot be shown in the browser: the page links to its raw blob. A commit page lists at most 300 files; clone the repository to read the rest.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.
71 71
72 ## Notes72 ## Notes
73 73
M internal/adapters/web/commit.go +7 -14
@@ -147,7 +147,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
147147 p.Refs = append(p.Refs, "tag: "+t.ShortName)
148148 }
149149 }
150- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
150+ p.Note = diffNote(res.Diff)
151151 p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
152152
153153 lines, invisible := diffLines(p.Files)
@@ -190,26 +190,19 @@ func commitBody(c domain.Commit) string {
190190 }
191191
192192 // diffNote says what is missing from an incomplete diff, "" for a complete one:
193-// files past the limit of the listing, files too large for the browser at all.
194-// Collapsed files say so on their own line and load on their own.
195-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
194+// line and load on their own; every changed file is listed.
195+func diffNote(d domain.Diff) string {
196196 gone := 0
197197 for _, f := range d.Files {
198198 if f.Omitted == domain.OmitUnavailable {
199199 gone++
200200 }
201201 }
202- var parts []string
203- if d.Truncated && shown < total {
204- parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
205- }
206- if gone > 0 {
207- parts = append(parts, plural(gone, "file is", "files are")+" too large to show")
208- }
209- if len(parts) == 0 {
202+ if gone == 0 {
210203 return ""
211204 }
212- return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."
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."
213206 }
214207
215208 // commitWindow is how many files of a commit get their hunks read with the
@@ -272,7 +265,7 @@ func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Compon
272265 files := s.hl.files(ctx, raw)
273266 files[0].ID = "f" + itoa(i+1)
274267 contextGaps(files, raw, repoHref(d.name(), "context", cid))
275- collapseFiles(files, raw, commitLinks(d.name(), cid))
268+ files[0].Raw = rawLink(raw[0], commitLinks(d.name(), cid))
276269 lines, _ := diffLines(files)
277270 return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil
278271 }
@@ -147,7 +147,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
147 p.Refs = append(p.Refs, "tag: "+t.ShortName)147 p.Refs = append(p.Refs, "tag: "+t.ShortName)
148 }148 }
149 }149 }
150- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)150+ p.Note = diffNote(res.Diff)
151 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))
152 152
153 lines, invisible := diffLines(p.Files)153 lines, invisible := diffLines(p.Files)
@@ -190,26 +190,19 @@ func commitBody(c domain.Commit) string {
190 }190 }
191 191
192 // 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:
193-// files past the limit of the listing, files too large for the browser at all.193+// files too large for the browser at all. Collapsed files say so on their own
194-// Collapsed files say so on their own line and load on their own.194+// line and load on their own; every changed file is listed.
195-func diffNote(d domain.Diff, shown, total int) string {195+func diffNote(d domain.Diff) string {
196 gone := 0196 gone := 0
197 for _, f := range d.Files {197 for _, f := range d.Files {
198 if f.Omitted == domain.OmitUnavailable {198 if f.Omitted == domain.OmitUnavailable {
199 gone++199 gone++
200 }200 }
201 }201 }
202- var parts []string202+ if gone == 0 {
203- if d.Truncated && shown < total {
204- parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
205- }
206- if gone > 0 {
207- parts = append(parts, plural(gone, "file is", "files are")+" too large to show")
208- }
209- if len(parts) == 0 {
210 return ""203 return ""
211 }204 }
212- return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."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."
213 }206 }
214 207
215 // commitWindow is how many files of a commit get their hunks read with the208 // commitWindow is how many files of a commit get their hunks read with the
@@ -272,7 +265,7 @@ func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Compon
272 files := s.hl.files(ctx, raw)265 files := s.hl.files(ctx, raw)
273 files[0].ID = "f" + itoa(i+1)266 files[0].ID = "f" + itoa(i+1)
274 contextGaps(files, raw, repoHref(d.name(), "context", cid))267 contextGaps(files, raw, repoHref(d.name(), "context", cid))
275- collapseFiles(files, raw, commitLinks(d.name(), cid))268+ files[0].Raw = rawLink(raw[0], commitLinks(d.name(), cid))
276 lines, _ := diffLines(files)269 lines, _ := diffLines(files)
277 return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil270 return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil
278 }271 }
M internal/adapters/web/compare.go +1 -1
@@ -85,7 +85,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
8585 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
8686 collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String()))
8787 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
88- if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
88+ if n := diffNote(res.Diff); n != "" {
8989 p.Note = n
9090 }
9191 lines, invisible := diffLines(p.Files)
@@ -85,7 +85,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
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 collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String()))
87 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
88- if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {88+ if n := diffNote(res.Diff); n != "" {
89 p.Note = n89 p.Note = n
90 }90 }
91 lines, invisible := diffLines(p.Files)91 lines, invisible := diffLines(p.Files)
M internal/adapters/web/diff.go +12 -3
@@ -424,7 +424,7 @@ func fileNote(f domain.FileDiff) string {
424424 case f.Omitted == domain.OmitBudget:
425425 parts = append(parts, "collapsed to keep the page light")
426426 case f.Omitted == domain.OmitUnavailable:
427- parts = append(parts, "too large to show in the browser (over 8 MiB of text)")
427+ parts = append(parts, "too large to show in the browser")
428428 }
429429 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
430430 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))
@@ -505,12 +505,21 @@ func collapseFiles(out []DiffFile, files []domain.FileDiff, l fileLinks) {
505505 q = "?from=" + itoa(l.from) + "&file=" + itoa(i)
506506 }
507507 out[i].Href = q + "#" + out[i].ID
508- case f.Omitted == domain.OmitUnavailable && f.Status != domain.FileDeleted && l.rev != "":
509- out[i].Raw = repoHref(l.repo, "raw", l.rev, f.NewPath)
508+ default:
509+ out[i].Raw = rawLink(f, l)
510510 }
511511 }
512512 }
513513
514+// rawLink is the address of the raw blob of a file too large to show, "" for
515+// any other file.
516+func rawLink(f domain.FileDiff, l fileLinks) string {
517+ if f.Omitted != domain.OmitUnavailable || f.Status == domain.FileDeleted || l.rev == "" {
518+ return ""
519+ }
520+ return repoHref(l.repo, "raw", l.rev, f.NewPath)
521+}
522+
514523 // fileParam reads ?file= of a page without scripts: the index of a collapsed
515524 // file to read in full, in [0, n).
516525 func fileParam(r *http.Request, n int) (int, bool) {
@@ -424,7 +424,7 @@ func fileNote(f domain.FileDiff) string {
424 case f.Omitted == domain.OmitBudget:424 case f.Omitted == domain.OmitBudget:
425 parts = append(parts, "collapsed to keep the page light")425 parts = append(parts, "collapsed to keep the page light")
426 case f.Omitted == domain.OmitUnavailable:426 case f.Omitted == domain.OmitUnavailable:
427- parts = append(parts, "too large to show in the browser (over 8 MiB of text)")427+ parts = append(parts, "too large to show in the browser")
428 }428 }
429 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {429 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
430 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))
@@ -505,12 +505,21 @@ func collapseFiles(out []DiffFile, files []domain.FileDiff, l fileLinks) {
505 q = "?from=" + itoa(l.from) + "&file=" + itoa(i)505 q = "?from=" + itoa(l.from) + "&file=" + itoa(i)
506 }506 }
507 out[i].Href = q + "#" + out[i].ID507 out[i].Href = q + "#" + out[i].ID
508- case f.Omitted == domain.OmitUnavailable && f.Status != domain.FileDeleted && l.rev != "":508+ default:
509- out[i].Raw = repoHref(l.repo, "raw", l.rev, f.NewPath)509+ out[i].Raw = rawLink(f, l)
510 }510 }
511 }511 }
512 }512 }
513 513
514+// rawLink is the address of the raw blob of a file too large to show, "" for
515+// any other file.
516+func rawLink(f domain.FileDiff, l fileLinks) string {
517+ if f.Omitted != domain.OmitUnavailable || f.Status == domain.FileDeleted || l.rev == "" {
518+ return ""
519+ }
520+ return repoHref(l.repo, "raw", l.rev, f.NewPath)
521+}
522+
514 // fileParam reads ?file= of a page without scripts: the index of a collapsed523 // fileParam reads ?file= of a page without scripts: the index of a collapsed
515 // file to read in full, in [0, n).524 // file to read in full, in [0, n).
516 func fileParam(r *http.Request, n int) (int, bool) {525 func fileParam(r *http.Request, n int) (int, bool) {
M internal/adapters/web/diff_test.go +5 -10
@@ -102,7 +102,7 @@ func TestFileNotes(t *testing.T) {
102102 {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},
103103 {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true},
104104 {"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 (over 8 MiB of text)", true},
105+ {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true},
106106 }
107107 for _, tt := range tests {
108108 t.Run(tt.name, func(t *testing.T) {
@@ -204,15 +204,9 @@ func TestDiffNote(t *testing.T) {
204204 }
205205 return d
206206 }
207- assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget), 3, 3), "collapsed files have no global note")
208- d := files(domain.OmitNone)
209- d.Truncated = true
210- assert.Equal(t, "this diff is incomplete: 299 files are not listed. the totals above count everything; clone the repository to read the rest.", diffNote(d, 1, 300))
207+ assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note")
211208 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.",
212- diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable), 3, 3))
213- d = files(domain.OmitUnavailable)
214- d.Truncated = true
215- assert.Contains(t, diffNote(d, 1, 5), "4 files are not listed, 1 file is too large to show")
209+ diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable)))
216210 }
217211
218212 func TestCollapsedFileMarkup(t *testing.T) {
@@ -224,7 +218,8 @@ func TestCollapsedFileMarkup(t *testing.T) {
224218 fs := diffFiles(raw)
225219 collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
226220 out := renderToString(t, DiffFileView(fs[0], view))
227- assert.NotContains(t, out, " open", "omission %d", why)
221+ assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why)
222+ assert.Regexp(t, `<details `, out, "the file is a details element")
228223 assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
229224 assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
230225 assert.NotContains(t, out, "<table")
@@ -102,7 +102,7 @@ func TestFileNotes(t *testing.T) {
102 {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true},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},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},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 (over 8 MiB of text)", true},105+ {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true},
106 }106 }
107 for _, tt := range tests {107 for _, tt := range tests {
108 t.Run(tt.name, func(t *testing.T) {108 t.Run(tt.name, func(t *testing.T) {
@@ -204,15 +204,9 @@ func TestDiffNote(t *testing.T) {
204 }204 }
205 return d205 return d
206 }206 }
207- assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget), 3, 3), "collapsed files have no global note")207+ assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note")
208- d := files(domain.OmitNone)
209- d.Truncated = true
210- assert.Equal(t, "this diff is incomplete: 299 files are not listed. the totals above count everything; clone the repository to read the rest.", diffNote(d, 1, 300))
211 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.",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.",
212- diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable), 3, 3))209+ diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable)))
213- d = files(domain.OmitUnavailable)
214- d.Truncated = true
215- assert.Contains(t, diffNote(d, 1, 5), "4 files are not listed, 1 file is too large to show")
216 }210 }
217 211
218 func TestCollapsedFileMarkup(t *testing.T) {212 func TestCollapsedFileMarkup(t *testing.T) {
@@ -224,7 +218,8 @@ func TestCollapsedFileMarkup(t *testing.T) {
224 fs := diffFiles(raw)218 fs := diffFiles(raw)
225 collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})219 collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"})
226 out := renderToString(t, DiffFileView(fs[0], view))220 out := renderToString(t, DiffFileView(fs[0], view))
227- assert.NotContains(t, out, " open", "omission %d", why)221+ assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why)
222+ assert.Regexp(t, `<details `, out, "the file is a details element")
228 assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)223 assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&amp;from=0" data-manual>`)
229 assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)224 assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`)
230 assert.NotContains(t, out, "<table")225 assert.NotContains(t, out, "<table")
M internal/adapters/web/jstest/lazy.test.mjs +96 -4
@@ -17,18 +17,19 @@ function placeholder(src, manual) {
1717 return { ph, link, details };
1818 }
1919
20-async function setup(phs, io = true) {
20+async function setup(phs, io = true, opts = {}) {
2121 const observed = [];
2222 const urls = [];
2323 if (io) globalThis.IntersectionObserver = class { observe(p) { observed.push(p); } unobserve() {} disconnect() {} };
2424 else delete globalThis.IntersectionObserver;
25- globalThis.fetch = async (u) => { urls.push(u); return { status: 200, ok: true, text: async () => '' }; };
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 }; };
2627 globalThis.document = {
2728 readyState: 'complete',
2829 querySelector: (s) => (s === '#diffs' ? { dataset: {} } : null),
2930 querySelectorAll: (s) => (s.includes(':not([data-manual])') ? phs.filter((p) => !('manual' in p.dataset)) : phs),
30- getElementById: () => null,
31- createElement: () => ({ content: { querySelectorAll: () => [] } }),
31+ getElementById: opts.getElementById || (() => null),
32+ createElement: () => ({ content: { querySelectorAll: () => opts.files || [] } }),
3233 addEventListener() {},
3334 };
3435 delete globalThis.ermiteLazy;
@@ -37,6 +38,7 @@ async function setup(phs, io = true) {
3738 }
3839 const settle = () => new Promise((r) => setTimeout(r, 0));
3940 const restore = () => {
41+ delete globalThis.ermiteLazy;
4042 for (const [k, v] of [['document', saved.document], ['fetch', saved.fetch], ['IntersectionObserver', saved.IO]]) {
4143 if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
4244 }
@@ -154,3 +156,93 @@ test('reset clears the stale state; watch disconnects the previous observer', as
154156 assert.equal(urls.length, 1);
155157 } finally { await settle(); restore(); }
156158 });
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+});
@@ -17,18 +17,19 @@ function placeholder(src, manual) {
17 return { ph, link, details };17 return { ph, link, details };
18 }18 }
19 19
20-async function setup(phs, io = true) {20+async function setup(phs, io = true, opts = {}) {
21 const observed = [];21 const observed = [];
22 const urls = [];22 const urls = [];
23 if (io) globalThis.IntersectionObserver = class { observe(p) { observed.push(p); } unobserve() {} disconnect() {} };23 if (io) globalThis.IntersectionObserver = class { observe(p) { observed.push(p); } unobserve() {} disconnect() {} };
24 else delete globalThis.IntersectionObserver;24 else delete globalThis.IntersectionObserver;
25- globalThis.fetch = async (u) => { urls.push(u); return { status: 200, ok: true, text: async () => '' }; };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 }; };
26 globalThis.document = {27 globalThis.document = {
27 readyState: 'complete',28 readyState: 'complete',
28 querySelector: (s) => (s === '#diffs' ? { dataset: {} } : null),29 querySelector: (s) => (s === '#diffs' ? { dataset: {} } : null),
29 querySelectorAll: (s) => (s.includes(':not([data-manual])') ? phs.filter((p) => !('manual' in p.dataset)) : phs),30 querySelectorAll: (s) => (s.includes(':not([data-manual])') ? phs.filter((p) => !('manual' in p.dataset)) : phs),
30- getElementById: () => null,31+ getElementById: opts.getElementById || (() => null),
31- createElement: () => ({ content: { querySelectorAll: () => [] } }),32+ createElement: () => ({ content: { querySelectorAll: () => opts.files || [] } }),
32 addEventListener() {},33 addEventListener() {},
33 };34 };
34 delete globalThis.ermiteLazy;35 delete globalThis.ermiteLazy;
@@ -37,6 +38,7 @@ async function setup(phs, io = true) {
37 }38 }
38 const settle = () => new Promise((r) => setTimeout(r, 0));39 const settle = () => new Promise((r) => setTimeout(r, 0));
39 const restore = () => {40 const restore = () => {
41+ delete globalThis.ermiteLazy;
40 for (const [k, v] of [['document', saved.document], ['fetch', saved.fetch], ['IntersectionObserver', saved.IO]]) {42 for (const [k, v] of [['document', saved.document], ['fetch', saved.fetch], ['IntersectionObserver', saved.IO]]) {
41 if (v === undefined) delete globalThis[k]; else globalThis[k] = v;43 if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
42 }44 }
@@ -154,3 +156,93 @@ test('reset clears the stale state; watch disconnects the previous observer', as
154 assert.equal(urls.length, 1);156 assert.equal(urls.length, 1);
155 } finally { await settle(); restore(); }157 } finally { await settle(); restore(); }
156 });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/mr.go +7 -4
@@ -4,6 +4,7 @@ package web
44
55 import (
66 "context"
7+ "errors"
78 "html"
89 "html/template"
910 "io"
@@ -570,15 +571,17 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
570571 return nil, 0, errNotFound
571572 }
572573 // A page without scripts: ?file=i reads that collapsed file in full.
573- if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() {
574+ // Without a pinned base, or when the pair moved since the first read, the
575+ // page is shown unspliced.
576+ if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() && !res.DiffBaseID.IsZero() {
574577 one, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
575578 SinceLastRead: since, DiffOnly: true, Base: res.DiffBaseID, Head: res.MergeRequest.HeadID,
576579 Files: &ports.FileWindow{From: i, To: i + 1, Full: true},
577580 })
578- if err != nil {
581+ if err != nil && !errors.Is(err, domain.ErrConflict) {
579582 return nil, 0, err
580583 }
581- if i < len(one.Diff.Files) {
584+ if err == nil && i < len(one.Diff.Files) {
582585 res.Diff.Files[i] = one.Diff.Files[i]
583586 }
584587 }
@@ -612,7 +615,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
612615 return nil, 0, err
613616 }
614617
615- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
618+ p.Note = diffNote(res.Diff)
616619 countNotes(p.Files, res.Comments)
617620 if !res.DiffBaseID.IsZero() {
618621 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
@@ -4,6 +4,7 @@ package web
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "html"8 "html"
8 "html/template"9 "html/template"
9 "io"10 "io"
@@ -570,15 +571,17 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
570 return nil, 0, errNotFound571 return nil, 0, errNotFound
571 }572 }
572 // A page without scripts: ?file=i reads that collapsed file in full.573 // A page without scripts: ?file=i reads that collapsed file in full.
573- if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() {574+ // Without a pinned base, or when the pair moved since the first read, the
575+ // page is shown unspliced.
576+ if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() && !res.DiffBaseID.IsZero() {
574 one, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{577 one, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
575 SinceLastRead: since, DiffOnly: true, Base: res.DiffBaseID, Head: res.MergeRequest.HeadID,578 SinceLastRead: since, DiffOnly: true, Base: res.DiffBaseID, Head: res.MergeRequest.HeadID,
576 Files: &ports.FileWindow{From: i, To: i + 1, Full: true},579 Files: &ports.FileWindow{From: i, To: i + 1, Full: true},
577 })580 })
578- if err != nil {581+ if err != nil && !errors.Is(err, domain.ErrConflict) {
579 return nil, 0, err582 return nil, 0, err
580 }583 }
581- if i < len(one.Diff.Files) {584+ if err == nil && i < len(one.Diff.Files) {
582 res.Diff.Files[i] = one.Diff.Files[i]585 res.Diff.Files[i] = one.Diff.Files[i]
583 }586 }
584 }587 }
@@ -612,7 +615,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
612 return nil, 0, err615 return nil, 0, err
613 }616 }
614 617
615- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)618+ p.Note = diffNote(res.Diff)
616 countNotes(p.Files, res.Comments)619 countNotes(p.Files, res.Comments)
617 if !res.DiffBaseID.IsZero() {620 if !res.DiffBaseID.IsZero() {
618 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))621 lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
M internal/adapters/web/mr_files.go +8 -3
@@ -219,9 +219,14 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
219219 canWrite := signed && m.State == domain.MRStateOpen && !since
220220 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
221221 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
222- collapseFiles(p.Files, res.Diff.Files, fileLinks{
223- src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String(),
224- })
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+ }
225230 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
226231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
227232 if one {
@@ -219,9 +219,14 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
219 canWrite := signed && m.State == domain.MRStateOpen && !since219 canWrite := signed && m.State == domain.MRStateOpen && !since
220 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))}
221 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- collapseFiles(p.Files, res.Diff.Files, fileLinks{222+ links := fileLinks{src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String()}
223- src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String(),223+ if one {
224- })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+ }
225 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)
226 window := p.Files[from:min(from+mrWindow, len(p.Files))]231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
227 if one {232 if one {
M internal/app/web_commit_test.go +28 -0
@@ -41,6 +41,34 @@ func TestWebCommitGolden(t *testing.T) {
4141 }
4242 }
4343
44+// A commit page reads the hunks of its first 300 files; the others are listed
45+// collapsed and load on their own. A binary file or one without changed lines
46+// past the window has nothing to load and gets no link.
47+func TestWebCommitFileWindow(t *testing.T) {
48+ r := newMRRepo(t, domain.VisibilityPublic)
49+ b := browser{r.harness}
50+ for i := range 305 {
51+ require.NoError(t, os.MkdirAll(filepath.Join(r.wt, "many"), 0o755))
52+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, fmt.Sprintf("many/f%03d.txt", i)), []byte(fmt.Sprintf("line %d\n", i)), 0o644))
53+ }
54+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zbin.bin"), []byte("a\x00b"), 0o644))
55+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zempty.txt"), nil, 0o644))
56+ r.mustGit(r.wt, "add", ".")
57+ r.mustGit(r.wt, "commit", "-q", "-m", "many files")
58+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
59+
60+ page := b.get("/proj/commit/"+r.headOf("feat/search"), nil)
61+ require.Equal(t, 200, page.status)
62+ assert.Contains(t, page.body, "line 299</td>", "index 299 is read")
63+ assert.NotContains(t, page.body, "line 300</td>", "index 300 is collapsed")
64+ assert.Equal(t, 5, strings.Count(page.body, "collapsed to keep the page light"))
65+ assert.Equal(t, 5, strings.Count(page.body, "data-manual"), "binary and empty files past the window have nothing to load")
66+ assert.Contains(t, page.body, `<a href="?file=300#f301">load diff</a>`)
67+ assert.NotContains(t, page.body, `?file=299#`)
68+ assert.NotContains(t, page.body, `?file=305#`)
69+ assert.NotContains(t, page.body, `?file=306#`)
70+}
71+
4472 // The behaviours of the commit pages, asserted on their own.
4573 func TestWebCommit(t *testing.T) {
4674 h := newHarness(t, harnessOpts{})
@@ -41,6 +41,34 @@ func TestWebCommitGolden(t *testing.T) {
41 }41 }
42 }42 }
43 43
44+// A commit page reads the hunks of its first 300 files; the others are listed
45+// collapsed and load on their own. A binary file or one without changed lines
46+// past the window has nothing to load and gets no link.
47+func TestWebCommitFileWindow(t *testing.T) {
48+ r := newMRRepo(t, domain.VisibilityPublic)
49+ b := browser{r.harness}
50+ for i := range 305 {
51+ require.NoError(t, os.MkdirAll(filepath.Join(r.wt, "many"), 0o755))
52+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, fmt.Sprintf("many/f%03d.txt", i)), []byte(fmt.Sprintf("line %d\n", i)), 0o644))
53+ }
54+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zbin.bin"), []byte("a\x00b"), 0o644))
55+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zempty.txt"), nil, 0o644))
56+ r.mustGit(r.wt, "add", ".")
57+ r.mustGit(r.wt, "commit", "-q", "-m", "many files")
58+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
59+
60+ page := b.get("/proj/commit/"+r.headOf("feat/search"), nil)
61+ require.Equal(t, 200, page.status)
62+ assert.Contains(t, page.body, "line 299</td>", "index 299 is read")
63+ assert.NotContains(t, page.body, "line 300</td>", "index 300 is collapsed")
64+ assert.Equal(t, 5, strings.Count(page.body, "collapsed to keep the page light"))
65+ assert.Equal(t, 5, strings.Count(page.body, "data-manual"), "binary and empty files past the window have nothing to load")
66+ assert.Contains(t, page.body, `<a href="?file=300#f301">load diff</a>`)
67+ assert.NotContains(t, page.body, `?file=299#`)
68+ assert.NotContains(t, page.body, `?file=305#`)
69+ assert.NotContains(t, page.body, `?file=306#`)
70+}
71+
44 // The behaviours of the commit pages, asserted on their own.72 // The behaviours of the commit pages, asserted on their own.
45 func TestWebCommit(t *testing.T) {73 func TestWebCommit(t *testing.T) {
46 h := newHarness(t, harnessOpts{})74 h := newHarness(t, harnessOpts{})
M internal/app/web_mr_test.go +29 -1
@@ -944,7 +944,8 @@ func TestWebMergeRequestCollapsedFiles(t *testing.T) {
944944 assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
945945 assert.Contains(t, page.body, "generated file: collapsed")
946946 assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
947- assert.NotContains(t, page.body, "row 5<", "no rows for a collapsed file")
947+ assert.NotRegexp(t, `row \d+`, page.body, "no rows for a collapsed file")
948+ assert.NotContains(t, page.body, "generated line</td>")
948949 assert.Contains(t, page.body, "small line")
949950 assert.NotContains(t, page.body, "this diff is incomplete")
950951 srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
@@ -970,3 +971,30 @@ func TestWebMergeRequestCollapsedFiles(t *testing.T) {
970971 assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
971972 }
972973 }
974+
975+// The page of a window without scripts: ?file=i splices that collapsed file in
976+// and the load link of a window keeps the window.
977+func TestWebMergeRequestWindowFileSplice(t *testing.T) {
978+ r := newMRRepo(t, domain.VisibilityPublic)
979+ b := browser{r.harness}
980+ for i := range 30 {
981+ r.writeFile(r.wt, fmt.Sprintf("a/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i)))
982+ }
983+ r.writeFile(r.wt, "vendor/gen.txt", []byte("generated line\n"))
984+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
985+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Window").ok(t)
986+
987+ // Files sort as a/f00..a/f29, search.go, vendor/gen.txt: the generated
988+ // file is index 31, in the window that starts at 25.
989+ win := b.get("/proj/merge-requests/1?from=25", nil)
990+ require.Equal(t, 200, win.status)
991+ m := regexp.MustCompile(`href="(\?from=25&amp;file=(\d+)#f\d+)"`).FindStringSubmatch(win.body)
992+ require.Len(t, m, 3, "the load link keeps the window")
993+ assert.Equal(t, "31", m[2])
994+ assert.NotContains(t, win.body, "generated line</td>")
995+
996+ own := b.get("/proj/merge-requests/1?from=25&file=31", nil)
997+ require.Equal(t, 200, own.status)
998+ assert.Contains(t, own.body, "generated line</td>", "the file is spliced in")
999+ assert.NotContains(t, own.body, "data-manual")
1000+}
@@ -944,7 +944,8 @@ func TestWebMergeRequestCollapsedFiles(t *testing.T) {
944 assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")944 assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
945 assert.Contains(t, page.body, "generated file: collapsed")945 assert.Contains(t, page.body, "generated file: collapsed")
946 assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")946 assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
947- assert.NotContains(t, page.body, "row 5<", "no rows for a collapsed file")947+ assert.NotRegexp(t, `row \d+`, page.body, "no rows for a collapsed file")
948+ assert.NotContains(t, page.body, "generated line</td>")
948 assert.Contains(t, page.body, "small line")949 assert.Contains(t, page.body, "small line")
949 assert.NotContains(t, page.body, "this diff is incomplete")950 assert.NotContains(t, page.body, "this diff is incomplete")
950 srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)951 srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
@@ -970,3 +971,30 @@ func TestWebMergeRequestCollapsedFiles(t *testing.T) {
970 assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")971 assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
971 }972 }
972 }973 }
974+
975+// The page of a window without scripts: ?file=i splices that collapsed file in
976+// and the load link of a window keeps the window.
977+func TestWebMergeRequestWindowFileSplice(t *testing.T) {
978+ r := newMRRepo(t, domain.VisibilityPublic)
979+ b := browser{r.harness}
980+ for i := range 30 {
981+ r.writeFile(r.wt, fmt.Sprintf("a/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i)))
982+ }
983+ r.writeFile(r.wt, "vendor/gen.txt", []byte("generated line\n"))
984+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
985+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Window").ok(t)
986+
987+ // Files sort as a/f00..a/f29, search.go, vendor/gen.txt: the generated
988+ // file is index 31, in the window that starts at 25.
989+ win := b.get("/proj/merge-requests/1?from=25", nil)
990+ require.Equal(t, 200, win.status)
991+ m := regexp.MustCompile(`href="(\?from=25&amp;file=(\d+)#f\d+)"`).FindStringSubmatch(win.body)
992+ require.Len(t, m, 3, "the load link keeps the window")
993+ assert.Equal(t, "31", m[2])
994+ assert.NotContains(t, win.body, "generated line</td>")
995+
996+ own := b.get("/proj/merge-requests/1?from=25&file=31", nil)
997+ require.Equal(t, 200, own.status)
998+ assert.Contains(t, own.body, "generated line</td>", "the file is spliced in")
999+ assert.NotContains(t, own.body, "data-manual")
1000+}
M internal/domain/budget.go +2 -0
@@ -84,6 +84,8 @@ func (b DiffBudget) Admit(binary bool, path string, changed int) (read bool, why
8484 return false, OmitNone
8585 case b.limits.SkipGenerated && IsGeneratedPath(path):
8686 return false, OmitGenerated
87+ case b.limits.MaxAloneLines > 0 && changed > b.limits.MaxAloneLines:
88+ return false, OmitUnavailable
8789 case b.limits.MaxFileLines > 0 && changed > b.limits.MaxFileLines:
8890 return false, OmitLarge
8991 case b.limits.MaxTotalLines > 0 && changed > b.linesLeft, b.bytesLeft <= 0:
@@ -84,6 +84,8 @@ func (b DiffBudget) Admit(binary bool, path string, changed int) (read bool, why
84 return false, OmitNone84 return false, OmitNone
85 case b.limits.SkipGenerated && IsGeneratedPath(path):85 case b.limits.SkipGenerated && IsGeneratedPath(path):
86 return false, OmitGenerated86 return false, OmitGenerated
87+ case b.limits.MaxAloneLines > 0 && changed > b.limits.MaxAloneLines:
88+ return false, OmitUnavailable
87 case b.limits.MaxFileLines > 0 && changed > b.limits.MaxFileLines:89 case b.limits.MaxFileLines > 0 && changed > b.limits.MaxFileLines:
88 return false, OmitLarge90 return false, OmitLarge
89 case b.limits.MaxTotalLines > 0 && changed > b.linesLeft, b.bytesLeft <= 0:91 case b.limits.MaxTotalLines > 0 && changed > b.linesLeft, b.bytesLeft <= 0:
M internal/domain/budget_test.go +11 -1
@@ -187,7 +187,7 @@ func TestFileOmissionCollapsed(t *testing.T) {
187187
188188 func TestOneFileDiffLimits(t *testing.T) {
189189 l := OneFileDiffLimits(3)
190- if l.From != 3 || l.To != 4 || l.MaxFileLines != 0 || l.MaxTotalLines != 0 || l.MaxFiles != 0 || l.MaxTotalBytes != DefaultDiffLimits().MaxTotalBytes {
190+ if l.From != 3 || l.To != 4 || l.MaxFileLines != 0 || l.MaxAloneLines != MaxOneFileLines || l.MaxTotalLines != 0 || l.MaxFiles != 0 || l.MaxTotalBytes != DefaultDiffLimits().MaxTotalBytes {
191191 t.Fatalf("got %+v", l)
192192 }
193193 // A negative index (an unchecked IndexFunc) selects no file, not the whole diff.
@@ -230,3 +230,13 @@ func TestDiffBudgetContext(t *testing.T) {
230230 t.Fatal("context must not exceed the byte budget")
231231 }
232232 }
233+
234+func TestDiffBudgetAdmitAloneLines(t *testing.T) {
235+ b := NewDiffBudget(OneFileDiffLimits(0))
236+ if read, why := b.Admit(false, "a.go", MaxOneFileLines); !read || why != OmitNone {
237+ t.Fatalf("at the cap: read=%v why=%v", read, why)
238+ }
239+ if read, why := b.Admit(false, "a.go", MaxOneFileLines+1); read || why != OmitUnavailable {
240+ t.Fatalf("over the cap: read=%v why=%v, want OmitUnavailable", read, why)
241+ }
242+}
@@ -187,7 +187,7 @@ func TestFileOmissionCollapsed(t *testing.T) {
187 187
188 func TestOneFileDiffLimits(t *testing.T) {188 func TestOneFileDiffLimits(t *testing.T) {
189 l := OneFileDiffLimits(3)189 l := OneFileDiffLimits(3)
190- if l.From != 3 || l.To != 4 || l.MaxFileLines != 0 || l.MaxTotalLines != 0 || l.MaxFiles != 0 || l.MaxTotalBytes != DefaultDiffLimits().MaxTotalBytes {190+ if l.From != 3 || l.To != 4 || l.MaxFileLines != 0 || l.MaxAloneLines != MaxOneFileLines || l.MaxTotalLines != 0 || l.MaxFiles != 0 || l.MaxTotalBytes != DefaultDiffLimits().MaxTotalBytes {
191 t.Fatalf("got %+v", l)191 t.Fatalf("got %+v", l)
192 }192 }
193 // A negative index (an unchecked IndexFunc) selects no file, not the whole diff.193 // A negative index (an unchecked IndexFunc) selects no file, not the whole diff.
@@ -230,3 +230,13 @@ func TestDiffBudgetContext(t *testing.T) {
230 t.Fatal("context must not exceed the byte budget")230 t.Fatal("context must not exceed the byte budget")
231 }231 }
232 }232 }
233+
234+func TestDiffBudgetAdmitAloneLines(t *testing.T) {
235+ b := NewDiffBudget(OneFileDiffLimits(0))
236+ if read, why := b.Admit(false, "a.go", MaxOneFileLines); !read || why != OmitNone {
237+ t.Fatalf("at the cap: read=%v why=%v", read, why)
238+ }
239+ if read, why := b.Admit(false, "a.go", MaxOneFileLines+1); read || why != OmitUnavailable {
240+ t.Fatalf("over the cap: read=%v why=%v, want OmitUnavailable", read, why)
241+ }
242+}
M internal/domain/git.go +13 -4
@@ -587,19 +587,28 @@ type DiffLimits struct {
587587 // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values
588588 // select every file. MaxFileLines applies to each file of the window.
589589 From, To int
590+ // MaxAloneLines is the most changed lines a file may have to be read at all;
591+ // a file over it is OmitUnavailable (it cannot be shown even alone), unlike
592+ // OmitLarge, which a read of that file alone can load. 0 means no limit.
593+ MaxAloneLines int
590594 // SkipGenerated omits the files of the window that IsGeneratedPath
591595 // recognizes (OmitGenerated), without reading their hunks.
592596 SkipGenerated bool
593597 }
594598
595-// OneFileDiffLimits reads the hunks of file i only, with no line cap and
596-// MaxTotalBytes as the sole guard. i must be at least 0: From -1 with To 0
599+// MaxOneFileLines is the most changed lines of a file read alone on the web
600+// (OneFileDiffLimits). A file over it is OmitUnavailable, so a short-lines
601+// file within the byte guard cannot produce millions of rows.
602+const MaxOneFileLines = 50000
603+
604+// OneFileDiffLimits reads the hunks of file i only, with no MaxFileLines cap;
605+// MaxAloneLines (MaxOneFileLines) and MaxTotalBytes are the guards. i must be at least 0: From -1 with To 0
597606 // would mean the whole diff, so a negative i reads no file at all.
598607 func OneFileDiffLimits(i int) DiffLimits {
599608 if i < 0 {
600- return DiffLimits{From: 1, To: 1, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
609+ return DiffLimits{From: 1, To: 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
601610 }
602- return DiffLimits{From: i, To: i + 1, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
611+ return DiffLimits{From: i, To: i + 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
603612 }
604613
605614 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
@@ -587,19 +587,28 @@ type DiffLimits struct {
587 // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values587 // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values
588 // select every file. MaxFileLines applies to each file of the window.588 // select every file. MaxFileLines applies to each file of the window.
589 From, To int589 From, To int
590+ // MaxAloneLines is the most changed lines a file may have to be read at all;
591+ // a file over it is OmitUnavailable (it cannot be shown even alone), unlike
592+ // OmitLarge, which a read of that file alone can load. 0 means no limit.
593+ MaxAloneLines int
590 // SkipGenerated omits the files of the window that IsGeneratedPath594 // SkipGenerated omits the files of the window that IsGeneratedPath
591 // recognizes (OmitGenerated), without reading their hunks.595 // recognizes (OmitGenerated), without reading their hunks.
592 SkipGenerated bool596 SkipGenerated bool
593 }597 }
594 598
595-// OneFileDiffLimits reads the hunks of file i only, with no line cap and599+// MaxOneFileLines is the most changed lines of a file read alone on the web
596-// MaxTotalBytes as the sole guard. i must be at least 0: From -1 with To 0600+// (OneFileDiffLimits). A file over it is OmitUnavailable, so a short-lines
601+// file within the byte guard cannot produce millions of rows.
602+const MaxOneFileLines = 50000
603+
604+// OneFileDiffLimits reads the hunks of file i only, with no MaxFileLines cap;
605+// MaxAloneLines (MaxOneFileLines) and MaxTotalBytes are the guards. i must be at least 0: From -1 with To 0
597 // would mean the whole diff, so a negative i reads no file at all.606 // would mean the whole diff, so a negative i reads no file at all.
598 func OneFileDiffLimits(i int) DiffLimits {607 func OneFileDiffLimits(i int) DiffLimits {
599 if i < 0 {608 if i < 0 {
600- return DiffLimits{From: 1, To: 1, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}609+ return DiffLimits{From: 1, To: 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
601 }610 }
602- return DiffLimits{From: i, To: i + 1, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}611+ return DiffLimits{From: i, To: i + 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
603 }612 }
604 613
605 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000614 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
M internal/readertest/diff.go +26 -0
@@ -156,4 +156,30 @@ func DiffOmissions(t *testing.T, mk Maker) {
156156 for _, file := range d.Diff.Files {
157157 assert.Equal(t, domain.OmitNone, file.Omitted)
158158 }
159+
160+ // A one-file read is capped at MaxOneFileLines changed lines: the cap
161+ // itself loads, one more line is OmitUnavailable (it cannot be loaded).
162+ f.Lines("cap.txt", "c", domain.MaxOneFileLines).Lines("over.txt", "o", domain.MaxOneFileLines+1).Commit("caps")
163+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1})
164+ require.NoError(t, err)
165+ capIdx, overIdx := -1, -1
166+ for i, file := range d.Diff.Files {
167+ switch file.NewPath {
168+ case "cap.txt":
169+ capIdx = i
170+ case "over.txt":
171+ overIdx = i
172+ }
173+ }
174+ require.GreaterOrEqual(t, capIdx, 0)
175+ require.GreaterOrEqual(t, overIdx, 0)
176+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(capIdx))
177+ require.NoError(t, err)
178+ assert.Equal(t, domain.OmitNone, d.Diff.Files[capIdx].Omitted)
179+ require.Len(t, d.Diff.Files[capIdx].Hunks, 1)
180+ assert.Len(t, d.Diff.Files[capIdx].Hunks[0].Lines, domain.MaxOneFileLines)
181+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(overIdx))
182+ require.NoError(t, err)
183+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[overIdx].Omitted)
184+ assert.Empty(t, d.Diff.Files[overIdx].Hunks)
159185 }
@@ -156,4 +156,30 @@ func DiffOmissions(t *testing.T, mk Maker) {
156 for _, file := range d.Diff.Files {156 for _, file := range d.Diff.Files {
157 assert.Equal(t, domain.OmitNone, file.Omitted)157 assert.Equal(t, domain.OmitNone, file.Omitted)
158 }158 }
159+
160+ // A one-file read is capped at MaxOneFileLines changed lines: the cap
161+ // itself loads, one more line is OmitUnavailable (it cannot be loaded).
162+ f.Lines("cap.txt", "c", domain.MaxOneFileLines).Lines("over.txt", "o", domain.MaxOneFileLines+1).Commit("caps")
163+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1})
164+ require.NoError(t, err)
165+ capIdx, overIdx := -1, -1
166+ for i, file := range d.Diff.Files {
167+ switch file.NewPath {
168+ case "cap.txt":
169+ capIdx = i
170+ case "over.txt":
171+ overIdx = i
172+ }
173+ }
174+ require.GreaterOrEqual(t, capIdx, 0)
175+ require.GreaterOrEqual(t, overIdx, 0)
176+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(capIdx))
177+ require.NoError(t, err)
178+ assert.Equal(t, domain.OmitNone, d.Diff.Files[capIdx].Omitted)
179+ require.Len(t, d.Diff.Files[capIdx].Hunks, 1)
180+ assert.Len(t, d.Diff.Files[capIdx].Hunks[0].Lines, domain.MaxOneFileLines)
181+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(overIdx))
182+ require.NoError(t, err)
183+ assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[overIdx].Omitted)
184+ assert.Empty(t, d.Diff.Files[overIdx].Hunks)
159 }185 }

Keyboard shortcuts

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

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

Branches 2

Tags 42

↑↓ moveenter openesc close