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
M go.mod +1 -1
M go.sum +2 -2
M internal/adapters/gitcli/diff.go +38 -30
M internal/adapters/gitcli/diff_test.go +40 -38
M internal/adapters/rpc/convert.go +17 -1
M internal/adapters/rpc/convert_test.go +18 -0
M internal/adapters/rpc/git.go +1 -1
M internal/adapters/rpc/git_test.go +1 -1
M internal/adapters/web/assets.go +2 -1
M internal/adapters/web/browse.go +2 -2
M internal/adapters/web/commit.go +57 -16
M internal/adapters/web/compare.go +4 -2
M internal/adapters/web/diff.go +109 -5
M internal/adapters/web/diff.templ +10 -1
M internal/adapters/web/diff_highlight.go +2 -2
M internal/adapters/web/diff_highlight_test.go +2 -2
M internal/adapters/web/diff_templ.go +235 -173
M internal/adapters/web/diff_test.go +51 -2
A internal/adapters/web/jstest/lazy.test.mjs +248 -0
M internal/adapters/web/jstest/mr.test.mjs +1 -0
M internal/adapters/web/mr.go +8 -2
M internal/adapters/web/mr_files.go +36 -10
M internal/adapters/web/shell.templ +3 -0
M internal/adapters/web/shell_templ.go +199 -180
M internal/adapters/web/static/css/60-mr.css +3 -1
@@ -191,7 +191,9 @@
191191 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }
192192 /* with scripts the window comes by itself: the link of the page without
193193 scripts would only flash */
194- html.js & > a { visibility: hidden; }
194+ html.js &:not([data-manual]) > a { visibility: hidden; }
195+ &[data-manual] { height: auto; padding: var(--s3); }
196+ &[data-manual][data-loading] > a { opacity: .5; }
195197 & .notice { margin: 0; }
196198 }
197199 .dloose {
@@ -191,7 +191,9 @@
191 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }191 &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; }
192 /* with scripts the window comes by itself: the link of the page without192 /* with scripts the window comes by itself: the link of the page without
193 scripts would only flash */193 scripts would only flash */
194- html.js & > a { visibility: hidden; }194+ html.js &:not([data-manual]) > a { visibility: hidden; }
195+ &[data-manual] { height: auto; padding: var(--s3); }
196+ &[data-manual][data-loading] > a { opacity: .5; }
195 & .notice { margin: 0; }197 & .notice { margin: 0; }
196 }198 }
197 .dloose {199 .dloose {
A internal/adapters/web/static/js/lazy.js +170 -0
@@ -0,0 +1,170 @@
1+// lazy.js: the files of a diff that are not in the page yet (commit, compare
2+// and merge request pages).
3+//
4+// A placeholder is a .dlazy[data-src] in a details.dfile. Two kinds:
5+// - a window of a merge request: loaded by itself when it comes within two
6+// screens of the view (the files of its window replace the placeholders of
7+// the same ids);
8+// - a collapsed file (generated, large) [data-manual]: never loaded by
9+// itself. Its "load diff" link loads that one file (click, or Enter / Space
10+// on the link); without scripts the link reloads the page with the file.
11+// A fragment is fetched once. Nothing observes the DOM: the one
12+// IntersectionObserver watches the automatic placeholders, and a manual one
13+// costs no more than its markup.
14+//
15+// window.ermiteLazy.load is also the loader of mr.js (reveal).
16+window.ermiteLazy = (() => {
17+ const $ = (sel, root = document) => root.querySelector(sel);
18+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
19+ const AUTO = '.dlazy[data-src]:not([data-manual])';
20+ const loading = new Map(); // src -> promise of the swap
21+ let moved = false;
22+ let lazyIO = null; // the observer of the automatic placeholders
23+ let generation = 0; // bumped by reset: older responses are ignored
24+ let onSwap = () => {};
25+
26+ // load fetches the fragment of a placeholder and swaps its files in. The
27+ // promise resolves once they are in the page (or the page said to reload).
28+ function load(ph) {
29+ const gen = generation;
30+ const src = ph?.dataset.src;
31+ if (!src || moved) return Promise.resolve();
32+ if (loading.has(src)) return loading.get(src);
33+ for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
34+ // The fragment renders like the page: the layout shown, and both layouts
35+ // when the page switches them itself (data-diff) or one (data-view).
36+ const d = $('#diffs')?.dataset || {};
37+ const view = d.diff || d.view;
38+ const layouts = d.diff ? 'both' : d.view ? 'one' : '';
39+ const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
40+ const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
41+ .then(async (res) => {
42+ const html = await res.text();
43+ if (gen !== generation) return;
44+ if (res.status === 409) return stale(html);
45+ if (!res.ok) throw new Error('loading files: ' + res.status);
46+ // A fragment of the old diff after the page went stale stays out.
47+ if (!moved) swap(html);
48+ })
49+ .catch((err) => {
50+ if (gen !== generation) return;
51+ loading.delete(src);
52+ for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
53+ window.ermite?.flash?.(err.message, 4000);
54+ });
55+ loading.set(src, p);
56+ return p;
57+ }
58+
59+ // swap replaces each placeholder file with the file of the same id. A
60+ // file the reader folded stays folded.
61+ function swap(html) {
62+ const t = document.createElement('template');
63+ t.innerHTML = html;
64+ for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
65+ const old = document.getElementById(f.id);
66+ if (!old || !old.querySelector('.dlazy')) continue;
67+ // A file still collapsed stays closed; a collapsed file opens once
68+ // loaded; a file of a window keeps the fold.
69+ f.open = f.querySelector('.dlazy[data-manual]') ? false
70+ : old.querySelector('.dlazy[data-manual]') ? true : old.open;
71+ for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
72+ old.replaceWith(f);
73+ window.htmx?.process(f);
74+ window.ermite?.watchFile?.(f, old);
75+ }
76+ onSwap();
77+ }
78+
79+ // stale: the diff changed under the page. The placeholders say so; no file
80+ // of another diff comes in.
81+ function stale(html) {
82+ moved = true;
83+ for (const p of $$('.dlazy')) {
84+ p.removeAttribute('data-loading');
85+ p.innerHTML = html;
86+ }
87+ }
88+
89+ // Automatic placeholders load as they come within two screens of the view;
90+ // files that come in are marked in the list of files (ermite.js marks the
91+ // one in view). Manual ones are left alone.
92+ function watch() {
93+ lazyIO?.disconnect();
94+ if (!('IntersectionObserver' in window)) {
95+ for (const p of $$(AUTO)) load(p);
96+ return;
97+ }
98+ lazyIO = new IntersectionObserver((entries) => {
99+ for (const e of entries) {
100+ if (!e.isIntersecting) continue;
101+ lazyIO.unobserve(e.target);
102+ load(e.target);
103+ }
104+ }, { rootMargin: '200% 0px 200% 0px' });
105+ for (const p of $$(AUTO)) lazyIO.observe(p);
106+ }
107+
108+ // reset forgets the loads in flight and the stale state: the page shows
109+ // another diff (refresh in place). watch starts the observer again.
110+ function reset() {
111+ generation++;
112+ moved = false;
113+ loading.clear();
114+ lazyIO?.disconnect();
115+ }
116+
117+ // manualLink is the "load diff" link of an event target, when it is one that
118+ // loads in place (the placeholder has an address).
119+ const manualLink = (t) => {
120+ const a = t?.closest?.('.dlazy[data-manual] > a');
121+ return a && a.closest('.dlazy')?.dataset.src ? a : null;
122+ };
123+
124+ // loadManual loads the file of a "load diff" link and puts the focus in it:
125+ // on its first line link, else on its header. The scroll never moves for it.
126+ function loadManual(a) {
127+ const ph = a.closest('.dlazy');
128+ const id = ph.closest('details')?.id;
129+ return load(ph).then(() => {
130+ const f = id && document.getElementById(id);
131+ if (!f || f.querySelector('.dlazy[data-manual]')) return;
132+ (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true });
133+ });
134+ }
135+
136+ function onClick(e) {
137+ if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
138+ const a = manualLink(e.target);
139+ if (!a) return false;
140+ e.preventDefault();
141+ loadManual(a);
142+ return true;
143+ }
144+ // Enter makes the browser click a link by itself; Space does not. Both are
145+ // handled here, once (a fragment is fetched once).
146+ function onKey(e) {
147+ // A held key repeats: after the load, focus is on a line link.
148+ if (e.repeat) return false;
149+ if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
150+ const a = manualLink(e.target);
151+ if (!a) return false;
152+ e.preventDefault();
153+ loadManual(a);
154+ return true;
155+ }
156+
157+ let started = false;
158+ function start() {
159+ if (started || !$('#diffs')) return;
160+ started = true;
161+ watch();
162+ document.addEventListener('click', onClick);
163+ document.addEventListener('keydown', onKey);
164+ }
165+
166+ return { load, start, watch, reset, onSwap: (fn) => { onSwap = fn; }, onClick, onKey };
167+})();
168+
169+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start());
170+else window.ermiteLazy.start();
@@ -0,0 +1,170 @@
1+// lazy.js: the files of a diff that are not in the page yet (commit, compare
2+// and merge request pages).
3+//
4+// A placeholder is a .dlazy[data-src] in a details.dfile. Two kinds:
5+// - a window of a merge request: loaded by itself when it comes within two
6+// screens of the view (the files of its window replace the placeholders of
7+// the same ids);
8+// - a collapsed file (generated, large) [data-manual]: never loaded by
9+// itself. Its "load diff" link loads that one file (click, or Enter / Space
10+// on the link); without scripts the link reloads the page with the file.
11+// A fragment is fetched once. Nothing observes the DOM: the one
12+// IntersectionObserver watches the automatic placeholders, and a manual one
13+// costs no more than its markup.
14+//
15+// window.ermiteLazy.load is also the loader of mr.js (reveal).
16+window.ermiteLazy = (() => {
17+ const $ = (sel, root = document) => root.querySelector(sel);
18+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
19+ const AUTO = '.dlazy[data-src]:not([data-manual])';
20+ const loading = new Map(); // src -> promise of the swap
21+ let moved = false;
22+ let lazyIO = null; // the observer of the automatic placeholders
23+ let generation = 0; // bumped by reset: older responses are ignored
24+ let onSwap = () => {};
25+
26+ // load fetches the fragment of a placeholder and swaps its files in. The
27+ // promise resolves once they are in the page (or the page said to reload).
28+ function load(ph) {
29+ const gen = generation;
30+ const src = ph?.dataset.src;
31+ if (!src || moved) return Promise.resolve();
32+ if (loading.has(src)) return loading.get(src);
33+ for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
34+ // The fragment renders like the page: the layout shown, and both layouts
35+ // when the page switches them itself (data-diff) or one (data-view).
36+ const d = $('#diffs')?.dataset || {};
37+ const view = d.diff || d.view;
38+ const layouts = d.diff ? 'both' : d.view ? 'one' : '';
39+ const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
40+ const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
41+ .then(async (res) => {
42+ const html = await res.text();
43+ if (gen !== generation) return;
44+ if (res.status === 409) return stale(html);
45+ if (!res.ok) throw new Error('loading files: ' + res.status);
46+ // A fragment of the old diff after the page went stale stays out.
47+ if (!moved) swap(html);
48+ })
49+ .catch((err) => {
50+ if (gen !== generation) return;
51+ loading.delete(src);
52+ for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
53+ window.ermite?.flash?.(err.message, 4000);
54+ });
55+ loading.set(src, p);
56+ return p;
57+ }
58+
59+ // swap replaces each placeholder file with the file of the same id. A
60+ // file the reader folded stays folded.
61+ function swap(html) {
62+ const t = document.createElement('template');
63+ t.innerHTML = html;
64+ for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
65+ const old = document.getElementById(f.id);
66+ if (!old || !old.querySelector('.dlazy')) continue;
67+ // A file still collapsed stays closed; a collapsed file opens once
68+ // loaded; a file of a window keeps the fold.
69+ f.open = f.querySelector('.dlazy[data-manual]') ? false
70+ : old.querySelector('.dlazy[data-manual]') ? true : old.open;
71+ for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
72+ old.replaceWith(f);
73+ window.htmx?.process(f);
74+ window.ermite?.watchFile?.(f, old);
75+ }
76+ onSwap();
77+ }
78+
79+ // stale: the diff changed under the page. The placeholders say so; no file
80+ // of another diff comes in.
81+ function stale(html) {
82+ moved = true;
83+ for (const p of $$('.dlazy')) {
84+ p.removeAttribute('data-loading');
85+ p.innerHTML = html;
86+ }
87+ }
88+
89+ // Automatic placeholders load as they come within two screens of the view;
90+ // files that come in are marked in the list of files (ermite.js marks the
91+ // one in view). Manual ones are left alone.
92+ function watch() {
93+ lazyIO?.disconnect();
94+ if (!('IntersectionObserver' in window)) {
95+ for (const p of $$(AUTO)) load(p);
96+ return;
97+ }
98+ lazyIO = new IntersectionObserver((entries) => {
99+ for (const e of entries) {
100+ if (!e.isIntersecting) continue;
101+ lazyIO.unobserve(e.target);
102+ load(e.target);
103+ }
104+ }, { rootMargin: '200% 0px 200% 0px' });
105+ for (const p of $$(AUTO)) lazyIO.observe(p);
106+ }
107+
108+ // reset forgets the loads in flight and the stale state: the page shows
109+ // another diff (refresh in place). watch starts the observer again.
110+ function reset() {
111+ generation++;
112+ moved = false;
113+ loading.clear();
114+ lazyIO?.disconnect();
115+ }
116+
117+ // manualLink is the "load diff" link of an event target, when it is one that
118+ // loads in place (the placeholder has an address).
119+ const manualLink = (t) => {
120+ const a = t?.closest?.('.dlazy[data-manual] > a');
121+ return a && a.closest('.dlazy')?.dataset.src ? a : null;
122+ };
123+
124+ // loadManual loads the file of a "load diff" link and puts the focus in it:
125+ // on its first line link, else on its header. The scroll never moves for it.
126+ function loadManual(a) {
127+ const ph = a.closest('.dlazy');
128+ const id = ph.closest('details')?.id;
129+ return load(ph).then(() => {
130+ const f = id && document.getElementById(id);
131+ if (!f || f.querySelector('.dlazy[data-manual]')) return;
132+ (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true });
133+ });
134+ }
135+
136+ function onClick(e) {
137+ if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
138+ const a = manualLink(e.target);
139+ if (!a) return false;
140+ e.preventDefault();
141+ loadManual(a);
142+ return true;
143+ }
144+ // Enter makes the browser click a link by itself; Space does not. Both are
145+ // handled here, once (a fragment is fetched once).
146+ function onKey(e) {
147+ // A held key repeats: after the load, focus is on a line link.
148+ if (e.repeat) return false;
149+ if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
150+ const a = manualLink(e.target);
151+ if (!a) return false;
152+ e.preventDefault();
153+ loadManual(a);
154+ return true;
155+ }
156+
157+ let started = false;
158+ function start() {
159+ if (started || !$('#diffs')) return;
160+ started = true;
161+ watch();
162+ document.addEventListener('click', onClick);
163+ document.addEventListener('keydown', onKey);
164+ }
165+
166+ return { load, start, watch, reset, onSwap: (fn) => { onSwap = fn; }, onClick, onKey };
167+})();
168+
169+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start());
170+else window.ermiteLazy.start();
M internal/adapters/web/static/js/mr.js +8 -91
@@ -1,10 +1,9 @@
11 // mr.js: the merge request page beyond what htmx and hx-live give.
22 //
33 // 1. Windows of files. The page holds the hunks of one window of files; the
4-// other files are placeholders (.dlazy[data-src]). A placeholder that comes
5-// near the screen fetches its window (/files?from=&base=&head=), whose
6-// files replace the placeholders of the same ids. A window is fetched once;
7-// a link to a file or to a comment of a placeholder fetches it first.
4+// other files are placeholders (.dlazy[data-src]), loaded by lazy.js (which
5+// the commit and compare pages use too) when they come near the screen. A
6+// link to a file or to a comment of a placeholder loads it first (reveal).
87 // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu
98 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter
109 // submits (the browser does it: the focus is on the button), Esc closes.
@@ -80,72 +79,10 @@ window.ermiteMR = (() => {
8079
8180 const $ = (sel, root = document) => root.querySelector(sel);
8281 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
83- const loading = new Map(); // src -> promise of the swap
8482 let refreshTouchComments = () => {};
85- let moved = false;
86- let lazyIO = null; // the observer of the placeholders (watch)
87- let diffGeneration = 0;
8883 let revealGen = 0; // the last reveal wins the scroll
89-
90- // load fetches the window of a placeholder and swaps its files in. The
91- // promise resolves once they are in the page (or the page said to reload).
92- function load(ph) {
93- const generation = diffGeneration;
94- const src = ph?.dataset.src;
95- if (!src || moved) return Promise.resolve();
96- if (loading.has(src)) return loading.get(src);
97- for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
98- // The window renders like the page: the layout shown, and both layouts
99- // when the page switches them itself (data-diff) or one (data-view).
100- const d = $('#diffs')?.dataset || {};
101- const view = d.diff || d.view;
102- const layouts = d.diff ? 'both' : d.view ? 'one' : '';
103- const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
104- const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
105- .then(async (res) => {
106- const html = await res.text();
107- if (generation !== diffGeneration) return;
108- if (res.status === 409) return stale(html);
109- if (!res.ok) throw new Error('loading files: ' + res.status);
110- // A window of the old diff after the page went stale stays out.
111- if (!moved) swap(html);
112- })
113- .catch((err) => {
114- if (generation !== diffGeneration) return;
115- loading.delete(src);
116- for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
117- window.ermite?.flash?.(err.message, 4000);
118- });
119- loading.set(src, p);
120- return p;
121- }
122-
123- // swap replaces each placeholder file with the file of the same id. A
124- // file the reader folded stays folded.
125- function swap(html) {
126- const t = document.createElement('template');
127- t.innerHTML = html;
128- for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
129- const old = document.getElementById(f.id);
130- if (!old || !old.querySelector('.dlazy')) continue;
131- f.open = old.open;
132- for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
133- old.replaceWith(f);
134- window.htmx?.process(f);
135- window.ermite?.watchFile?.(f, old);
136- }
137- refreshTouchComments();
138- }
139-
140- // stale: the diff changed under the page. The placeholders say so; no file
141- // of another diff comes in.
142- function stale(html) {
143- moved = true;
144- for (const p of $$('.dlazy')) {
145- p.removeAttribute('data-loading');
146- p.innerHTML = html;
147- }
148- }
84+ const load = (ph) => window.ermiteLazy.load(ph);
85+ window.ermiteLazy.onSwap(() => refreshTouchComments());
14986
15087 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
15188 // a placeholder, then scrolls to it. It returns false when there is nothing
@@ -163,7 +100,7 @@ window.ermiteMR = (() => {
163100 }
164101 return document.getElementById(id);
165102 };
166- let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
103+ let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])');
167104 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
168105 const base = id.replace(/-s$/, '');
169106 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
@@ -195,25 +132,6 @@ window.ermiteMR = (() => {
195132 return true;
196133 }
197134
198- // Placeholders load as they come within two screens of the view; files
199- // that come in are marked in the list of files (ermite.js marks the one in
200- // view).
201- function watch() {
202- lazyIO?.disconnect();
203- if (!('IntersectionObserver' in window)) {
204- for (const p of $$('.dlazy[data-src]')) load(p);
205- return;
206- }
207- lazyIO = new IntersectionObserver((entries) => {
208- for (const e of entries) {
209- if (!e.isIntersecting) continue;
210- lazyIO.unobserve(e.target);
211- load(e.target);
212- }
213- }, { rootMargin: '200% 0px 200% 0px' });
214- for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
215- }
216-
217135 // The merge menu.
218136 const menu = () => $('#merge-menu');
219137 function openMerge() {
@@ -598,7 +516,7 @@ window.ermiteMR = (() => {
598516 const active = document.activeElement;
599517 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
600518 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
601- ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();
519+ ++revealGen; window.ermiteLazy.reset();
602520 $('#diffs', page).dataset.mrStarted = 'true';
603521 // The fresh footer comes in with the panels as they are, so that an open
604522 // panel does not fold and open again while the other regions swap.
@@ -624,7 +542,7 @@ window.ermiteMR = (() => {
624542 touchComments(false);
625543 $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);
626544 window.ermite?.refreshFiles?.();
627- watch();
545+ window.ermiteLazy.watch();
628546 if (focus && active !== document.body) {
629547 const region = focus.region ? $(focus.region) : document;
630548 const menu = focus.menu && document.getElementById(focus.menu);
@@ -700,7 +618,6 @@ window.ermiteMR = (() => {
700618 // the address of the request, so that a reload does not post again.
701619 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
702620 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
703- watch();
704621 // A link to a file or a comment that is not loaded yet.
705622 const hash = () => {
706623 const id = decodeURIComponent(location.hash.slice(1));
@@ -1,10 +1,9 @@
1 // mr.js: the merge request page beyond what htmx and hx-live give.1 // mr.js: the merge request page beyond what htmx and hx-live give.
2 //2 //
3 // 1. Windows of files. The page holds the hunks of one window of files; the3 // 1. Windows of files. The page holds the hunks of one window of files; the
4-// other files are placeholders (.dlazy[data-src]). A placeholder that comes4+// other files are placeholders (.dlazy[data-src]), loaded by lazy.js (which
5-// near the screen fetches its window (/files?from=&base=&head=), whose5+// the commit and compare pages use too) when they come near the screen. A
6-// files replace the placeholders of the same ids. A window is fetched once;6+// link to a file or to a comment of a placeholder loads it first (reveal).
7-// a link to a file or to a comment of a placeholder fetches it first.
8 // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu7 // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu
9 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter8 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter
10 // submits (the browser does it: the focus is on the button), Esc closes.9 // submits (the browser does it: the focus is on the button), Esc closes.
@@ -80,72 +79,10 @@ window.ermiteMR = (() => {
80 79
81 const $ = (sel, root = document) => root.querySelector(sel);80 const $ = (sel, root = document) => root.querySelector(sel);
82 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];81 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
83- const loading = new Map(); // src -> promise of the swap
84 let refreshTouchComments = () => {};82 let refreshTouchComments = () => {};
85- let moved = false;
86- let lazyIO = null; // the observer of the placeholders (watch)
87- let diffGeneration = 0;
88 let revealGen = 0; // the last reveal wins the scroll83 let revealGen = 0; // the last reveal wins the scroll
89-84+ const load = (ph) => window.ermiteLazy.load(ph);
90- // load fetches the window of a placeholder and swaps its files in. The85+ window.ermiteLazy.onSwap(() => refreshTouchComments());
91- // promise resolves once they are in the page (or the page said to reload).
92- function load(ph) {
93- const generation = diffGeneration;
94- const src = ph?.dataset.src;
95- if (!src || moved) return Promise.resolve();
96- if (loading.has(src)) return loading.get(src);
97- for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
98- // The window renders like the page: the layout shown, and both layouts
99- // when the page switches them itself (data-diff) or one (data-view).
100- const d = $('#diffs')?.dataset || {};
101- const view = d.diff || d.view;
102- const layouts = d.diff ? 'both' : d.view ? 'one' : '';
103- const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
104- const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
105- .then(async (res) => {
106- const html = await res.text();
107- if (generation !== diffGeneration) return;
108- if (res.status === 409) return stale(html);
109- if (!res.ok) throw new Error('loading files: ' + res.status);
110- // A window of the old diff after the page went stale stays out.
111- if (!moved) swap(html);
112- })
113- .catch((err) => {
114- if (generation !== diffGeneration) return;
115- loading.delete(src);
116- for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
117- window.ermite?.flash?.(err.message, 4000);
118- });
119- loading.set(src, p);
120- return p;
121- }
122-
123- // swap replaces each placeholder file with the file of the same id. A
124- // file the reader folded stays folded.
125- function swap(html) {
126- const t = document.createElement('template');
127- t.innerHTML = html;
128- for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
129- const old = document.getElementById(f.id);
130- if (!old || !old.querySelector('.dlazy')) continue;
131- f.open = old.open;
132- for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
133- old.replaceWith(f);
134- window.htmx?.process(f);
135- window.ermite?.watchFile?.(f, old);
136- }
137- refreshTouchComments();
138- }
139-
140- // stale: the diff changed under the page. The placeholders say so; no file
141- // of another diff comes in.
142- function stale(html) {
143- moved = true;
144- for (const p of $$('.dlazy')) {
145- p.removeAttribute('data-loading');
146- p.innerHTML = html;
147- }
148- }
149 86
150 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is87 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
151 // a placeholder, then scrolls to it. It returns false when there is nothing88 // a placeholder, then scrolls to it. It returns false when there is nothing
@@ -163,7 +100,7 @@ window.ermiteMR = (() => {
163 }100 }
164 return document.getElementById(id);101 return document.getElementById(id);
165 };102 };
166- let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');103+ let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])');
167 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {104 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
168 const base = id.replace(/-s$/, '');105 const base = id.replace(/-s$/, '');
169 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));106 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
@@ -195,25 +132,6 @@ window.ermiteMR = (() => {
195 return true;132 return true;
196 }133 }
197 134
198- // Placeholders load as they come within two screens of the view; files
199- // that come in are marked in the list of files (ermite.js marks the one in
200- // view).
201- function watch() {
202- lazyIO?.disconnect();
203- if (!('IntersectionObserver' in window)) {
204- for (const p of $$('.dlazy[data-src]')) load(p);
205- return;
206- }
207- lazyIO = new IntersectionObserver((entries) => {
208- for (const e of entries) {
209- if (!e.isIntersecting) continue;
210- lazyIO.unobserve(e.target);
211- load(e.target);
212- }
213- }, { rootMargin: '200% 0px 200% 0px' });
214- for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
215- }
216-
217 // The merge menu.135 // The merge menu.
218 const menu = () => $('#merge-menu');136 const menu = () => $('#merge-menu');
219 function openMerge() {137 function openMerge() {
@@ -598,7 +516,7 @@ window.ermiteMR = (() => {
598 const active = document.activeElement;516 const active = document.activeElement;
599 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };517 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
600 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);518 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
601- ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();519+ ++revealGen; window.ermiteLazy.reset();
602 $('#diffs', page).dataset.mrStarted = 'true';520 $('#diffs', page).dataset.mrStarted = 'true';
603 // The fresh footer comes in with the panels as they are, so that an open521 // The fresh footer comes in with the panels as they are, so that an open
604 // panel does not fold and open again while the other regions swap.522 // panel does not fold and open again while the other regions swap.
@@ -624,7 +542,7 @@ window.ermiteMR = (() => {
624 touchComments(false);542 touchComments(false);
625 $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);543 $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);
626 window.ermite?.refreshFiles?.();544 window.ermite?.refreshFiles?.();
627- watch();545+ window.ermiteLazy.watch();
628 if (focus && active !== document.body) {546 if (focus && active !== document.body) {
629 const region = focus.region ? $(focus.region) : document;547 const region = focus.region ? $(focus.region) : document;
630 const menu = focus.menu && document.getElementById(focus.menu);548 const menu = focus.menu && document.getElementById(focus.menu);
@@ -700,7 +618,6 @@ window.ermiteMR = (() => {
700 // the address of the request, so that a reload does not post again.618 // the address of the request, so that a reload does not post again.
701 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;619 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
702 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);620 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
703- watch();
704 // A link to a file or a comment that is not loaded yet.621 // A link to a file or a comment that is not loaded yet.
705 const hash = () => {622 const hash = () => {
706 const id = decodeURIComponent(location.hash.slice(1));623 const id = decodeURIComponent(location.hash.slice(1));
M internal/adapters/web/view.go +3 -0
@@ -67,6 +67,9 @@ type Layout struct {
6767 // Passkeys tells that the passkey routes exist: the header then links to
6868 // them.
6969 Passkeys bool
70+ // Lazy loads the loader of the collapsed and lazy files of a diff
71+ // (lazy.js): the commit, compare and merge request pages.
72+ Lazy bool
7073 // MR loads the script of the merge request page (mr.js).
7174 MR bool
7275 // IssueForm loads the script of the public issue form (issue-form.js).
@@ -67,6 +67,9 @@ type Layout struct {
67 // Passkeys tells that the passkey routes exist: the header then links to67 // Passkeys tells that the passkey routes exist: the header then links to
68 // them.68 // them.
69 Passkeys bool69 Passkeys bool
70+ // Lazy loads the loader of the collapsed and lazy files of a diff
71+ // (lazy.js): the commit, compare and merge request pages.
72+ Lazy bool
70 // MR loads the script of the merge request page (mr.js).73 // MR loads the script of the merge request page (mr.js).
71 MR bool74 MR bool
72 // IssueForm loads the script of the public issue form (issue-form.js).75 // IssueForm loads the script of the public issue form (issue-form.js).
M internal/adapters/web/web.go +1 -0
@@ -207,6 +207,7 @@ func (s *Server) routes() *http.ServeMux {
207207 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
208208 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
209209 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
210+ mux.HandleFunc("GET /{repo}/commit/{sha}/files", s.page(s.commitFiles))
210211 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
211212 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
212213 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
@@ -207,6 +207,7 @@ func (s *Server) routes() *http.ServeMux {
207 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))207 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
208 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))208 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
209 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))209 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
210+ mux.HandleFunc("GET /{repo}/commit/{sha}/files", s.page(s.commitFiles))
210 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))211 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
211 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))212 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
212 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))213 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
M internal/app/cli_mr.go +21 -10
@@ -470,14 +470,16 @@ type jsonHunk struct {
470470 }
471471
472472 type jsonFile struct {
473- OldPath string `json:"old_path"`
474- NewPath string `json:"new_path"`
475- Status string `json:"status"`
476- Additions int32 `json:"additions"`
477- Deletions int32 `json:"deletions"`
478- Binary bool `json:"binary"`
479- Truncated bool `json:"truncated"`
480- Hunks []jsonHunk `json:"hunks,omitempty"`
473+ OldPath string `json:"old_path"`
474+ NewPath string `json:"new_path"`
475+ Status string `json:"status"`
476+ Additions int32 `json:"additions"`
477+ Deletions int32 `json:"deletions"`
478+ Binary bool `json:"binary"`
479+ Truncated bool `json:"truncated"`
480+ // Omitted is generated, large, budget or unavailable; absent when the hunks are not omitted.
481+ Omitted string `json:"omitted,omitempty"`
482+ Hunks []jsonHunk `json:"hunks,omitempty"`
481483 }
482484
483485 type jsonCommit struct {
@@ -516,6 +518,14 @@ func fileStatusName(s ermite.FileStatus) string {
516518 return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_"))
517519 }
518520
521+// fileOmissionName is the lowercase reason of an omitted file, "" for none.
522+func fileOmissionName(o ermite.FileOmission) string {
523+ if o == ermite.FileOmission_FILE_OMISSION_UNSPECIFIED {
524+ return ""
525+ }
526+ return strings.ToLower(strings.TrimPrefix(o.String(), "FILE_OMISSION_"))
527+}
528+
519529 func lineKindName(k ermite.LineKind) string {
520530 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
521531 }
@@ -540,6 +550,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri
540550 jf := jsonFile{
541551 OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()),
542552 Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(),
553+ Omitted: fileOmissionName(f.GetOmitted()),
543554 }
544555 if withDiff {
545556 for _, h := range f.GetHunks() {
@@ -581,8 +592,8 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
581592 "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +
582593 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
583594 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
584- "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
585- "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
595+ "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated, omitted (generated, large, budget or unavailable; absent when the hunks are\n" +
596+ "not omitted) and, with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
586597 "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
587598 Args: cobra.ExactArgs(2),
588599 ValidArgsFunction: completeMRRepoThenNumber(env),
@@ -470,14 +470,16 @@ type jsonHunk struct {
470 }470 }
471 471
472 type jsonFile struct {472 type jsonFile struct {
473- OldPath string `json:"old_path"`473+ OldPath string `json:"old_path"`
474- NewPath string `json:"new_path"`474+ NewPath string `json:"new_path"`
475- Status string `json:"status"`475+ Status string `json:"status"`
476- Additions int32 `json:"additions"`476+ Additions int32 `json:"additions"`
477- Deletions int32 `json:"deletions"`477+ Deletions int32 `json:"deletions"`
478- Binary bool `json:"binary"`478+ Binary bool `json:"binary"`
479- Truncated bool `json:"truncated"`479+ Truncated bool `json:"truncated"`
480- Hunks []jsonHunk `json:"hunks,omitempty"`480+ // Omitted is generated, large, budget or unavailable; absent when the hunks are not omitted.
481+ Omitted string `json:"omitted,omitempty"`
482+ Hunks []jsonHunk `json:"hunks,omitempty"`
481 }483 }
482 484
483 type jsonCommit struct {485 type jsonCommit struct {
@@ -516,6 +518,14 @@ func fileStatusName(s ermite.FileStatus) string {
516 return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_"))518 return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_"))
517 }519 }
518 520
521+// fileOmissionName is the lowercase reason of an omitted file, "" for none.
522+func fileOmissionName(o ermite.FileOmission) string {
523+ if o == ermite.FileOmission_FILE_OMISSION_UNSPECIFIED {
524+ return ""
525+ }
526+ return strings.ToLower(strings.TrimPrefix(o.String(), "FILE_OMISSION_"))
527+}
528+
519 func lineKindName(k ermite.LineKind) string {529 func lineKindName(k ermite.LineKind) string {
520 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))530 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
521 }531 }
@@ -540,6 +550,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri
540 jf := jsonFile{550 jf := jsonFile{
541 OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()),551 OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()),
542 Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(),552 Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(),
553+ Omitted: fileOmissionName(f.GetOmitted()),
543 }554 }
544 if withDiff {555 if withDiff {
545 for _, h := range f.GetHunks() {556 for _, h := range f.GetHunks() {
@@ -581,8 +592,8 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
581 "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +592 "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +
582 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +593 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
583 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +594 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
584- "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +595+ "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated, omitted (generated, large, budget or unavailable; absent when the hunks are\n" +
585- "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +596+ "not omitted) and, with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
586 "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",597 "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
587 Args: cobra.ExactArgs(2),598 Args: cobra.ExactArgs(2),
588 ValidArgsFunction: completeMRRepoThenNumber(env),599 ValidArgsFunction: completeMRRepoThenNumber(env),
M internal/app/cli_mr_test.go +15 -0
@@ -563,3 +563,18 @@ func TestMRWebURL(t *testing.T) {
563563 t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })
564564 }
565565 }
566+
567+func TestJSONFileOmitted(t *testing.T) {
568+ d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Diff: &ermite.Diff{Files: []*ermite.FileDiff{
569+ {NewPath: "a"},
570+ {NewPath: "go.sum", Omitted: ermite.FileOmission_FILE_OMISSION_GENERATED, Truncated: true},
571+ {NewPath: "b", Omitted: ermite.FileOmission_FILE_OMISSION_UNAVAILABLE, Truncated: true},
572+ }}}
573+ got := toJSONDetail(d, false, "https://git.example.org")
574+ assert.Equal(t, []string{"", "generated", "unavailable"}, []string{got.Files[0].Omitted, got.Files[1].Omitted, got.Files[2].Omitted})
575+ raw, err := json.Marshal(got.Files)
576+ require.NoError(t, err)
577+ assert.Contains(t, string(raw), `"omitted":"generated"`)
578+ assert.Contains(t, string(raw), `"truncated":true`)
579+ assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`))
580+}
@@ -563,3 +563,18 @@ func TestMRWebURL(t *testing.T) {
563 t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })563 t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })
564 }564 }
565 }565 }
566+
567+func TestJSONFileOmitted(t *testing.T) {
568+ d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Diff: &ermite.Diff{Files: []*ermite.FileDiff{
569+ {NewPath: "a"},
570+ {NewPath: "go.sum", Omitted: ermite.FileOmission_FILE_OMISSION_GENERATED, Truncated: true},
571+ {NewPath: "b", Omitted: ermite.FileOmission_FILE_OMISSION_UNAVAILABLE, Truncated: true},
572+ }}}
573+ got := toJSONDetail(d, false, "https://git.example.org")
574+ assert.Equal(t, []string{"", "generated", "unavailable"}, []string{got.Files[0].Omitted, got.Files[1].Omitted, got.Files[2].Omitted})
575+ raw, err := json.Marshal(got.Files)
576+ require.NoError(t, err)
577+ assert.Contains(t, string(raw), `"omitted":"generated"`)
578+ assert.Contains(t, string(raw), `"truncated":true`)
579+ assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`))
580+}
M internal/app/testdata/web/commit-big.html +6 -3
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -141,7 +143,6 @@
141143 </div>
142144 </details>
143145 </div>
144-<p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>
145146 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
146147 <details class="dfile lnw-2" id="f1" data-file="big.txt">
147148 <summary>
@@ -149,9 +150,11 @@
149150 <span class="p">big.txt</span>
150151 <span class="plus">+2600</span>
151152 <span class="minus">-0</span>
152-<span class="note">diff too large to show (2600 lines changed): collapsed</span>
153+<span class="note">large diff (2600 lines changed): collapsed</span>
153154 </summary>
154-<p class="note pad">diff too large to show (2600 lines changed): collapsed</p>
155+<div class="dlazy" data-src="/diffs/commit/SHA40/files?one=1&amp;from=0" data-manual>
156+<a href="?file=0#f1">load diff</a>
157+</div>
155158 </details>
156159 </div>
157160 </main>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
@@ -141,7 +143,6 @@
141 </div>143 </div>
142 </details>144 </details>
143 </div>145 </div>
144-<p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>
145 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">146 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
146 <details class="dfile lnw-2" id="f1" data-file="big.txt">147 <details class="dfile lnw-2" id="f1" data-file="big.txt">
147 <summary>148 <summary>
@@ -149,9 +150,11 @@
149 <span class="p">big.txt</span>150 <span class="p">big.txt</span>
150 <span class="plus">+2600</span>151 <span class="plus">+2600</span>
151 <span class="minus">-0</span>152 <span class="minus">-0</span>
152-<span class="note">diff too large to show (2600 lines changed): collapsed</span>153+<span class="note">large diff (2600 lines changed): collapsed</span>
153 </summary>154 </summary>
154-<p class="note pad">diff too large to show (2600 lines changed): collapsed</p>155+<div class="dlazy" data-src="/diffs/commit/SHA40/files?one=1&amp;from=0" data-manual>
156+<a href="?file=0#f1">load diff</a>
157+</div>
155 </details>158 </details>
156 </div>159 </div>
157 </main>160 </main>
M internal/app/testdata/web/commit-binary.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-merge.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify-split.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-rename.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-root.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-trojan.html +2 -0
@@ -30,6 +30,8 @@
3030 </script>
3131 <script src="/-/static/js/log.HASH.js" defer>
3232 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
3335 </head>
3436 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
3537 <a class="skip" href="#main">skip to content</a>
@@ -30,6 +30,8 @@
30 </script>30 </script>
31 <script src="/-/static/js/log.HASH.js" defer>31 <script src="/-/static/js/log.HASH.js" defer>
32 </script>32 </script>
33+<script src="/-/static/js/lazy.HASH.js" defer>
34+</script>
33 </head>35 </head>
34 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">36 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
35 <a class="skip" href="#main">skip to content</a>37 <a class="skip" href="#main">skip to content</a>
M internal/app/web_commit_test.go +63 -5
@@ -41,6 +41,34 @@ func TestWebCommitGolden(t *testing.T) {
4141 }
4242 }
4343
44+// A commit page reads the hunks of its first 300 files; the others are listed
45+// collapsed and load on their own. A binary file or one without changed lines
46+// past the window has nothing to load and gets no link.
47+func TestWebCommitFileWindow(t *testing.T) {
48+ r := newMRRepo(t, domain.VisibilityPublic)
49+ b := browser{r.harness}
50+ for i := range 305 {
51+ require.NoError(t, os.MkdirAll(filepath.Join(r.wt, "many"), 0o755))
52+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, fmt.Sprintf("many/f%03d.txt", i)), []byte(fmt.Sprintf("line %d\n", i)), 0o644))
53+ }
54+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zbin.bin"), []byte("a\x00b"), 0o644))
55+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zempty.txt"), nil, 0o644))
56+ r.mustGit(r.wt, "add", ".")
57+ r.mustGit(r.wt, "commit", "-q", "-m", "many files")
58+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
59+
60+ page := b.get("/proj/commit/"+r.headOf("feat/search"), nil)
61+ require.Equal(t, 200, page.status)
62+ assert.Contains(t, page.body, "line 299</td>", "index 299 is read")
63+ assert.NotContains(t, page.body, "line 300</td>", "index 300 is collapsed")
64+ assert.Equal(t, 5, strings.Count(page.body, "collapsed to keep the page light"))
65+ assert.Equal(t, 5, strings.Count(page.body, "data-manual"), "binary and empty files past the window have nothing to load")
66+ assert.Contains(t, page.body, `<a href="?file=300#f301">load diff</a>`)
67+ assert.NotContains(t, page.body, `?file=299#`)
68+ assert.NotContains(t, page.body, `?file=305#`)
69+ assert.NotContains(t, page.body, `?file=306#`)
70+}
71+
4472 // The behaviours of the commit pages, asserted on their own.
4573 func TestWebCommit(t *testing.T) {
4674 h := newHarness(t, harnessOpts{})
@@ -112,12 +140,42 @@ func TestWebCommit(t *testing.T) {
112140 assert.Contains(t, body, "holds invisible characters")
113141 })
114142
115- t.Run("truncated", func(t *testing.T) {
143+ t.Run("large file is collapsed and loads on its own", func(t *testing.T) {
116144 body := get("/diffs/commit/"+f.big, 200)
117- assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed")
118- assert.Contains(t, body, "this diff is incomplete")
119- assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed")
120- assert.NotContains(t, body, "line 2000", "no lines of a truncated file")
145+ assert.Contains(t, body, "large diff (2600 lines changed): collapsed")
146+ assert.NotContains(t, body, "this diff is incomplete", "a collapsed file is not a global note")
147+ assert.NotRegexp(t, `<details class="dfile[^"]*" id="f1"[^>]* open`, body, "a collapsed file is closed")
148+ assert.Contains(t, body, `<div class="dlazy" data-src="/diffs/commit/`+f.big+`/files?one=1&amp;from=0" data-manual>`)
149+ assert.Contains(t, body, `<a href="?file=0#f1">load diff</a>`)
150+ assert.NotContains(t, body, "line 2000", "no rows for a collapsed file")
151+
152+ // One file, in full, over the 2,000 lines of the page.
153+ frag := get("/diffs/commit/"+f.big+"/files?one=1&from=0", 200)
154+ assert.NotContains(t, frag, "<html")
155+ assert.Equal(t, 1, strings.Count(frag, `<details class="dfile`))
156+ assert.Contains(t, frag, "line 2599")
157+ assert.NotContains(t, frag, "data-manual")
158+ assert.Contains(t, frag, `id="f1"`)
159+ both := get("/diffs/commit/"+f.big+"/files?one=1&from=0&view=split&layouts=one", 200)
160+ assert.NotContains(t, both, `class="dt uni"`)
161+ assert.Contains(t, both, `class="dt spl"`)
162+
163+ // Without scripts: the page splices the file in.
164+ page := get("/diffs/commit/"+f.big+"?file=0", 200)
165+ assert.Contains(t, page, "line 2599")
166+ assert.NotContains(t, page, "data-manual")
167+ assert.Contains(t, get("/diffs/commit/"+f.big+"?file=9", 200), "data-manual", "out of range: ignored")
168+ })
169+
170+ t.Run("one file route", func(t *testing.T) {
171+ for _, p := range []string{
172+ "/files?one=1&from=1", "/files?one=1&from=-1", "/files?one=1&from=x", "/files?from=0", "/files?one=1",
173+ } {
174+ st, _, _ := h.get("/diffs/commit/" + f.big + p)
175+ assert.Equal(t, 404, st, p)
176+ }
177+ st, _, _ := h.get("/diffs/commit/main/files?one=1&from=0")
178+ assert.Equal(t, 404, st)
121179 })
122180
123181 t.Run("not found", func(t *testing.T) {
@@ -41,6 +41,34 @@ func TestWebCommitGolden(t *testing.T) {
41 }41 }
42 }42 }
43 43
44+// A commit page reads the hunks of its first 300 files; the others are listed
45+// collapsed and load on their own. A binary file or one without changed lines
46+// past the window has nothing to load and gets no link.
47+func TestWebCommitFileWindow(t *testing.T) {
48+ r := newMRRepo(t, domain.VisibilityPublic)
49+ b := browser{r.harness}
50+ for i := range 305 {
51+ require.NoError(t, os.MkdirAll(filepath.Join(r.wt, "many"), 0o755))
52+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, fmt.Sprintf("many/f%03d.txt", i)), []byte(fmt.Sprintf("line %d\n", i)), 0o644))
53+ }
54+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zbin.bin"), []byte("a\x00b"), 0o644))
55+ require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zempty.txt"), nil, 0o644))
56+ r.mustGit(r.wt, "add", ".")
57+ r.mustGit(r.wt, "commit", "-q", "-m", "many files")
58+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
59+
60+ page := b.get("/proj/commit/"+r.headOf("feat/search"), nil)
61+ require.Equal(t, 200, page.status)
62+ assert.Contains(t, page.body, "line 299</td>", "index 299 is read")
63+ assert.NotContains(t, page.body, "line 300</td>", "index 300 is collapsed")
64+ assert.Equal(t, 5, strings.Count(page.body, "collapsed to keep the page light"))
65+ assert.Equal(t, 5, strings.Count(page.body, "data-manual"), "binary and empty files past the window have nothing to load")
66+ assert.Contains(t, page.body, `<a href="?file=300#f301">load diff</a>`)
67+ assert.NotContains(t, page.body, `?file=299#`)
68+ assert.NotContains(t, page.body, `?file=305#`)
69+ assert.NotContains(t, page.body, `?file=306#`)
70+}
71+
44 // The behaviours of the commit pages, asserted on their own.72 // The behaviours of the commit pages, asserted on their own.
45 func TestWebCommit(t *testing.T) {73 func TestWebCommit(t *testing.T) {
46 h := newHarness(t, harnessOpts{})74 h := newHarness(t, harnessOpts{})
@@ -112,12 +140,42 @@ func TestWebCommit(t *testing.T) {
112 assert.Contains(t, body, "holds invisible characters")140 assert.Contains(t, body, "holds invisible characters")
113 })141 })
114 142
115- t.Run("truncated", func(t *testing.T) {143+ t.Run("large file is collapsed and loads on its own", func(t *testing.T) {
116 body := get("/diffs/commit/"+f.big, 200)144 body := get("/diffs/commit/"+f.big, 200)
117- assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed")145+ assert.Contains(t, body, "large diff (2600 lines changed): collapsed")
118- assert.Contains(t, body, "this diff is incomplete")146+ assert.NotContains(t, body, "this diff is incomplete", "a collapsed file is not a global note")
119- assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed")147+ assert.NotRegexp(t, `<details class="dfile[^"]*" id="f1"[^>]* open`, body, "a collapsed file is closed")
120- assert.NotContains(t, body, "line 2000", "no lines of a truncated file")148+ assert.Contains(t, body, `<div class="dlazy" data-src="/diffs/commit/`+f.big+`/files?one=1&amp;from=0" data-manual>`)
149+ assert.Contains(t, body, `<a href="?file=0#f1">load diff</a>`)
150+ assert.NotContains(t, body, "line 2000", "no rows for a collapsed file")
151+
152+ // One file, in full, over the 2,000 lines of the page.
153+ frag := get("/diffs/commit/"+f.big+"/files?one=1&from=0", 200)
154+ assert.NotContains(t, frag, "<html")
155+ assert.Equal(t, 1, strings.Count(frag, `<details class="dfile`))
156+ assert.Contains(t, frag, "line 2599")
157+ assert.NotContains(t, frag, "data-manual")
158+ assert.Contains(t, frag, `id="f1"`)
159+ both := get("/diffs/commit/"+f.big+"/files?one=1&from=0&view=split&layouts=one", 200)
160+ assert.NotContains(t, both, `class="dt uni"`)
161+ assert.Contains(t, both, `class="dt spl"`)
162+
163+ // Without scripts: the page splices the file in.
164+ page := get("/diffs/commit/"+f.big+"?file=0", 200)
165+ assert.Contains(t, page, "line 2599")
166+ assert.NotContains(t, page, "data-manual")
167+ assert.Contains(t, get("/diffs/commit/"+f.big+"?file=9", 200), "data-manual", "out of range: ignored")
168+ })
169+
170+ t.Run("one file route", func(t *testing.T) {
171+ for _, p := range []string{
172+ "/files?one=1&from=1", "/files?one=1&from=-1", "/files?one=1&from=x", "/files?from=0", "/files?one=1",
173+ } {
174+ st, _, _ := h.get("/diffs/commit/" + f.big + p)
175+ assert.Equal(t, 404, st, p)
176+ }
177+ st, _, _ := h.get("/diffs/commit/main/files?one=1&from=0")
178+ assert.Equal(t, 404, st)
121 })179 })
122 180
123 t.Run("not found", func(t *testing.T) {181 t.Run("not found", func(t *testing.T) {
M internal/app/web_mr_test.go +77 -0
@@ -921,3 +921,80 @@ func TestWebMergeRequestMergeabilityLive(t *testing.T) {
921921 break
922922 }
923923 }
924+
925+// Collapsed files of a merge request: a generated file and a file over the
926+// line cap are listed closed, with no rows and a manual placeholder; /files
927+// with one=1 reads exactly one file in full, whatever its index; the page
928+// without scripts (?file=i) splices the file in.
929+func TestWebMergeRequestCollapsedFiles(t *testing.T) {
930+ r := newMRRepo(t, domain.VisibilityPublic)
931+ b := browser{r.harness}
932+ var big strings.Builder
933+ for i := range 2600 {
934+ fmt.Fprintf(&big, "row %d\n", i)
935+ }
936+ r.writeFile(r.wt, "zz/big.txt", []byte(big.String()))
937+ r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n"))
938+ r.writeFile(r.wt, "zz/small.txt", []byte("small line\n"))
939+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
940+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t)
941+
942+ page := b.get("/proj/merge-requests/1", nil)
943+ require.Equal(t, 200, page.status)
944+ assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
945+ assert.Contains(t, page.body, "generated file: collapsed")
946+ assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
947+ assert.NotRegexp(t, `row \d+`, page.body, "no rows for a collapsed file")
948+ assert.NotContains(t, page.body, "generated line</td>")
949+ assert.Contains(t, page.body, "small line")
950+ assert.NotContains(t, page.body, "this diff is incomplete")
951+ srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
952+ require.Len(t, srcs, 2)
953+
954+ for _, m := range srcs {
955+ one := b.get(html.UnescapeString(m[1]), nil)
956+ require.Equal(t, 200, one.status, one.body)
957+ assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file")
958+ assert.NotContains(t, one.body, "data-manual")
959+ }
960+ bigOne := b.get(html.UnescapeString(srcs[1][1]), nil)
961+ genOne := b.get(html.UnescapeString(srcs[0][1]), nil)
962+ assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599"))
963+ assert.Contains(t, bigOne.body+genOne.body, "generated line")
964+
965+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status)
966+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status)
967+
968+ for _, m := range srcs {
969+ own := b.get("/proj/merge-requests/1?file="+m[2], nil)
970+ require.Equal(t, 200, own.status)
971+ assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
972+ }
973+}
974+
975+// The page of a window without scripts: ?file=i splices that collapsed file in
976+// and the load link of a window keeps the window.
977+func TestWebMergeRequestWindowFileSplice(t *testing.T) {
978+ r := newMRRepo(t, domain.VisibilityPublic)
979+ b := browser{r.harness}
980+ for i := range 30 {
981+ r.writeFile(r.wt, fmt.Sprintf("a/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i)))
982+ }
983+ r.writeFile(r.wt, "vendor/gen.txt", []byte("generated line\n"))
984+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
985+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Window").ok(t)
986+
987+ // Files sort as a/f00..a/f29, search.go, vendor/gen.txt: the generated
988+ // file is index 31, in the window that starts at 25.
989+ win := b.get("/proj/merge-requests/1?from=25", nil)
990+ require.Equal(t, 200, win.status)
991+ m := regexp.MustCompile(`href="(\?from=25&amp;file=(\d+)#f\d+)"`).FindStringSubmatch(win.body)
992+ require.Len(t, m, 3, "the load link keeps the window")
993+ assert.Equal(t, "31", m[2])
994+ assert.NotContains(t, win.body, "generated line</td>")
995+
996+ own := b.get("/proj/merge-requests/1?from=25&file=31", nil)
997+ require.Equal(t, 200, own.status)
998+ assert.Contains(t, own.body, "generated line</td>", "the file is spliced in")
999+ assert.NotContains(t, own.body, "data-manual")
1000+}
@@ -921,3 +921,80 @@ func TestWebMergeRequestMergeabilityLive(t *testing.T) {
921 break921 break
922 }922 }
923 }923 }
924+
925+// Collapsed files of a merge request: a generated file and a file over the
926+// line cap are listed closed, with no rows and a manual placeholder; /files
927+// with one=1 reads exactly one file in full, whatever its index; the page
928+// without scripts (?file=i) splices the file in.
929+func TestWebMergeRequestCollapsedFiles(t *testing.T) {
930+ r := newMRRepo(t, domain.VisibilityPublic)
931+ b := browser{r.harness}
932+ var big strings.Builder
933+ for i := range 2600 {
934+ fmt.Fprintf(&big, "row %d\n", i)
935+ }
936+ r.writeFile(r.wt, "zz/big.txt", []byte(big.String()))
937+ r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n"))
938+ r.writeFile(r.wt, "zz/small.txt", []byte("small line\n"))
939+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
940+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t)
941+
942+ page := b.get("/proj/merge-requests/1", nil)
943+ require.Equal(t, 200, page.status)
944+ assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
945+ assert.Contains(t, page.body, "generated file: collapsed")
946+ assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
947+ assert.NotRegexp(t, `row \d+`, page.body, "no rows for a collapsed file")
948+ assert.NotContains(t, page.body, "generated line</td>")
949+ assert.Contains(t, page.body, "small line")
950+ assert.NotContains(t, page.body, "this diff is incomplete")
951+ srcs := regexp.MustCompile(`data-src="([^"]*one=1&amp;from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
952+ require.Len(t, srcs, 2)
953+
954+ for _, m := range srcs {
955+ one := b.get(html.UnescapeString(m[1]), nil)
956+ require.Equal(t, 200, one.status, one.body)
957+ assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file")
958+ assert.NotContains(t, one.body, "data-manual")
959+ }
960+ bigOne := b.get(html.UnescapeString(srcs[1][1]), nil)
961+ genOne := b.get(html.UnescapeString(srcs[0][1]), nil)
962+ assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599"))
963+ assert.Contains(t, bigOne.body+genOne.body, "generated line")
964+
965+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status)
966+ assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status)
967+
968+ for _, m := range srcs {
969+ own := b.get("/proj/merge-requests/1?file="+m[2], nil)
970+ require.Equal(t, 200, own.status)
971+ assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
972+ }
973+}
974+
975+// The page of a window without scripts: ?file=i splices that collapsed file in
976+// and the load link of a window keeps the window.
977+func TestWebMergeRequestWindowFileSplice(t *testing.T) {
978+ r := newMRRepo(t, domain.VisibilityPublic)
979+ b := browser{r.harness}
980+ for i := range 30 {
981+ r.writeFile(r.wt, fmt.Sprintf("a/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i)))
982+ }
983+ r.writeFile(r.wt, "vendor/gen.txt", []byte("generated line\n"))
984+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
985+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Window").ok(t)
986+
987+ // Files sort as a/f00..a/f29, search.go, vendor/gen.txt: the generated
988+ // file is index 31, in the window that starts at 25.
989+ win := b.get("/proj/merge-requests/1?from=25", nil)
990+ require.Equal(t, 200, win.status)
991+ m := regexp.MustCompile(`href="(\?from=25&amp;file=(\d+)#f\d+)"`).FindStringSubmatch(win.body)
992+ require.Len(t, m, 3, "the load link keeps the window")
993+ assert.Equal(t, "31", m[2])
994+ assert.NotContains(t, win.body, "generated line</td>")
995+
996+ own := b.get("/proj/merge-requests/1?from=25&file=31", nil)
997+ require.Equal(t, 200, own.status)
998+ assert.Contains(t, own.body, "generated line</td>", "the file is spliced in")
999+ assert.NotContains(t, own.body, "data-manual")
1000+}
M internal/domain/budget.go +60 -11
@@ -2,7 +2,10 @@
22
33 package domain
44
5-import "fmt"
5+import (
6+ "fmt"
7+ "math"
8+)
69
710 // ValidateCommitObjects checks a request before any repository I/O. max is the
811 // caller's commit-count limit.
@@ -45,15 +48,17 @@ func (b *CommitObjectBudget) Charge(id ObjectID, size int64) {
4548 b.total += size
4649 }
4750
48-// DiffBudget owns file selection and retained hunk accounting. Truncated files
51+// DiffBudget owns file selection and retained hunk accounting. Omitted files
4952 // consume no budget; streaming readers may use a copy while attempting a file.
5053 type DiffBudget struct {
5154 limits DiffLimits
5255 linesLeft, bytesLeft int
56+ // fileBytes is bytesLeft when the current file started (StartFile).
57+ fileBytes int
5358 }
5459
5560 func NewDiffBudget(limits DiffLimits) DiffBudget {
56- return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes}
61+ return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes, fileBytes: limits.MaxTotalBytes}
5762 }
5863
5964 func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
@@ -69,17 +74,61 @@ func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
6974 return
7075 }
7176
72-func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 }
77+// Admit decides whether the hunks of a file are read. read is false with
78+// OmitNone for a binary file or one without changed lines (nothing to show).
79+// Otherwise read is false with the reason the file is omitted. path is the path
80+// a comment designates (see FileDiff.CommentPath).
81+func (b DiffBudget) Admit(binary bool, path string, changed int) (read bool, why FileOmission) {
82+ switch {
83+ case binary || changed <= 0:
84+ return false, OmitNone
85+ case b.limits.SkipGenerated && IsGeneratedPath(path):
86+ return false, OmitGenerated
87+ case b.limits.MaxAloneLines > 0 && changed > b.limits.MaxAloneLines:
88+ return false, OmitUnavailable
89+ case b.limits.MaxFileLines > 0 && changed > b.limits.MaxFileLines:
90+ return false, OmitLarge
91+ case b.limits.MaxTotalLines > 0 && changed > b.linesLeft, b.bytesLeft <= 0:
92+ return false, OmitBudget
93+ }
94+ return true, OmitNone
95+}
7396
74-func (b DiffBudget) WantsHunks(binary bool, n int) bool {
75- return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 0
97+// FileLines is the most changed lines the next file may have; math.MaxInt when
98+// neither MaxFileLines nor MaxTotalLines limits it.
99+func (b DiffBudget) FileLines() int {
100+ n := math.MaxInt
101+ if b.limits.MaxFileLines > 0 {
102+ n = b.limits.MaxFileLines
103+ }
104+ if b.limits.MaxTotalLines > 0 {
105+ n = min(n, b.linesLeft)
106+ }
107+ return n
76108 }
77109
78-func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }
110+// StartFile marks the start of a file's lines, for ChargeLine to tell a file
111+// over the byte limit on its own from one that met the bytes of earlier files.
112+func (b *DiffBudget) StartFile() { b.fileBytes = b.bytesLeft }
79113
80-// ChargeLine accounts for one decoded hunk line and reports whether it fits.
81-func (b *DiffBudget) ChargeLine(bytes int) bool {
82- b.linesLeft--
114+// ChargeLine accounts for decoded text bytes and, for additions or deletions,
115+// one changed line. Context consumes bytes only. It returns OmitNone when the
116+// line fits and OmitBudget when the changed lines ran out. When the bytes run
117+// out it returns OmitBudget if files read before this one (since StartFile)
118+// spent some of them, as this file may still fit on its own, and
119+// OmitUnavailable if this file alone is over MaxTotalBytes.
120+func (b *DiffBudget) ChargeLine(bytes int, changed bool) FileOmission {
121+ if changed && b.limits.MaxTotalLines > 0 {
122+ b.linesLeft--
123+ }
83124 b.bytesLeft -= bytes
84- return b.linesLeft >= 0 && b.bytesLeft >= 0
125+ switch {
126+ case b.limits.MaxTotalLines > 0 && b.linesLeft < 0:
127+ return OmitBudget
128+ case b.bytesLeft < 0 && b.fileBytes < b.limits.MaxTotalBytes:
129+ return OmitBudget
130+ case b.bytesLeft < 0:
131+ return OmitUnavailable
132+ }
133+ return OmitNone
85134 }
@@ -2,7 +2,10 @@
2 2
3 package domain3 package domain
4 4
5-import "fmt"5+import (
6+ "fmt"
7+ "math"
8+)
6 9
7 // ValidateCommitObjects checks a request before any repository I/O. max is the10 // ValidateCommitObjects checks a request before any repository I/O. max is the
8 // caller's commit-count limit.11 // caller's commit-count limit.
@@ -45,15 +48,17 @@ func (b *CommitObjectBudget) Charge(id ObjectID, size int64) {
45 b.total += size48 b.total += size
46 }49 }
47 50
48-// DiffBudget owns file selection and retained hunk accounting. Truncated files51+// DiffBudget owns file selection and retained hunk accounting. Omitted files
49 // consume no budget; streaming readers may use a copy while attempting a file.52 // consume no budget; streaming readers may use a copy while attempting a file.
50 type DiffBudget struct {53 type DiffBudget struct {
51 limits DiffLimits54 limits DiffLimits
52 linesLeft, bytesLeft int55 linesLeft, bytesLeft int
56+ // fileBytes is bytesLeft when the current file started (StartFile).
57+ fileBytes int
53 }58 }
54 59
55 func NewDiffBudget(limits DiffLimits) DiffBudget {60 func NewDiffBudget(limits DiffLimits) DiffBudget {
56- return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes}61+ return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes, fileBytes: limits.MaxTotalBytes}
57 }62 }
58 63
59 func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {64 func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
@@ -69,17 +74,61 @@ func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
69 return74 return
70 }75 }
71 76
72-func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 }77+// Admit decides whether the hunks of a file are read. read is false with
78+// OmitNone for a binary file or one without changed lines (nothing to show).
79+// Otherwise read is false with the reason the file is omitted. path is the path
80+// a comment designates (see FileDiff.CommentPath).
81+func (b DiffBudget) Admit(binary bool, path string, changed int) (read bool, why FileOmission) {
82+ switch {
83+ case binary || changed <= 0:
84+ return false, OmitNone
85+ case b.limits.SkipGenerated && IsGeneratedPath(path):
86+ return false, OmitGenerated
87+ case b.limits.MaxAloneLines > 0 && changed > b.limits.MaxAloneLines:
88+ return false, OmitUnavailable
89+ case b.limits.MaxFileLines > 0 && changed > b.limits.MaxFileLines:
90+ return false, OmitLarge
91+ case b.limits.MaxTotalLines > 0 && changed > b.linesLeft, b.bytesLeft <= 0:
92+ return false, OmitBudget
93+ }
94+ return true, OmitNone
95+}
73 96
74-func (b DiffBudget) WantsHunks(binary bool, n int) bool {97+// FileLines is the most changed lines the next file may have; math.MaxInt when
75- return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 098+// neither MaxFileLines nor MaxTotalLines limits it.
99+func (b DiffBudget) FileLines() int {
100+ n := math.MaxInt
101+ if b.limits.MaxFileLines > 0 {
102+ n = b.limits.MaxFileLines
103+ }
104+ if b.limits.MaxTotalLines > 0 {
105+ n = min(n, b.linesLeft)
106+ }
107+ return n
76 }108 }
77 109
78-func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }110+// StartFile marks the start of a file's lines, for ChargeLine to tell a file
111+// over the byte limit on its own from one that met the bytes of earlier files.
112+func (b *DiffBudget) StartFile() { b.fileBytes = b.bytesLeft }
79 113
80-// ChargeLine accounts for one decoded hunk line and reports whether it fits.114+// ChargeLine accounts for decoded text bytes and, for additions or deletions,
81-func (b *DiffBudget) ChargeLine(bytes int) bool {115+// one changed line. Context consumes bytes only. It returns OmitNone when the
82- b.linesLeft--116+// line fits and OmitBudget when the changed lines ran out. When the bytes run
117+// out it returns OmitBudget if files read before this one (since StartFile)
118+// spent some of them, as this file may still fit on its own, and
119+// OmitUnavailable if this file alone is over MaxTotalBytes.
120+func (b *DiffBudget) ChargeLine(bytes int, changed bool) FileOmission {
121+ if changed && b.limits.MaxTotalLines > 0 {
122+ b.linesLeft--
123+ }
83 b.bytesLeft -= bytes124 b.bytesLeft -= bytes
84- return b.linesLeft >= 0 && b.bytesLeft >= 0125+ switch {
126+ case b.limits.MaxTotalLines > 0 && b.linesLeft < 0:
127+ return OmitBudget
128+ case b.bytesLeft < 0 && b.fileBytes < b.limits.MaxTotalBytes:
129+ return OmitBudget
130+ case b.bytesLeft < 0:
131+ return OmitUnavailable
132+ }
133+ return OmitNone
85 }134 }
M internal/domain/budget_test.go +136 -17
@@ -94,21 +94,32 @@ func TestDiffBudgetWindow(t *testing.T) {
9494 }
9595 }
9696
97-func TestDiffBudgetHunks(t *testing.T) {
97+func TestDiffBudgetAdmit(t *testing.T) {
9898 for _, tt := range []struct {
99- name string
100- binary bool
101- n, lines, bytes int
102- want bool
99+ name string
100+ limits DiffLimits
101+ binary bool
102+ path string
103+ n int
104+ wantRead bool
105+ wantWhy FileOmission
103106 }{
104- {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false},
105- {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false},
106- {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false},
107+ {"fits", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3, MaxTotalBytes: 1}, false, "a.go", 3, true, OmitNone},
108+ {"binary", DiffLimits{MaxTotalBytes: 1}, true, "a.bin", 3, false, OmitNone},
109+ {"no changes", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 0, false, OmitNone},
110+ {"file limit", DiffLimits{MaxFileLines: 3, MaxTotalLines: 5, MaxTotalBytes: 1}, false, "a.go", 4, false, OmitLarge},
111+ {"total lines", DiffLimits{MaxFileLines: 3, MaxTotalLines: 2, MaxTotalBytes: 1}, false, "a.go", 3, false, OmitBudget},
112+ {"no bytes", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3}, false, "a.go", 1, false, OmitBudget},
113+ {"generated", DiffLimits{SkipGenerated: true, MaxTotalBytes: 1}, false, "go.sum", 1, false, OmitGenerated},
114+ {"generated not skipped", DiffLimits{MaxTotalBytes: 1}, false, "go.sum", 1, true, OmitNone},
115+ {"generated before large", DiffLimits{SkipGenerated: true, MaxFileLines: 1, MaxTotalBytes: 1}, false, "go.sum", 9, false, OmitGenerated},
116+ {"unlimited lines", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 1 << 20, true, OmitNone},
107117 } {
108118 t.Run(tt.name, func(t *testing.T) {
109- b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes})
110- if got := b.WantsHunks(tt.binary, tt.n); got != tt.want {
111- t.Fatalf("got %v", got)
119+ b := NewDiffBudget(tt.limits)
120+ read, why := b.Admit(tt.binary, tt.path, tt.n)
121+ if read != tt.wantRead || why != tt.wantWhy {
122+ t.Fatalf("got %v,%v", read, why)
112123 }
113124 })
114125 }
@@ -118,24 +129,132 @@ func TestDiffBudgetCharge(t *testing.T) {
118129 for _, tt := range []struct {
119130 name string
120131 bytes int
121- fits bool
122- }{{"exact", 3, true}, {"over", 4, false}} {
132+ want FileOmission
133+ }{{"exact", 3, OmitNone}, {"over", 4, OmitUnavailable}} {
123134 t.Run(tt.name, func(t *testing.T) {
124135 b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})
125136 before := b
126137 if b.FileLines() != 1 {
127138 t.Fatal("wrong file limit")
128139 }
129- if b.ChargeLine(tt.bytes) != tt.fits {
130- t.Fatal("wrong charge result")
140+ if got := b.ChargeLine(tt.bytes, true); got != tt.want {
141+ t.Fatalf("wrong charge result %v", got)
131142 }
132- if b.ChargeLine(0) {
143+ if b.ChargeLine(0, true) == OmitNone {
133144 t.Fatal("line budget exceeded")
134145 }
135146 b = before
136- if !b.WantsHunks(false, 1) {
147+ if read, _ := b.Admit(false, "a", 1); !read {
137148 t.Fatal("discarded file consumed budget")
138149 }
139150 })
140151 }
141152 }
153+
154+func TestDiffBudgetChargeReasons(t *testing.T) {
155+ b := NewDiffBudget(DiffLimits{MaxTotalLines: 1, MaxTotalBytes: 3})
156+ if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 1 {
157+ t.Fatal("context consumes bytes only")
158+ }
159+ if b.ChargeLine(1, true) != OmitNone || b.FileLines() != 0 {
160+ t.Fatal("first changed line fits")
161+ }
162+ if got := b.ChargeLine(0, true); got != OmitBudget {
163+ t.Fatalf("lines ran out: %v", got)
164+ }
165+ b = NewDiffBudget(DiffLimits{MaxTotalLines: 9, MaxTotalBytes: 1})
166+ if got := b.ChargeLine(2, false); got != OmitUnavailable {
167+ t.Fatalf("bytes ran out: %v", got)
168+ }
169+ free := NewDiffBudget(DiffLimits{MaxTotalBytes: 5})
170+ for range 100 {
171+ if free.ChargeLine(0, true) != OmitNone {
172+ t.Fatal("0 means no line limit")
173+ }
174+ }
175+ if free.FileLines() < 1<<30 {
176+ t.Fatal("unlimited FileLines")
177+ }
178+}
179+
180+func TestFileOmissionCollapsed(t *testing.T) {
181+ for o, want := range map[FileOmission]bool{OmitNone: false, OmitGenerated: true, OmitLarge: true, OmitBudget: true, OmitUnavailable: false} {
182+ if o.Collapsed() != want {
183+ t.Errorf("%d: got %v", o, !want)
184+ }
185+ }
186+}
187+
188+func TestOneFileDiffLimits(t *testing.T) {
189+ l := OneFileDiffLimits(3)
190+ if l.From != 3 || l.To != 4 || l.MaxFileLines != 0 || l.MaxAloneLines != MaxOneFileLines || l.MaxTotalLines != 0 || l.MaxFiles != 0 || l.MaxTotalBytes != DefaultDiffLimits().MaxTotalBytes {
191+ t.Fatalf("got %+v", l)
192+ }
193+ // A negative index (an unchecked IndexFunc) selects no file, not the whole diff.
194+ if _, from, to, _ := NewDiffBudget(OneFileDiffLimits(-1)).Window(10); from < to {
195+ t.Fatalf("OneFileDiffLimits(-1) selects files [%d, %d)", from, to)
196+ }
197+}
198+
199+func TestDiffBudgetBytesOfEarlierFiles(t *testing.T) {
200+ b := NewDiffBudget(DiffLimits{MaxTotalBytes: 10})
201+ b.StartFile()
202+ if b.ChargeLine(4, true) != OmitNone {
203+ t.Fatal("the first file fits")
204+ }
205+ b.StartFile()
206+ if got := b.ChargeLine(7, true); got != OmitBudget {
207+ t.Fatalf("a file meeting the bytes of earlier files: got %v, want OmitBudget", got)
208+ }
209+ alone := NewDiffBudget(DiffLimits{MaxTotalBytes: 10})
210+ alone.StartFile()
211+ if got := alone.ChargeLine(11, true); got != OmitUnavailable {
212+ t.Fatalf("a file over the bytes on its own: got %v, want OmitUnavailable", got)
213+ }
214+}
215+
216+func TestDiffBudgetContext(t *testing.T) {
217+ b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 2, MaxTotalBytes: 6})
218+ if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 2 {
219+ t.Fatal("context consumed changed-line budget")
220+ }
221+ first := b.ChargeLine(1, true)
222+ second := b.ChargeLine(1, true)
223+ if first != OmitNone || second != OmitNone || b.FileLines() != 0 {
224+ t.Fatal("additions and deletions must consume changed-line budget")
225+ }
226+ if b.ChargeLine(2, false) != OmitNone {
227+ t.Fatal("context at the byte boundary must fit even without changed lines left")
228+ }
229+ if b.ChargeLine(1, false) != OmitUnavailable {
230+ t.Fatal("context must not exceed the byte budget")
231+ }
232+}
233+
234+func TestDiffBudgetAdmitAloneLines(t *testing.T) {
235+ b := NewDiffBudget(OneFileDiffLimits(0))
236+ if read, why := b.Admit(false, "a.go", MaxOneFileLines); !read || why != OmitNone {
237+ t.Fatalf("at the cap: read=%v why=%v", read, why)
238+ }
239+ if read, why := b.Admit(false, "a.go", MaxOneFileLines+1); read || why != OmitUnavailable {
240+ t.Fatalf("over the cap: read=%v why=%v, want OmitUnavailable", read, why)
241+ }
242+}
243+
244+func TestDiffCollapseFrom(t *testing.T) {
245+ d := Diff{Files: []FileDiff{
246+ {Additions: 1},
247+ {Additions: 1},
248+ {Binary: true, Additions: 1},
249+ {Omitted: OmitLarge, Additions: 1},
250+ {},
251+ {Deletions: 2},
252+ }}
253+ d.CollapseFrom(1)
254+ want := []FileOmission{OmitNone, OmitBudget, OmitNone, OmitLarge, OmitNone, OmitBudget}
255+ for i, f := range d.Files {
256+ if f.Omitted != want[i] {
257+ t.Errorf("file %d: omitted %v, want %v", i, f.Omitted, want[i])
258+ }
259+ }
260+}
@@ -94,21 +94,32 @@ func TestDiffBudgetWindow(t *testing.T) {
94 }94 }
95 }95 }
96 96
97-func TestDiffBudgetHunks(t *testing.T) {97+func TestDiffBudgetAdmit(t *testing.T) {
98 for _, tt := range []struct {98 for _, tt := range []struct {
99- name string99+ name string
100- binary bool100+ limits DiffLimits
101- n, lines, bytes int101+ binary bool
102- want bool102+ path string
103+ n int
104+ wantRead bool
105+ wantWhy FileOmission
103 }{106 }{
104- {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false},107+ {"fits", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3, MaxTotalBytes: 1}, false, "a.go", 3, true, OmitNone},
105- {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false},108+ {"binary", DiffLimits{MaxTotalBytes: 1}, true, "a.bin", 3, false, OmitNone},
106- {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false},109+ {"no changes", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 0, false, OmitNone},
110+ {"file limit", DiffLimits{MaxFileLines: 3, MaxTotalLines: 5, MaxTotalBytes: 1}, false, "a.go", 4, false, OmitLarge},
111+ {"total lines", DiffLimits{MaxFileLines: 3, MaxTotalLines: 2, MaxTotalBytes: 1}, false, "a.go", 3, false, OmitBudget},
112+ {"no bytes", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3}, false, "a.go", 1, false, OmitBudget},
113+ {"generated", DiffLimits{SkipGenerated: true, MaxTotalBytes: 1}, false, "go.sum", 1, false, OmitGenerated},
114+ {"generated not skipped", DiffLimits{MaxTotalBytes: 1}, false, "go.sum", 1, true, OmitNone},
115+ {"generated before large", DiffLimits{SkipGenerated: true, MaxFileLines: 1, MaxTotalBytes: 1}, false, "go.sum", 9, false, OmitGenerated},
116+ {"unlimited lines", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 1 << 20, true, OmitNone},
107 } {117 } {
108 t.Run(tt.name, func(t *testing.T) {118 t.Run(tt.name, func(t *testing.T) {
109- b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes})119+ b := NewDiffBudget(tt.limits)
110- if got := b.WantsHunks(tt.binary, tt.n); got != tt.want {120+ read, why := b.Admit(tt.binary, tt.path, tt.n)
111- t.Fatalf("got %v", got)121+ if read != tt.wantRead || why != tt.wantWhy {
122+ t.Fatalf("got %v,%v", read, why)
112 }123 }
113 })124 })
114 }125 }
@@ -118,24 +129,132 @@ func TestDiffBudgetCharge(t *testing.T) {
118 for _, tt := range []struct {129 for _, tt := range []struct {
119 name string130 name string
120 bytes int131 bytes int
121- fits bool132+ want FileOmission
122- }{{"exact", 3, true}, {"over", 4, false}} {133+ }{{"exact", 3, OmitNone}, {"over", 4, OmitUnavailable}} {
123 t.Run(tt.name, func(t *testing.T) {134 t.Run(tt.name, func(t *testing.T) {
124 b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})135 b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})
125 before := b136 before := b
126 if b.FileLines() != 1 {137 if b.FileLines() != 1 {
127 t.Fatal("wrong file limit")138 t.Fatal("wrong file limit")
128 }139 }
129- if b.ChargeLine(tt.bytes) != tt.fits {140+ if got := b.ChargeLine(tt.bytes, true); got != tt.want {
130- t.Fatal("wrong charge result")141+ t.Fatalf("wrong charge result %v", got)
131 }142 }
132- if b.ChargeLine(0) {143+ if b.ChargeLine(0, true) == OmitNone {
133 t.Fatal("line budget exceeded")144 t.Fatal("line budget exceeded")
134 }145 }
135 b = before146 b = before
136- if !b.WantsHunks(false, 1) {147+ if read, _ := b.Admit(false, "a", 1); !read {
137 t.Fatal("discarded file consumed budget")148 t.Fatal("discarded file consumed budget")
138 }149 }
139 })150 })
140 }151 }
141 }152 }
153+
154+func TestDiffBudgetChargeReasons(t *testing.T) {
155+ b := NewDiffBudget(DiffLimits{MaxTotalLines: 1, MaxTotalBytes: 3})
156+ if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 1 {
157+ t.Fatal("context consumes bytes only")
158+ }
159+ if b.ChargeLine(1, true) != OmitNone || b.FileLines() != 0 {
160+ t.Fatal("first changed line fits")
161+ }
162+ if got := b.ChargeLine(0, true); got != OmitBudget {
163+ t.Fatalf("lines ran out: %v", got)
164+ }
165+ b = NewDiffBudget(DiffLimits{MaxTotalLines: 9, MaxTotalBytes: 1})
166+ if got := b.ChargeLine(2, false); got != OmitUnavailable {
167+ t.Fatalf("bytes ran out: %v", got)
168+ }
169+ free := NewDiffBudget(DiffLimits{MaxTotalBytes: 5})
170+ for range 100 {
171+ if free.ChargeLine(0, true) != OmitNone {
172+ t.Fatal("0 means no line limit")
173+ }
174+ }
175+ if free.FileLines() < 1<<30 {
176+ t.Fatal("unlimited FileLines")
177+ }
178+}
179+
180+func TestFileOmissionCollapsed(t *testing.T) {
181+ for o, want := range map[FileOmission]bool{OmitNone: false, OmitGenerated: true, OmitLarge: true, OmitBudget: true, OmitUnavailable: false} {
182+ if o.Collapsed() != want {
183+ t.Errorf("%d: got %v", o, !want)
184+ }
185+ }
186+}
187+
188+func TestOneFileDiffLimits(t *testing.T) {
189+ l := OneFileDiffLimits(3)
190+ if l.From != 3 || l.To != 4 || l.MaxFileLines != 0 || l.MaxAloneLines != MaxOneFileLines || l.MaxTotalLines != 0 || l.MaxFiles != 0 || l.MaxTotalBytes != DefaultDiffLimits().MaxTotalBytes {
191+ t.Fatalf("got %+v", l)
192+ }
193+ // A negative index (an unchecked IndexFunc) selects no file, not the whole diff.
194+ if _, from, to, _ := NewDiffBudget(OneFileDiffLimits(-1)).Window(10); from < to {
195+ t.Fatalf("OneFileDiffLimits(-1) selects files [%d, %d)", from, to)
196+ }
197+}
198+
199+func TestDiffBudgetBytesOfEarlierFiles(t *testing.T) {
200+ b := NewDiffBudget(DiffLimits{MaxTotalBytes: 10})
201+ b.StartFile()
202+ if b.ChargeLine(4, true) != OmitNone {
203+ t.Fatal("the first file fits")
204+ }
205+ b.StartFile()
206+ if got := b.ChargeLine(7, true); got != OmitBudget {
207+ t.Fatalf("a file meeting the bytes of earlier files: got %v, want OmitBudget", got)
208+ }
209+ alone := NewDiffBudget(DiffLimits{MaxTotalBytes: 10})
210+ alone.StartFile()
211+ if got := alone.ChargeLine(11, true); got != OmitUnavailable {
212+ t.Fatalf("a file over the bytes on its own: got %v, want OmitUnavailable", got)
213+ }
214+}
215+
216+func TestDiffBudgetContext(t *testing.T) {
217+ b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 2, MaxTotalBytes: 6})
218+ if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 2 {
219+ t.Fatal("context consumed changed-line budget")
220+ }
221+ first := b.ChargeLine(1, true)
222+ second := b.ChargeLine(1, true)
223+ if first != OmitNone || second != OmitNone || b.FileLines() != 0 {
224+ t.Fatal("additions and deletions must consume changed-line budget")
225+ }
226+ if b.ChargeLine(2, false) != OmitNone {
227+ t.Fatal("context at the byte boundary must fit even without changed lines left")
228+ }
229+ if b.ChargeLine(1, false) != OmitUnavailable {
230+ t.Fatal("context must not exceed the byte budget")
231+ }
232+}
233+
234+func TestDiffBudgetAdmitAloneLines(t *testing.T) {
235+ b := NewDiffBudget(OneFileDiffLimits(0))
236+ if read, why := b.Admit(false, "a.go", MaxOneFileLines); !read || why != OmitNone {
237+ t.Fatalf("at the cap: read=%v why=%v", read, why)
238+ }
239+ if read, why := b.Admit(false, "a.go", MaxOneFileLines+1); read || why != OmitUnavailable {
240+ t.Fatalf("over the cap: read=%v why=%v, want OmitUnavailable", read, why)
241+ }
242+}
243+
244+func TestDiffCollapseFrom(t *testing.T) {
245+ d := Diff{Files: []FileDiff{
246+ {Additions: 1},
247+ {Additions: 1},
248+ {Binary: true, Additions: 1},
249+ {Omitted: OmitLarge, Additions: 1},
250+ {},
251+ {Deletions: 2},
252+ }}
253+ d.CollapseFrom(1)
254+ want := []FileOmission{OmitNone, OmitBudget, OmitNone, OmitLarge, OmitNone, OmitBudget}
255+ for i, f := range d.Files {
256+ if f.Omitted != want[i] {
257+ t.Errorf("file %d: omitted %v, want %v", i, f.Omitted, want[i])
258+ }
259+ }
260+}
A internal/domain/generated.go +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "path"
7+ "strings"
8+)
9+
10+var generatedSuffixes = []string{
11+ "_templ.go", ".pb.go", "_gen.go", ".gen.go",
12+ ".min.js", ".min.css", ".js.map", ".css.map",
13+}
14+
15+var generatedNames = map[string]struct{}{
16+ "go.sum": {}, "package-lock.json": {}, "pnpm-lock.yaml": {}, "yarn.lock": {},
17+ "Cargo.lock": {}, "bun.lockb": {}, "bun.lock": {}, "composer.lock": {},
18+ "Gemfile.lock": {}, "poetry.lock": {}, "uv.lock": {}, "flake.lock": {},
19+}
20+
21+// IsGeneratedPath reports whether a changed file is generated or vendored by
22+// its path alone: its diff is collapsed by default in the web view.
23+func IsGeneratedPath(p string) bool {
24+ if p == "" {
25+ return false
26+ }
27+ base := path.Base(p)
28+ if _, ok := generatedNames[base]; ok {
29+ return true
30+ }
31+ for _, s := range generatedSuffixes {
32+ if strings.HasSuffix(base, s) {
33+ return true
34+ }
35+ }
36+ // Directory segments only: the last segment is the file name.
37+ dir := p[:len(p)-len(base)]
38+ for dir != "" {
39+ seg, rest, _ := strings.Cut(dir, "/")
40+ if seg == "vendor" || seg == "node_modules" {
41+ return true
42+ }
43+ dir = rest
44+ }
45+ return false
46+}
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "path"
7+ "strings"
8+)
9+
10+var generatedSuffixes = []string{
11+ "_templ.go", ".pb.go", "_gen.go", ".gen.go",
12+ ".min.js", ".min.css", ".js.map", ".css.map",
13+}
14+
15+var generatedNames = map[string]struct{}{
16+ "go.sum": {}, "package-lock.json": {}, "pnpm-lock.yaml": {}, "yarn.lock": {},
17+ "Cargo.lock": {}, "bun.lockb": {}, "bun.lock": {}, "composer.lock": {},
18+ "Gemfile.lock": {}, "poetry.lock": {}, "uv.lock": {}, "flake.lock": {},
19+}
20+
21+// IsGeneratedPath reports whether a changed file is generated or vendored by
22+// its path alone: its diff is collapsed by default in the web view.
23+func IsGeneratedPath(p string) bool {
24+ if p == "" {
25+ return false
26+ }
27+ base := path.Base(p)
28+ if _, ok := generatedNames[base]; ok {
29+ return true
30+ }
31+ for _, s := range generatedSuffixes {
32+ if strings.HasSuffix(base, s) {
33+ return true
34+ }
35+ }
36+ // Directory segments only: the last segment is the file name.
37+ dir := p[:len(p)-len(base)]
38+ for dir != "" {
39+ seg, rest, _ := strings.Cut(dir, "/")
40+ if seg == "vendor" || seg == "node_modules" {
41+ return true
42+ }
43+ dir = rest
44+ }
45+ return false
46+}
A internal/domain/generated_test.go +51 -0
@@ -0,0 +1,51 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "testing"
6+
7+func TestIsGeneratedPath(t *testing.T) {
8+ t.Parallel()
9+ tests := []struct {
10+ path string
11+ want bool
12+ }{
13+ {"views/a_templ.go", true},
14+ {"api/x.pb.go", true},
15+ {"x_gen.go", true},
16+ {"x.gen.go", true},
17+ {"static/app.min.js", true},
18+ {"static/app.min.css", true},
19+ {"static/app.js.map", true},
20+ {"static/app.css.map", true},
21+ {"go.sum", true},
22+ {"web/package-lock.json", true},
23+ {"pnpm-lock.yaml", true},
24+ {"yarn.lock", true},
25+ {"Cargo.lock", true},
26+ {"bun.lockb", true},
27+ {"bun.lock", true},
28+ {"composer.lock", true},
29+ {"Gemfile.lock", true},
30+ {"poetry.lock", true},
31+ {"uv.lock", true},
32+ {"flake.lock", true},
33+ {"vendor/x.go", true},
34+ {"a/vendor/b/x.go", true},
35+ {"node_modules/a.js", true},
36+ {"web/node_modules/a.js", true},
37+ {"vendor.go", false},
38+ {"templ.go", false},
39+ {"main.go", false},
40+ {"go.mod", false},
41+ {"minify.js", false},
42+ {"myvendor/x.go", false},
43+ {"x.min.jsx", false},
44+ {"", false},
45+ }
46+ for _, tt := range tests {
47+ if got := IsGeneratedPath(tt.path); got != tt.want {
48+ t.Errorf("IsGeneratedPath(%q) = %v, want %v", tt.path, got, tt.want)
49+ }
50+ }
51+}
@@ -0,0 +1,51 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "testing"
6+
7+func TestIsGeneratedPath(t *testing.T) {
8+ t.Parallel()
9+ tests := []struct {
10+ path string
11+ want bool
12+ }{
13+ {"views/a_templ.go", true},
14+ {"api/x.pb.go", true},
15+ {"x_gen.go", true},
16+ {"x.gen.go", true},
17+ {"static/app.min.js", true},
18+ {"static/app.min.css", true},
19+ {"static/app.js.map", true},
20+ {"static/app.css.map", true},
21+ {"go.sum", true},
22+ {"web/package-lock.json", true},
23+ {"pnpm-lock.yaml", true},
24+ {"yarn.lock", true},
25+ {"Cargo.lock", true},
26+ {"bun.lockb", true},
27+ {"bun.lock", true},
28+ {"composer.lock", true},
29+ {"Gemfile.lock", true},
30+ {"poetry.lock", true},
31+ {"uv.lock", true},
32+ {"flake.lock", true},
33+ {"vendor/x.go", true},
34+ {"a/vendor/b/x.go", true},
35+ {"node_modules/a.js", true},
36+ {"web/node_modules/a.js", true},
37+ {"vendor.go", false},
38+ {"templ.go", false},
39+ {"main.go", false},
40+ {"go.mod", false},
41+ {"minify.js", false},
42+ {"myvendor/x.go", false},
43+ {"x.min.jsx", false},
44+ {"", false},
45+ }
46+ for _, tt := range tests {
47+ if got := IsGeneratedPath(tt.path); got != tt.want {
48+ t.Errorf("IsGeneratedPath(%q) = %v, want %v", tt.path, got, tt.want)
49+ }
50+ }
51+}
M internal/domain/git.go +84 -19
@@ -505,11 +505,37 @@ type FileDiff struct {
505505 Additions, Deletions int
506506 // Similarity is a percentage for a renamed or copied file, 0 otherwise.
507507 Similarity int
508- // Truncated means the file exceeded a diff limit: Hunks is empty although
509- // the file changed. Additions and Deletions stay accurate. A file outside
510- // the DiffLimits window has no Hunks and is not Truncated.
511- Truncated bool
512- Hunks []Hunk
508+ // Omitted tells why the hunks were not kept: Hunks is empty although the
509+ // file changed; Additions and Deletions stay accurate. A file outside the
510+ // window is OmitNone with no Hunks.
511+ Omitted FileOmission
512+ Hunks []Hunk
513+}
514+
515+// CommentPath is the path a comment designates for f, as Diff.HasLine reads it:
516+// the old path of a deleted file, the new path otherwise.
517+func (f FileDiff) CommentPath() string {
518+ if f.Status == FileDeleted {
519+ return f.OldPath
520+ }
521+ return f.NewPath
522+}
523+
524+// FileOmission tells why a changed file has no hunks.
525+type FileOmission uint8
526+
527+// File omissions.
528+const (
529+ OmitNone FileOmission = iota
530+ OmitGenerated // SkipGenerated and IsGeneratedPath: hunks not read
531+ OmitLarge // changed lines > MaxFileLines
532+ OmitBudget // did not fit in the remaining MaxTotalLines
533+ OmitUnavailable // retained text would exceed MaxTotalBytes
534+)
535+
536+// Collapsed reports a file the web view folds and can load on its own.
537+func (o FileOmission) Collapsed() bool {
538+ return o == OmitGenerated || o == OmitLarge || o == OmitBudget
513539 }
514540
515541 // Diff is the structured difference a commit introduces against its first
@@ -518,9 +544,8 @@ type Diff struct {
518544 // Files are ordered as Git reports them (by path). Files past
519545 // DiffLimits.MaxFiles (when it is not 0) are left out.
520546 Files []FileDiff
521- // Truncated is true when files were left out or a listed file is truncated.
522- // Files listed without hunks because they are outside the DiffLimits window
523- // do not make it true.
547+ // Truncated is true when files past DiffLimits.MaxFiles were left out. It
548+ // says nothing of the listed files: see FileDiff.Omitted.
524549 Truncated bool
525550 }
526551
@@ -537,33 +562,58 @@ type CommitDetail struct {
537562 Diff Diff
538563 }
539564
540-// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.
565+// DiffLimits bound the size of a Diff. Line limits count additions plus
566+// deletions, not context. A file that does not fit is listed without hunks and
567+// with an Omitted reason.
541568 type DiffLimits struct {
542569 // MaxFiles is the number of files kept; 0 means no limit on the number of
543570 // files (every changed file is listed).
544571 MaxFiles int
545- // MaxFileLines is the most lines one file may have; a file over it is
546- // truncated.
572+ // MaxFileLines is the most changed lines one file may have; a file over it is
573+ // OmitLarge. 0 means no limit.
547574 MaxFileLines int
548- // MaxTotalLines is the most lines of all the files. A file is truncated
549- // when it does not fit in what remains; a smaller one after it may still
550- // fit.
575+ // MaxTotalLines is the most changed lines of all the files. A file is
576+ // OmitBudget when it does not fit in what remains; a smaller one after it
577+ // may still fit. 0 means no limit.
551578 MaxTotalLines int
552- // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds
553- // the memory of a diff whose lines are very long; files are truncated
554- // like for MaxTotalLines.
579+ // MaxTotalBytes is the most text, in bytes, of all the lines, including context. It bounds
580+ // the memory of a diff whose lines are very long; a file whose
581+ // text does not fit is OmitUnavailable.
555582 MaxTotalBytes int
556583 // From and To select the files whose hunks are read: those with an index in
557584 // [From, To), counted from 0 in the order of Diff.Files. The other files
558585 // are listed (paths, status, modes, ids, Binary, Additions, Deletions,
559- // Similarity) without hunks; they are not Truncated and use none of
586+ // Similarity) without hunks; they are OmitNone and use none of
560587 // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values
561588 // select every file. MaxFileLines applies to each file of the window.
562589 From, To int
590+ // MaxAloneLines is the most changed lines a file may have to be read at all;
591+ // a file over it is OmitUnavailable (it cannot be shown even alone), unlike
592+ // OmitLarge, which a read of that file alone can load. 0 means no limit.
593+ MaxAloneLines int
594+ // SkipGenerated omits the files of the window that IsGeneratedPath
595+ // recognizes (OmitGenerated), without reading their hunks.
596+ SkipGenerated bool
597+}
598+
599+// MaxOneFileLines is the most changed lines of a file read alone on the web
600+// (OneFileDiffLimits). A file over it is OmitUnavailable, so a short-lines
601+// file within the byte guard cannot produce millions of rows.
602+const MaxOneFileLines = 50000
603+
604+// OneFileDiffLimits reads the hunks of file i only, with no MaxFileLines cap;
605+// MaxAloneLines (MaxOneFileLines) and MaxTotalBytes are the guards. i must be at least 0: From -1 with To 0
606+// would mean the whole diff, so a negative i reads no file at all.
607+func OneFileDiffLimits(i int) DiffLimits {
608+ if i < 0 {
609+ return DiffLimits{From: 1, To: 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
610+ }
611+ return DiffLimits{From: i, To: i + 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
563612 }
564613
565614 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
566-// lines per file, 20,000 lines in total, plus 8 MiB of text.
615+// changed lines per file, 20,000 changed lines in total, plus 8 MiB of text
616+// including context. Generated files are not skipped.
567617 func DefaultDiffLimits() DiffLimits {
568618 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
569619 }
@@ -643,3 +693,18 @@ func (t *Tree) BoundTree() {
643693 t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true
644694 }
645695 }
696+
697+// CommitPageFiles is how many files of a commit get their hunks read with its
698+// page; the files past it are collapsed (see Diff.CollapseFrom) and load on
699+// their own.
700+const CommitPageFiles = 300
701+
702+// CollapseFrom marks the files from index n on that have lines to show (not
703+// binary, not already omitted, with changes) as OmitBudget: listed, not read.
704+func (d *Diff) CollapseFrom(n int) {
705+ for i := max(n, 0); i < len(d.Files); i++ {
706+ if f := &d.Files[i]; !f.Binary && f.Omitted == OmitNone && f.Additions+f.Deletions > 0 {
707+ f.Omitted = OmitBudget
708+ }
709+ }
710+}
@@ -505,11 +505,37 @@ type FileDiff struct {
505 Additions, Deletions int505 Additions, Deletions int
506 // Similarity is a percentage for a renamed or copied file, 0 otherwise.506 // Similarity is a percentage for a renamed or copied file, 0 otherwise.
507 Similarity int507 Similarity int
508- // Truncated means the file exceeded a diff limit: Hunks is empty although508+ // Omitted tells why the hunks were not kept: Hunks is empty although the
509- // the file changed. Additions and Deletions stay accurate. A file outside509+ // file changed; Additions and Deletions stay accurate. A file outside the
510- // the DiffLimits window has no Hunks and is not Truncated.510+ // window is OmitNone with no Hunks.
511- Truncated bool511+ Omitted FileOmission
512- Hunks []Hunk512+ Hunks []Hunk
513+}
514+
515+// CommentPath is the path a comment designates for f, as Diff.HasLine reads it:
516+// the old path of a deleted file, the new path otherwise.
517+func (f FileDiff) CommentPath() string {
518+ if f.Status == FileDeleted {
519+ return f.OldPath
520+ }
521+ return f.NewPath
522+}
523+
524+// FileOmission tells why a changed file has no hunks.
525+type FileOmission uint8
526+
527+// File omissions.
528+const (
529+ OmitNone FileOmission = iota
530+ OmitGenerated // SkipGenerated and IsGeneratedPath: hunks not read
531+ OmitLarge // changed lines > MaxFileLines
532+ OmitBudget // did not fit in the remaining MaxTotalLines
533+ OmitUnavailable // retained text would exceed MaxTotalBytes
534+)
535+
536+// Collapsed reports a file the web view folds and can load on its own.
537+func (o FileOmission) Collapsed() bool {
538+ return o == OmitGenerated || o == OmitLarge || o == OmitBudget
513 }539 }
514 540
515 // Diff is the structured difference a commit introduces against its first541 // Diff is the structured difference a commit introduces against its first
@@ -518,9 +544,8 @@ type Diff struct {
518 // Files are ordered as Git reports them (by path). Files past544 // Files are ordered as Git reports them (by path). Files past
519 // DiffLimits.MaxFiles (when it is not 0) are left out.545 // DiffLimits.MaxFiles (when it is not 0) are left out.
520 Files []FileDiff546 Files []FileDiff
521- // Truncated is true when files were left out or a listed file is truncated.547+ // Truncated is true when files past DiffLimits.MaxFiles were left out. It
522- // Files listed without hunks because they are outside the DiffLimits window548+ // says nothing of the listed files: see FileDiff.Omitted.
523- // do not make it true.
524 Truncated bool549 Truncated bool
525 }550 }
526 551
@@ -537,33 +562,58 @@ type CommitDetail struct {
537 Diff Diff562 Diff Diff
538 }563 }
539 564
540-// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.565+// DiffLimits bound the size of a Diff. Line limits count additions plus
566+// deletions, not context. A file that does not fit is listed without hunks and
567+// with an Omitted reason.
541 type DiffLimits struct {568 type DiffLimits struct {
542 // MaxFiles is the number of files kept; 0 means no limit on the number of569 // MaxFiles is the number of files kept; 0 means no limit on the number of
543 // files (every changed file is listed).570 // files (every changed file is listed).
544 MaxFiles int571 MaxFiles int
545- // MaxFileLines is the most lines one file may have; a file over it is572+ // MaxFileLines is the most changed lines one file may have; a file over it is
546- // truncated.573+ // OmitLarge. 0 means no limit.
547 MaxFileLines int574 MaxFileLines int
548- // MaxTotalLines is the most lines of all the files. A file is truncated575+ // MaxTotalLines is the most changed lines of all the files. A file is
549- // when it does not fit in what remains; a smaller one after it may still576+ // OmitBudget when it does not fit in what remains; a smaller one after it
550- // fit.577+ // may still fit. 0 means no limit.
551 MaxTotalLines int578 MaxTotalLines int
552- // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds579+ // MaxTotalBytes is the most text, in bytes, of all the lines, including context. It bounds
553- // the memory of a diff whose lines are very long; files are truncated580+ // the memory of a diff whose lines are very long; a file whose
554- // like for MaxTotalLines.581+ // text does not fit is OmitUnavailable.
555 MaxTotalBytes int582 MaxTotalBytes int
556 // From and To select the files whose hunks are read: those with an index in583 // From and To select the files whose hunks are read: those with an index in
557 // [From, To), counted from 0 in the order of Diff.Files. The other files584 // [From, To), counted from 0 in the order of Diff.Files. The other files
558 // are listed (paths, status, modes, ids, Binary, Additions, Deletions,585 // are listed (paths, status, modes, ids, Binary, Additions, Deletions,
559- // Similarity) without hunks; they are not Truncated and use none of586+ // Similarity) without hunks; they are OmitNone and use none of
560 // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values587 // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values
561 // select every file. MaxFileLines applies to each file of the window.588 // select every file. MaxFileLines applies to each file of the window.
562 From, To int589 From, To int
590+ // MaxAloneLines is the most changed lines a file may have to be read at all;
591+ // a file over it is OmitUnavailable (it cannot be shown even alone), unlike
592+ // OmitLarge, which a read of that file alone can load. 0 means no limit.
593+ MaxAloneLines int
594+ // SkipGenerated omits the files of the window that IsGeneratedPath
595+ // recognizes (OmitGenerated), without reading their hunks.
596+ SkipGenerated bool
597+}
598+
599+// MaxOneFileLines is the most changed lines of a file read alone on the web
600+// (OneFileDiffLimits). A file over it is OmitUnavailable, so a short-lines
601+// file within the byte guard cannot produce millions of rows.
602+const MaxOneFileLines = 50000
603+
604+// OneFileDiffLimits reads the hunks of file i only, with no MaxFileLines cap;
605+// MaxAloneLines (MaxOneFileLines) and MaxTotalBytes are the guards. i must be at least 0: From -1 with To 0
606+// would mean the whole diff, so a negative i reads no file at all.
607+func OneFileDiffLimits(i int) DiffLimits {
608+ if i < 0 {
609+ return DiffLimits{From: 1, To: 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
610+ }
611+ return DiffLimits{From: i, To: i + 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes}
563 }612 }
564 613
565 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000614 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
566-// lines per file, 20,000 lines in total, plus 8 MiB of text.615+// changed lines per file, 20,000 changed lines in total, plus 8 MiB of text
616+// including context. Generated files are not skipped.
567 func DefaultDiffLimits() DiffLimits {617 func DefaultDiffLimits() DiffLimits {
568 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}618 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
569 }619 }
@@ -643,3 +693,18 @@ func (t *Tree) BoundTree() {
643 t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true693 t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true
644 }694 }
645 }695 }
696+
697+// CommitPageFiles is how many files of a commit get their hunks read with its
698+// page; the files past it are collapsed (see Diff.CollapseFrom) and load on
699+// their own.
700+const CommitPageFiles = 300
701+
702+// CollapseFrom marks the files from index n on that have lines to show (not
703+// binary, not already omitted, with changes) as OmitBudget: listed, not read.
704+func (d *Diff) CollapseFrom(n int) {
705+ for i := max(n, 0); i < len(d.Files); i++ {
706+ if f := &d.Files[i]; !f.Binary && f.Omitted == OmitNone && f.Additions+f.Deletions > 0 {
707+ f.Omitted = OmitBudget
708+ }
709+ }
710+}
M internal/domain/mergerequest_test.go +1 -1
@@ -77,7 +77,7 @@ func hunkDiff() domain.Diff {
7777 {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{
7878 Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},
7979 }}},
80- {NewPath: "big.txt", Status: domain.FileAdded, Truncated: true},
80+ {NewPath: "big.txt", Status: domain.FileAdded, Omitted: domain.OmitLarge},
8181 {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{
8282 Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},
8383 }}},
@@ -77,7 +77,7 @@ func hunkDiff() domain.Diff {
77 {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{77 {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{
78 Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},78 Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},
79 }}},79 }}},
80- {NewPath: "big.txt", Status: domain.FileAdded, Truncated: true},80+ {NewPath: "big.txt", Status: domain.FileAdded, Omitted: domain.OmitLarge},
81 {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{81 {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{
82 Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},82 Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},
83 }}},83 }}},
M internal/ports/dto.go +33 -3
@@ -298,8 +298,10 @@ type GetMergeRequestOptions struct {
298298 SinceLastRead bool
299299 // Files, when set, lists every changed file, without the file-count
300300 // limit, and reads the hunks of the files in the window only, each within
301- // the per-file and the total limits of domain.DefaultDiffLimits. Unset,
302- // the diff is bounded by domain.DefaultDiffLimits (the API contract).
301+ // the per-file and the total limits of domain.DefaultDiffLimits; generated
302+ // files are listed collapsed (domain.OmitGenerated). With Full, the single
303+ // file is read without the line limits. Unset, the diff is bounded by
304+ // domain.DefaultDiffLimits and generated files are read (the API contract).
303305 Files *FileWindow
304306 // Base and Head, when not zero, are the diff the caller shows: Get
305307 // returns domain.ErrConflict when the request's diff base or head differ
@@ -307,10 +309,38 @@ type GetMergeRequestOptions struct {
307309 Base, Head domain.ObjectID
308310 // DiffOnly skips the commits of the request (Commits stays empty).
309311 DiffOnly bool
312+ // Load, when set with Files, is the index of a collapsed file Get reads in
313+ // full and splices into the diff in place of its collapsed entry (the page
314+ // of a client without scripts). Ignored when the index is out of range,
315+ // the file is not collapsed, or there is no diff.
316+ Load *int
310317 }
311318
312319 // FileWindow is the range [From, To) of files whose hunks are read.
313-type FileWindow struct{ From, To int }
320+//
321+// Full asks for the one file From read in full: no line limit applies, only
322+// the byte guard (domain.OneFileDiffLimits). It requires To == From+1; a
323+// window with Full and any other To is domain.ErrInvalid.
324+type FileWindow struct {
325+ From, To int
326+ Full bool
327+}
328+
329+// GetCommitOptions say which diff BrowseService.GetCommit reads.
330+type GetCommitOptions struct {
331+ // Files, when set, reads the hunks of a window of the files, as
332+ // GetMergeRequestOptions.Files does. Unset (the zero value), the diff is
333+ // bounded by domain.DefaultDiffLimits.
334+ Files *FileWindow
335+ // Page, without Files, reads the diff for a page: every file listed, the
336+ // hunks of the first domain.CommitPageFiles files read, generated files
337+ // collapsed, and the files past the window collapsed too
338+ // (domain.OmitBudget). Combined with Files it is domain.ErrInvalid.
339+ Page bool
340+ // Load is the index of a collapsed file read in full and spliced into the
341+ // diff, as GetMergeRequestOptions.Load. It requires Page.
342+ Load *int
343+}
314344
315345 // AddCommentParams describes the comment to add.
316346 type AddCommentParams struct {
@@ -298,8 +298,10 @@ type GetMergeRequestOptions struct {
298 SinceLastRead bool298 SinceLastRead bool
299 // Files, when set, lists every changed file, without the file-count299 // Files, when set, lists every changed file, without the file-count
300 // limit, and reads the hunks of the files in the window only, each within300 // limit, and reads the hunks of the files in the window only, each within
301- // the per-file and the total limits of domain.DefaultDiffLimits. Unset,301+ // the per-file and the total limits of domain.DefaultDiffLimits; generated
302- // the diff is bounded by domain.DefaultDiffLimits (the API contract).302+ // files are listed collapsed (domain.OmitGenerated). With Full, the single
303+ // file is read without the line limits. Unset, the diff is bounded by
304+ // domain.DefaultDiffLimits and generated files are read (the API contract).
303 Files *FileWindow305 Files *FileWindow
304 // Base and Head, when not zero, are the diff the caller shows: Get306 // Base and Head, when not zero, are the diff the caller shows: Get
305 // returns domain.ErrConflict when the request's diff base or head differ307 // returns domain.ErrConflict when the request's diff base or head differ
@@ -307,10 +309,38 @@ type GetMergeRequestOptions struct {
307 Base, Head domain.ObjectID309 Base, Head domain.ObjectID
308 // DiffOnly skips the commits of the request (Commits stays empty).310 // DiffOnly skips the commits of the request (Commits stays empty).
309 DiffOnly bool311 DiffOnly bool
312+ // Load, when set with Files, is the index of a collapsed file Get reads in
313+ // full and splices into the diff in place of its collapsed entry (the page
314+ // of a client without scripts). Ignored when the index is out of range,
315+ // the file is not collapsed, or there is no diff.
316+ Load *int
310 }317 }
311 318
312 // FileWindow is the range [From, To) of files whose hunks are read.319 // FileWindow is the range [From, To) of files whose hunks are read.
313-type FileWindow struct{ From, To int }320+//
321+// Full asks for the one file From read in full: no line limit applies, only
322+// the byte guard (domain.OneFileDiffLimits). It requires To == From+1; a
323+// window with Full and any other To is domain.ErrInvalid.
324+type FileWindow struct {
325+ From, To int
326+ Full bool
327+}
328+
329+// GetCommitOptions say which diff BrowseService.GetCommit reads.
330+type GetCommitOptions struct {
331+ // Files, when set, reads the hunks of a window of the files, as
332+ // GetMergeRequestOptions.Files does. Unset (the zero value), the diff is
333+ // bounded by domain.DefaultDiffLimits.
334+ Files *FileWindow
335+ // Page, without Files, reads the diff for a page: every file listed, the
336+ // hunks of the first domain.CommitPageFiles files read, generated files
337+ // collapsed, and the files past the window collapsed too
338+ // (domain.OmitBudget). Combined with Files it is domain.ErrInvalid.
339+ Page bool
340+ // Load is the index of a collapsed file read in full and spliced into the
341+ // diff, as GetMergeRequestOptions.Load. It requires Page.
342+ Load *int
343+}
314 344
315 // AddCommentParams describes the comment to add.345 // AddCommentParams describes the comment to add.
316 type AddCommentParams struct {346 type AddCommentParams struct {
M internal/ports/mocks/BrowseService.go +18 -12
@@ -51,8 +51,8 @@ func (_m *BrowseService) EXPECT() *BrowseService_Expecter {
5151 }
5252
5353 // GetCommit provides a mock function for the type BrowseService
54-func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
55- ret := _mock.Called(ctx, p, repo, rev)
54+func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
55+ ret := _mock.Called(ctx, p, repo, rev, opts)
5656
5757 if len(ret) == 0 {
5858 panic("no return value specified for GetCommit")
@@ -60,16 +60,16 @@ func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, r
6060
6161 var r0 domain.CommitDetail
6262 var r1 error
63- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.CommitDetail, error)); ok {
64- return returnFunc(ctx, p, repo, rev)
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) (domain.CommitDetail, error)); ok {
64+ return returnFunc(ctx, p, repo, rev, opts)
6565 }
66- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.CommitDetail); ok {
67- r0 = returnFunc(ctx, p, repo, rev)
66+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) domain.CommitDetail); ok {
67+ r0 = returnFunc(ctx, p, repo, rev, opts)
6868 } else {
6969 r0 = ret.Get(0).(domain.CommitDetail)
7070 }
71- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok {
72- r1 = returnFunc(ctx, p, repo, rev)
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) error); ok {
72+ r1 = returnFunc(ctx, p, repo, rev, opts)
7373 } else {
7474 r1 = ret.Error(1)
7575 }
@@ -86,11 +86,12 @@ type BrowseService_GetCommit_Call struct {
8686 // - p domain.Principal
8787 // - repo domain.RepoName
8888 // - rev domain.Revision
89-func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any) *BrowseService_GetCommit_Call {
90- return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev)}
89+// - opts ports.GetCommitOptions
90+func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any, opts any) *BrowseService_GetCommit_Call {
91+ return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev, opts)}
9192 }
9293
93-func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_GetCommit_Call {
94+func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions)) *BrowseService_GetCommit_Call {
9495 _c.Call.Run(func(args mock.Arguments) {
9596 var arg0 context.Context
9697 if args[0] != nil {
@@ -108,11 +109,16 @@ func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p doma
108109 if args[3] != nil {
109110 arg3 = args[3].(domain.Revision)
110111 }
112+ var arg4 ports.GetCommitOptions
113+ if args[4] != nil {
114+ arg4 = args[4].(ports.GetCommitOptions)
115+ }
111116 run(
112117 arg0,
113118 arg1,
114119 arg2,
115120 arg3,
121+ arg4,
116122 )
117123 })
118124 return _c
@@ -123,7 +129,7 @@ func (_c *BrowseService_GetCommit_Call) Return(commitDetail domain.CommitDetail,
123129 return _c
124130 }
125131
126-func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {
132+func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {
127133 _c.Call.Return(run)
128134 return _c
129135 }
@@ -51,8 +51,8 @@ func (_m *BrowseService) EXPECT() *BrowseService_Expecter {
51 }51 }
52 52
53 // GetCommit provides a mock function for the type BrowseService53 // GetCommit provides a mock function for the type BrowseService
54-func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {54+func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
55- ret := _mock.Called(ctx, p, repo, rev)55+ ret := _mock.Called(ctx, p, repo, rev, opts)
56 56
57 if len(ret) == 0 {57 if len(ret) == 0 {
58 panic("no return value specified for GetCommit")58 panic("no return value specified for GetCommit")
@@ -60,16 +60,16 @@ func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, r
60 60
61 var r0 domain.CommitDetail61 var r0 domain.CommitDetail
62 var r1 error62 var r1 error
63- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.CommitDetail, error)); ok {63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) (domain.CommitDetail, error)); ok {
64- return returnFunc(ctx, p, repo, rev)64+ return returnFunc(ctx, p, repo, rev, opts)
65 }65 }
66- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.CommitDetail); ok {66+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) domain.CommitDetail); ok {
67- r0 = returnFunc(ctx, p, repo, rev)67+ r0 = returnFunc(ctx, p, repo, rev, opts)
68 } else {68 } else {
69 r0 = ret.Get(0).(domain.CommitDetail)69 r0 = ret.Get(0).(domain.CommitDetail)
70 }70 }
71- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok {71+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) error); ok {
72- r1 = returnFunc(ctx, p, repo, rev)72+ r1 = returnFunc(ctx, p, repo, rev, opts)
73 } else {73 } else {
74 r1 = ret.Error(1)74 r1 = ret.Error(1)
75 }75 }
@@ -86,11 +86,12 @@ type BrowseService_GetCommit_Call struct {
86 // - p domain.Principal86 // - p domain.Principal
87 // - repo domain.RepoName87 // - repo domain.RepoName
88 // - rev domain.Revision88 // - rev domain.Revision
89-func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any) *BrowseService_GetCommit_Call {89+// - opts ports.GetCommitOptions
90- return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev)}90+func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any, opts any) *BrowseService_GetCommit_Call {
91+ return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev, opts)}
91 }92 }
92 93
93-func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_GetCommit_Call {94+func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions)) *BrowseService_GetCommit_Call {
94 _c.Call.Run(func(args mock.Arguments) {95 _c.Call.Run(func(args mock.Arguments) {
95 var arg0 context.Context96 var arg0 context.Context
96 if args[0] != nil {97 if args[0] != nil {
@@ -108,11 +109,16 @@ func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p doma
108 if args[3] != nil {109 if args[3] != nil {
109 arg3 = args[3].(domain.Revision)110 arg3 = args[3].(domain.Revision)
110 }111 }
112+ var arg4 ports.GetCommitOptions
113+ if args[4] != nil {
114+ arg4 = args[4].(ports.GetCommitOptions)
115+ }
111 run(116 run(
112 arg0,117 arg0,
113 arg1,118 arg1,
114 arg2,119 arg2,
115 arg3,120 arg3,
121+ arg4,
116 )122 )
117 })123 })
118 return _c124 return _c
@@ -123,7 +129,7 @@ func (_c *BrowseService_GetCommit_Call) Return(commitDetail domain.CommitDetail,
123 return _c129 return _c
124 }130 }
125 131
126-func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {132+func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {
127 _c.Call.Return(run)133 _c.Call.Return(run)
128 return _c134 return _c
129 }135 }
M internal/ports/services.go +1 -1
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 42

↑↓ moveenter openesc close