detached 973e682

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

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