lazy.js RawHistoryFolder
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
24 // load fetches the fragment of a placeholder and swaps its files in. The
25 // promise resolves once they are in the page (or the page said to reload).
26 function load(ph) {
27 const src = ph?.dataset.src;
28 if (!src || moved) return Promise.resolve();
29 if (loading.has(src)) return loading.get(src);
30 for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
31 // The fragment renders like the page: the layout shown, and both layouts
32 // when the page switches them itself (data-diff) or one (data-view).
33 const d = $('#diffs')?.dataset || {};
34 const view = d.diff || d.view;
35 const layouts = d.diff ? 'both' : d.view ? 'one' : '';
36 const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
37 const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
38 .then(async (res) => {
39 const html = await res.text();
40 if (res.status === 409) return stale(html);
41 if (!res.ok) throw new Error('loading files: ' + res.status);
42 // A fragment of the old diff after the page went stale stays out.
43 if (!moved) swap(html);
44 })
45 .catch((err) => {
46 loading.delete(src);
47 for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
48 window.ermite?.flash?.(err.message, 4000);
49 });
50 loading.set(src, p);
51 return p;
52 }
53
54 // swap replaces each placeholder file with the file of the same id. A
55 // file the reader folded stays folded.
56 function swap(html) {
57 const t = document.createElement('template');
58 t.innerHTML = html;
59 for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
60 const old = document.getElementById(f.id);
61 if (!old || !old.querySelector('.dlazy')) continue;
62 // A file still collapsed stays closed; a collapsed file opens once
63 // loaded; a file of a window keeps the fold.
64 f.open = f.querySelector('.dlazy[data-manual]') ? false
65 : old.querySelector('.dlazy[data-manual]') ? true : old.open;
66 for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
67 old.replaceWith(f);
68 window.htmx?.process(f);
69 window.ermite?.watchFile?.(f, old);
70 }
71 }
72
73 // stale: the diff changed under the page. The placeholders say so; no file
74 // of another diff comes in.
75 function stale(html) {
76 moved = true;
77 for (const p of $$('.dlazy')) {
78 p.removeAttribute('data-loading');
79 p.innerHTML = html;
80 }
81 }
82
83 // Automatic placeholders load as they come within two screens of the view;
84 // files that come in are marked in the list of files (ermite.js marks the
85 // one in view). Manual ones are left alone.
86 function watch() {
87 if (!('IntersectionObserver' in window)) {
88 for (const p of $$(AUTO)) load(p);
89 return;
90 }
91 lazyIO = new IntersectionObserver((entries) => {
92 for (const e of entries) {
93 if (!e.isIntersecting) continue;
94 lazyIO.unobserve(e.target);
95 load(e.target);
96 }
97 }, { rootMargin: '200% 0px 200% 0px' });
98 for (const p of $$(AUTO)) lazyIO.observe(p);
99 }
100
101 // manualLink is the "load diff" link of an event target, when it is one that
102 // loads in place (the placeholder has an address).
103 const manualLink = (t) => {
104 const a = t?.closest?.('.dlazy[data-manual] > a');
105 return a && a.closest('.dlazy')?.dataset.src ? a : null;
106 };
107
108 // loadManual loads the file of a "load diff" link and puts the focus in it:
109 // on its first line link, else on its header. The scroll never moves for it.
110 function loadManual(a) {
111 const ph = a.closest('.dlazy');
112 const id = ph.closest('details')?.id;
113 return load(ph).then(() => {
114 const f = id && document.getElementById(id);
115 if (!f || f.querySelector('.dlazy[data-manual]')) return;
116 (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true });
117 });
118 }
119
120 function onClick(e) {
121 if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
122 const a = manualLink(e.target);
123 if (!a) return false;
124 e.preventDefault();
125 loadManual(a);
126 return true;
127 }
128 // Enter makes the browser click a link by itself; Space does not. Both are
129 // handled here, once (a fragment is fetched once).
130 function onKey(e) {
131 // A held key repeats: after the load, focus is on a line link.
132 if (e.repeat) return false;
133 if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
134 const a = manualLink(e.target);
135 if (!a) return false;
136 e.preventDefault();
137 loadManual(a);
138 return true;
139 }
140
141 let started = false;
142 function start() {
143 if (started || !$('#diffs')) return;
144 started = true;
145 watch();
146 document.addEventListener('click', onClick);
147 document.addEventListener('keydown', onKey);
148 }
149
150 return { load, start, onClick, onKey };
151})();
152
153if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start());
154else window.ermiteLazy.start();