v0.19.0 55a688e

mr.js RawHistoryFolder

1// mr.js: the merge request page beyond what htmx and hx-live give.
2//
3//  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.
8//  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, Enter
10//     submits (the browser does it: the focus is on the button), Esc closes.
11//     R marks the head read. Nothing merges on one key.
12//  3. g f puts the focus in the list of files; j / k move in it, Enter opens
13//     the file, Esc goes back to the diff.
14//
15// Nothing runs on a page without #diffs[data-mr]. window.ermiteMR.core is pure
16// (jstest/mr.test.mjs).
17window.ermiteMR = (() => {
18  // core: the decisions, without the DOM.
19  const core = {
20    // windowOf is the start of the window of file i (files are f1, f2, ...).
21    windowOf(id, size) {
22      const n = Number(String(id).replace(/^f/, '')) - 1;
23      return Number.isInteger(n) && n >= 0 ? n - (n % size) : -1;
24    },
25    // menuKey says what a key does while the merge menu is open: a mode
26    // (index), the branch deletion, or nothing.
27    menuKey(key, modes) {
28      if (key === 'D') return { toggle: true };
29      if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };
30      return null;
31    },
32    // step moves an index within [0, n).
33    step(i, d, n) {
34      if (n === 0) return -1;
35      if (i < 0) return d > 0 ? 0 : n - 1;
36      return Math.max(0, Math.min(n - 1, i + d));
37    },
38  };
39
40  const $ = (sel, root = document) => root.querySelector(sel);
41  const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
42  const loading = new Map(); // src -> promise of the swap
43  let moved = false;
44  let lazyIO = null; // the observer of the placeholders (watch)
45  let revealGen = 0; // the last reveal wins the scroll
46
47  // load fetches the window of a placeholder and swaps its files in. The
48  // promise resolves once they are in the page (or the page said to reload).
49  function load(ph) {
50    const src = ph?.dataset.src;
51    if (!src || moved) return Promise.resolve();
52    if (loading.has(src)) return loading.get(src);
53    for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
54    // The window renders like the page: the layout shown, and both layouts
55    // when the page switches them itself (data-diff) or one (data-view).
56    const d = $('#diffs')?.dataset || {};
57    const view = d.diff || d.view;
58    const layouts = d.diff ? 'both' : d.view ? 'one' : '';
59    const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
60    const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
61      .then(async (res) => {
62        const html = await res.text();
63        if (res.status === 409) return stale(html);
64        if (!res.ok) throw new Error('loading files: ' + res.status);
65        // A window of the old diff after the page went stale stays out.
66        if (!moved) swap(html);
67      })
68      .catch((err) => {
69        loading.delete(src);
70        for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
71        window.ermite?.flash?.(err.message, 4000);
72      });
73    loading.set(src, p);
74    return p;
75  }
76
77  // swap replaces each placeholder file with the file of the same id. A
78  // file the reader folded stays folded.
79  function swap(html) {
80    const t = document.createElement('template');
81    t.innerHTML = html;
82    for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
83      const old = document.getElementById(f.id);
84      if (!old || !old.querySelector('.dlazy')) continue;
85      f.open = old.open;
86      for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
87      old.replaceWith(f);
88      window.htmx?.process(f);
89      window.ermite?.watchFile?.(f, old);
90    }
91  }
92
93  // stale: the diff changed under the page. The placeholders say so; no file
94  // of another diff comes in.
95  function stale(html) {
96    moved = true;
97    for (const p of $$('.dlazy')) {
98      p.removeAttribute('data-loading');
99      p.innerHTML = html;
100    }
101  }
102
103  // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
104  // a placeholder, then scrolls to it. It returns false when there is nothing
105  // to load.
106  function reveal(id, scroll = true) {
107    // Any navigation, loaded or not, makes the reveals still loading stale.
108    const gen = ++revealGen;
109    let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
110    if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
111      const base = id.replace(/-s$/, '');
112      ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
113    }
114    if (!ph) return false;
115    load(ph).then(() => {
116      // A later reveal (another file asked meanwhile) owns the view.
117      if (gen !== revealGen) return;
118      const el = document.getElementById(id);
119      if (!el) return;
120      const d = el.closest('details');
121      if (d) d.open = true;
122      if (!scroll) return;
123      // The files above may have changed size while it loaded (placeholders,
124      // content-visibility): place it, then again once laid out.
125      el.scrollIntoView({ block: 'start' });
126      requestAnimationFrame(() => requestAnimationFrame(() => {
127        if (gen === revealGen) el.scrollIntoView({ block: 'start' });
128      }));
129    });
130    return true;
131  }
132
133  // Placeholders load as they come within two screens of the view; files
134  // that come in are marked in the list of files (ermite.js marks the one in
135  // view).
136  function watch() {
137    if (!('IntersectionObserver' in window)) {
138      for (const p of $$('.dlazy[data-src]')) load(p);
139      return;
140    }
141    lazyIO = new IntersectionObserver((entries) => {
142      for (const e of entries) {
143        if (!e.isIntersecting) continue;
144        lazyIO.unobserve(e.target);
145        load(e.target);
146      }
147    }, { rootMargin: '200% 0px 200% 0px' });
148    for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
149  }
150
151  // The merge menu.
152  const menu = () => $('#merge-menu');
153  function openMerge() {
154    const m = menu();
155    if (!m) return false;
156    for (const d of $$('details.menu[open]')) if (d !== m) d.open = false;
157    m.open = true;
158    $('[data-default]', m)?.focus();
159    return true;
160  }
161  function closeMenus() {
162    const open = $$('details.menu[open]');
163    for (const d of open) d.open = false;
164    if (open.length) $('summary', open[0])?.focus();
165    return open.length > 0;
166  }
167  // menuKey handles a key while the merge menu is open; true when it used it.
168  function menuKey(e) {
169    const m = menu();
170    if (!m?.open || e.ctrlKey || e.metaKey || e.altKey) return false;
171    // An open dialog (help, commands) is above the menu: it keeps the keys.
172    if ($('dialog[open]')) return false;
173    // A field outside the menu (a comment being written) keeps its keys.
174    const field = e.target.closest?.('input, textarea, select, [contenteditable]');
175    if (field && !m.contains(field)) return false;
176    const radios = $$('input[name="mode"]', m);
177    const act = core.menuKey(e.key, radios.length);
178    if (!act) return false;
179    e.preventDefault();
180    if (act.toggle) {
181      const box = $('input[name="delete_branch"]', m);
182      if (box) box.checked = !box.checked;
183    } else {
184      radios[act.mode].checked = true;
185    }
186    $('[data-default]', m)?.focus();
187    return true;
188  }
189  function markRead() {
190    const b = $('button[data-read]');
191    if (!b) return false;
192    b.closest('form').requestSubmit(b);
193    return true;
194  }
195
196  // The list of files by keyboard: g f focuses the current file.
197  const links = () => $$('.r-files .files > li:not([hidden]) > a');
198  function focusFiles() {
199    const all = links();
200    if (!all.length) return false;
201    const cur = all.find((a) => a.getAttribute('aria-current') === 'true') || all[0];
202    cur.focus();
203    cur.scrollIntoView({ block: 'nearest' });
204    return true;
205  }
206  // stepFiles moves the focus in the list; false when the focus is not in it.
207  function stepFiles(d) {
208    const all = links();
209    const i = all.indexOf(document.activeElement);
210    if (i < 0) return false;
211    const j = core.step(i, d, all.length);
212    all[j].focus();
213    all[j].scrollIntoView({ block: 'nearest' });
214    return true;
215  }
216  function leaveFiles() {
217    if (!links().includes(document.activeElement)) return false;
218    document.getElementById('main')?.focus({ preventScroll: true });
219    return true;
220  }
221
222  // openFile opens the file of a link of the list (and loads it first).
223  function openFile(id) {
224    const f = document.getElementById(id);
225    if (f) f.open = true;
226    reveal(id);
227  }
228
229  function start() {
230    if (!$('#diffs[data-mr]')) return;
231    watch();
232    // A link to a file or a comment that is not loaded yet.
233    const hash = () => {
234      const id = decodeURIComponent(location.hash.slice(1));
235      if (id) reveal(id);
236    };
237    addEventListener('hashchange', hash);
238    hash();
239    // A click outside an open menu closes it.
240    addEventListener('click', (e) => {
241      for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
242    });
243  }
244
245  return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, openFile };
246})();
247
248if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
249else window.ermiteMR.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