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).
16window.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
169if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start());
170else window.ermiteLazy.start();