main 1fb2421

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  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();

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close