detached 388d3be

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 (opening it when folded); j / k
13//     move in it, Enter opens the file, Esc goes back to the diff. The button
14//     of its header folds the list, and the choice is remembered.
15//
16// Nothing runs on a page without #diffs[data-mr]. window.ermiteMR.core is pure
17// (jstest/mr.test.mjs).
18window.ermiteMR = (() => {
19  // core: the decisions, without the DOM.
20  const core = {
21    rangeURL(previous, current, base) {
22      const a = new URL(previous, base), b = new URL(current, base);
23      const parse = (u) => /^(new|old):(\d+):(.+)$/.exec(u.searchParams.get('on') || '');
24      const x = parse(a), y = parse(b);
25      if (!x || !y || a.pathname !== b.pathname || x[1] !== y[1] || x[3] !== y[3] || x[2] === y[2]) return null;
26      const nums = [Number(x[2]), Number(y[2])].sort((a,b) => a-b);
27      b.searchParams.set('on', `${y[1]}:${nums[0]}-${nums[1]}:${y[3]}`);
28      return b.href;
29    },
30    quote(button) {
31      const thread = button.closest('.cmt').closest('[data-thread]');
32      const details = thread?.querySelector(':scope > details[data-reply]');
33      const field = details?.querySelector('textarea');
34      if (!field) return false;
35      details.open = true;
36      field.value = button.dataset.quote;
37      field.focus();
38      return true;
39    },
40    // windowOf is the start of the window of file i (files are f1, f2, ...).
41    windowOf(id, size) {
42      const n = Number(String(id).replace(/^f/, '')) - 1;
43      return Number.isInteger(n) && n >= 0 ? n - (n % size) : -1;
44    },
45    // menuKey says what a key does while the merge menu is open: a mode
46    // (index), the branch deletion, or nothing.
47    menuKey(key, modes) {
48      if (key === 'D') return { toggle: true };
49      if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };
50      return null;
51    },
52    // step moves an index within [0, n).
53    step(i, d, n) {
54      if (n === 0) return -1;
55      if (i < 0) return d > 0 ? 0 : n - 1;
56      return Math.max(0, Math.min(n - 1, i + d));
57    },
58  };
59
60  const $ = (sel, root = document) => root.querySelector(sel);
61  const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
62  const loading = new Map(); // src -> promise of the swap
63  let moved = false;
64  let lazyIO = null; // the observer of the placeholders (watch)
65  let revealGen = 0; // the last reveal wins the scroll
66
67  // load fetches the window of a placeholder and swaps its files in. The
68  // promise resolves once they are in the page (or the page said to reload).
69  function load(ph) {
70    const src = ph?.dataset.src;
71    if (!src || moved) return Promise.resolve();
72    if (loading.has(src)) return loading.get(src);
73    for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
74    // The window renders like the page: the layout shown, and both layouts
75    // when the page switches them itself (data-diff) or one (data-view).
76    const d = $('#diffs')?.dataset || {};
77    const view = d.diff || d.view;
78    const layouts = d.diff ? 'both' : d.view ? 'one' : '';
79    const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
80    const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
81      .then(async (res) => {
82        const html = await res.text();
83        if (res.status === 409) return stale(html);
84        if (!res.ok) throw new Error('loading files: ' + res.status);
85        // A window of the old diff after the page went stale stays out.
86        if (!moved) swap(html);
87      })
88      .catch((err) => {
89        loading.delete(src);
90        for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
91        window.ermite?.flash?.(err.message, 4000);
92      });
93    loading.set(src, p);
94    return p;
95  }
96
97  // swap replaces each placeholder file with the file of the same id. A
98  // file the reader folded stays folded.
99  function swap(html) {
100    const t = document.createElement('template');
101    t.innerHTML = html;
102    for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
103      const old = document.getElementById(f.id);
104      if (!old || !old.querySelector('.dlazy')) continue;
105      f.open = old.open;
106      for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
107      old.replaceWith(f);
108      window.htmx?.process(f);
109      window.ermite?.watchFile?.(f, old);
110    }
111  }
112
113  // stale: the diff changed under the page. The placeholders say so; no file
114  // of another diff comes in.
115  function stale(html) {
116    moved = true;
117    for (const p of $$('.dlazy')) {
118      p.removeAttribute('data-loading');
119      p.innerHTML = html;
120    }
121  }
122
123  // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
124  // a placeholder, then scrolls to it. It returns false when there is nothing
125  // to load.
126  function reveal(id, scroll = true) {
127    // Any navigation, loaded or not, makes the reveals still loading stale.
128    const gen = ++revealGen;
129    const target = () => {
130      // A page with both layouts says which shows in data-diff; a large diff
131      // rendered in one layout only says it in data-view.
132      const diffs = $('#diffs')?.dataset;
133      if (/^c\d+$/.test(id) && (diffs?.diff || diffs?.view) === 'split') {
134        const split = document.getElementById(id + '-s');
135        if (split) return split;
136      }
137      return document.getElementById(id);
138    };
139    let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
140    if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
141      const base = id.replace(/-s$/, '');
142      ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
143    }
144    if (!ph) {
145      const el = target();
146      if (el && el.id !== id) {
147        const d = el.closest('details');
148        if (d) d.open = true;
149        if (scroll) el.scrollIntoView({ block: 'start' });
150      }
151      return false;
152    }
153    load(ph).then(() => {
154      // A later reveal (another file asked meanwhile) owns the view.
155      if (gen !== revealGen) return;
156      const el = target();
157      if (!el) return;
158      const d = el.closest('details');
159      if (d) d.open = true;
160      if (!scroll) return;
161      // The files above may have changed size while it loaded (placeholders,
162      // content-visibility): place it, then again once laid out.
163      el.scrollIntoView({ block: 'start' });
164      requestAnimationFrame(() => requestAnimationFrame(() => {
165        if (gen === revealGen) el.scrollIntoView({ block: 'start' });
166      }));
167    });
168    return true;
169  }
170
171  // Placeholders load as they come within two screens of the view; files
172  // that come in are marked in the list of files (ermite.js marks the one in
173  // view).
174  function watch() {
175    if (!('IntersectionObserver' in window)) {
176      for (const p of $$('.dlazy[data-src]')) load(p);
177      return;
178    }
179    lazyIO = new IntersectionObserver((entries) => {
180      for (const e of entries) {
181        if (!e.isIntersecting) continue;
182        lazyIO.unobserve(e.target);
183        load(e.target);
184      }
185    }, { rootMargin: '200% 0px 200% 0px' });
186    for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
187  }
188
189  // The merge menu.
190  const menu = () => $('#merge-menu');
191  function openMerge() {
192    const m = menu();
193    if (!m) return false;
194    for (const d of $$('details.menu[open]')) if (d !== m) d.open = false;
195    m.open = true;
196    $('[data-default]', m)?.focus();
197    return true;
198  }
199  // menuKey handles a key while the merge menu is open; true when it used it.
200  function menuKey(e) {
201    const m = menu();
202    if (!m?.open || e.ctrlKey || e.metaKey || e.altKey) return false;
203    // An open dialog (help, commands) is above the menu: it keeps the keys.
204    if ($('dialog[open]')) return false;
205    // A field outside the menu (a comment being written) keeps its keys.
206    const field = e.target.closest?.('input, textarea, select, [contenteditable]');
207    if (field && !m.contains(field)) return false;
208    const radios = $$('input[name="mode"]', m);
209    const act = core.menuKey(e.key, radios.length);
210    if (!act) return false;
211    e.preventDefault();
212    if (act.toggle) {
213      const box = $('input[name="delete_branch"]', m);
214      if (box) box.checked = !box.checked;
215    } else {
216      radios[act.mode].checked = true;
217    }
218    $('[data-default]', m)?.focus();
219    return true;
220  }
221  function markRead() {
222    const b = $('button[data-read]');
223    if (!b) return false;
224    b.closest('form').requestSubmit(b);
225    return true;
226  }
227
228  // The list of files by keyboard: g f focuses the current file.
229  const links = () => $$('.r-files .files > li:not([hidden]) > a');
230  function focusFiles() {
231    foldFiles(false);
232    const all = links();
233    if (!all.length) return false;
234    const cur = all.find((a) => a.getAttribute('aria-current') === 'true') || all[0];
235    cur.focus();
236    cur.scrollIntoView({ block: 'nearest' });
237    return true;
238  }
239  // stepFiles moves the focus in the list; false when the focus is not in it.
240  function stepFiles(d) {
241    const all = links();
242    const i = all.indexOf(document.activeElement);
243    if (i < 0) return false;
244    const j = core.step(i, d, all.length);
245    all[j].focus();
246    all[j].scrollIntoView({ block: 'nearest' });
247    return true;
248  }
249  function leaveFiles() {
250    if (!links().includes(document.activeElement)) return false;
251    document.getElementById('main')?.focus({ preventScroll: true });
252    return true;
253  }
254
255  // foldFiles folds the list of files to its header, or opens it again:
256  // toggles without an argument. The choice is kept for the next pages.
257  function foldFiles(fold) {
258    const sec = $('.r-files');
259    const btn = sec && $('button.fold', sec);
260    if (!btn) return;
261    if (fold === undefined) fold = !sec.hasAttribute('data-folded');
262    sec.toggleAttribute('data-folded', fold);
263    btn.setAttribute('aria-expanded', String(!fold));
264    btn.title = fold ? 'open the list of files' : 'fold the list of files';
265    $('.sr', btn).textContent = btn.title;
266    // The list did not follow the review while folded: show its current file,
267    // moving the list only (never the page).
268    const box = !fold && $('.files', sec);
269    const a = box && $('a[aria-current="true"]', box);
270    if (a && (a.offsetTop < box.scrollTop || a.offsetTop + a.offsetHeight > box.scrollTop + box.clientHeight)) {
271      box.scrollTop = a.offsetTop - (box.clientHeight - a.offsetHeight) / 2;
272    }
273    try { localStorage.setItem('mr-files-folded', fold ? '1' : ''); } catch { /* storage refused: holds for this page */ }
274  }
275
276  // openFile opens the file of a link of the list (and loads it first).
277  function openFile(id) {
278    const f = document.getElementById(id);
279    if (f) f.open = true;
280    reveal(id);
281  }
282
283  function start() {
284    if (!$('#diffs[data-mr]')) return;
285    let folded = false;
286    try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
287    if (folded) foldFiles(true);
288    watch();
289    // A link to a file or a comment that is not loaded yet.
290    const hash = () => {
291      const id = decodeURIComponent(location.hash.slice(1));
292      if (id) reveal(id);
293    };
294    addEventListener('hashchange', hash);
295    hash();
296    let lastLine = null;
297    try { lastLine = sessionStorage.getItem('mr-last-line'); } catch { /* storage optional */ }
298    addEventListener('click', (e) => {
299      const quote = e.target.closest?.('[data-quote]');
300      if (quote) { core.quote(quote); return; }
301      const link = e.target.closest?.('a[href*="on="]');
302      if (!link || e.ctrlKey || e.metaKey || e.altKey) return;
303      const next = e.shiftKey && lastLine && core.rangeURL(lastLine, link.href, location.href);
304      lastLine = link.href;
305      try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ }
306      if (next) { e.preventDefault(); location.assign(next); }
307    });
308  }
309
310  return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
311})();
312
313if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
314else 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