detached 973e682

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]), loaded by lazy.js (which
5//     the commit and compare pages use too) when they come near the screen. A
6//     link to a file or to a comment of a placeholder loads it first (reveal).
7//  2. The menus of the bar (merge, more) and their keys: M opens the merge menu
8//     on its button, 1-9 pick a mode, D toggles the branch deletion, Enter
9//     submits (the browser does it: the focus is on the button), Esc closes.
10//     R marks the head read. Nothing merges on one key.
11//  3. g f puts the focus in the list of files (opening it when folded); j / k
12//     move in it, Enter opens the file, Esc goes back to the diff. The button
13//     of its header folds the list, and the choice is remembered.
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    rangeURL(previous, current, base) {
21      const a = new URL(previous, base), b = new URL(current, base);
22      const parse = (u) => /^(new|old):(\d+):(.+)$/.exec(u.searchParams.get('on') || '');
23      const x = parse(a), y = parse(b);
24      if (!x || !y || a.pathname !== b.pathname || x[1] !== y[1] || x[3] !== y[3] || x[2] === y[2]) return null;
25      const nums = [Number(x[2]), Number(y[2])].sort((a,b) => a-b);
26      b.searchParams.set('on', `${y[1]}:${nums[0]}-${nums[1]}:${y[3]}`);
27      return b.href;
28    },
29    quote(button) {
30      const thread = button.closest('.cmt').closest('[data-thread]');
31      const details = thread?.querySelector(':scope > details[data-reply]');
32      const field = details?.querySelector('textarea');
33      if (!field) return false;
34      details.open = true;
35      field.value = button.dataset.quote;
36      field.focus();
37      return true;
38    },
39    // windowOf is the start of the window of file i (files are f1, f2, ...).
40    windowOf(id, size) {
41      const n = Number(String(id).replace(/^f/, '')) - 1;
42      return Number.isInteger(n) && n >= 0 ? n - (n % size) : -1;
43    },
44    // menuKey says what a key does while the merge menu is open: a mode
45    // (index), the branch deletion, or nothing.
46    menuKey(key, modes) {
47      if (key === 'D') return { toggle: true };
48      if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };
49      return null;
50    },
51    // step moves an index within [0, n).
52    step(i, d, n) {
53      if (n === 0) return -1;
54      if (i < 0) return d > 0 ? 0 : n - 1;
55      return Math.max(0, Math.min(n - 1, i + d));
56    },
57  };
58
59  const $ = (sel, root = document) => root.querySelector(sel);
60  const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
61  let revealGen = 0; // the last reveal wins the scroll
62  const load = (ph) => window.ermiteLazy.load(ph);
63
64  // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
65  // a placeholder, then scrolls to it. It returns false when there is nothing
66  // to load.
67  function reveal(id, scroll = true) {
68    // Any navigation, loaded or not, makes the reveals still loading stale.
69    const gen = ++revealGen;
70    const target = () => {
71      // A page with both layouts says which shows in data-diff; a large diff
72      // rendered in one layout only says it in data-view.
73      const diffs = $('#diffs')?.dataset;
74      if (/^c\d+$/.test(id) && (diffs?.diff || diffs?.view) === 'split') {
75        const split = document.getElementById(id + '-s');
76        if (split) return split;
77      }
78      return document.getElementById(id);
79    };
80    let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])');
81    if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
82      const base = id.replace(/-s$/, '');
83      ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
84    }
85    if (!ph) {
86      const el = target();
87      if (el && el.id !== id) {
88        const d = el.closest('details');
89        if (d) d.open = true;
90        if (scroll) el.scrollIntoView({ block: 'start' });
91      }
92      return false;
93    }
94    load(ph).then(() => {
95      // A later reveal (another file asked meanwhile) owns the view.
96      if (gen !== revealGen) return;
97      const el = target();
98      if (!el) return;
99      const d = el.closest('details');
100      if (d) d.open = true;
101      if (!scroll) return;
102      // The files above may have changed size while it loaded (placeholders,
103      // content-visibility): place it, then again once laid out.
104      el.scrollIntoView({ block: 'start' });
105      requestAnimationFrame(() => requestAnimationFrame(() => {
106        if (gen === revealGen) el.scrollIntoView({ block: 'start' });
107      }));
108    });
109    return true;
110  }
111
112  // The merge menu.
113  const menu = () => $('#merge-menu');
114  function openMerge() {
115    const m = menu();
116    if (!m) return false;
117    for (const d of $$('details.menu[open]')) if (d !== m) d.open = false;
118    m.open = true;
119    $('[data-default]', m)?.focus();
120    return true;
121  }
122  // menuKey handles a key while the merge menu is open; true when it used it.
123  function menuKey(e) {
124    const m = menu();
125    if (!m?.open || e.ctrlKey || e.metaKey || e.altKey) return false;
126    // An open dialog (help, commands) is above the menu: it keeps the keys.
127    if ($('dialog[open]')) return false;
128    // A field outside the menu (a comment being written) keeps its keys.
129    const field = e.target.closest?.('input, textarea, select, [contenteditable]');
130    if (field && !m.contains(field)) return false;
131    const radios = $$('input[name="mode"]', m);
132    const act = core.menuKey(e.key, radios.length);
133    if (!act) return false;
134    e.preventDefault();
135    if (act.toggle) {
136      const box = $('input[name="delete_branch"]', m);
137      if (box) box.checked = !box.checked;
138    } else {
139      const r = radios[act.mode];
140      // A change event lets the live bindings enable the Merge button again.
141      if (!r.disabled && !r.checked) {
142        r.checked = true;
143        r.dispatchEvent(new Event('change', { bubbles: true }));
144      }
145    }
146    $('[data-default]', m)?.focus();
147    return true;
148  }
149  function markRead() {
150    const b = $('button[data-read]');
151    if (!b) return false;
152    b.closest('form').requestSubmit(b);
153    return true;
154  }
155
156  // The list of files by keyboard: g f focuses the current file.
157  const links = () => $$('.r-files .files > li:not([hidden]) > a');
158  function focusFiles() {
159    foldFiles(false);
160    const all = links();
161    if (!all.length) return false;
162    const cur = all.find((a) => a.getAttribute('aria-current') === 'true') || all[0];
163    cur.focus();
164    cur.scrollIntoView({ block: 'nearest' });
165    return true;
166  }
167  // stepFiles moves the focus in the list; false when the focus is not in it.
168  function stepFiles(d) {
169    const all = links();
170    const i = all.indexOf(document.activeElement);
171    if (i < 0) return false;
172    const j = core.step(i, d, all.length);
173    all[j].focus();
174    all[j].scrollIntoView({ block: 'nearest' });
175    return true;
176  }
177  function leaveFiles() {
178    if (!links().includes(document.activeElement)) return false;
179    document.getElementById('main')?.focus({ preventScroll: true });
180    return true;
181  }
182
183  // foldFiles folds the list of files to its header, or opens it again:
184  // toggles without an argument. The choice is kept for the next pages.
185  function foldFiles(fold) {
186    const sec = $('.r-files');
187    const btn = sec && $('button.fold', sec);
188    if (!btn) return;
189    if (fold === undefined) fold = !sec.hasAttribute('data-folded');
190    sec.toggleAttribute('data-folded', fold);
191    btn.setAttribute('aria-expanded', String(!fold));
192    btn.title = fold ? 'open the list of files' : 'fold the list of files';
193    $('.sr', btn).textContent = btn.title;
194    // The list did not follow the review while folded: show its current file,
195    // moving the list only (never the page).
196    const box = !fold && $('.files', sec);
197    const a = box && $('a[aria-current="true"]', box);
198    if (a && (a.offsetTop < box.scrollTop || a.offsetTop + a.offsetHeight > box.scrollTop + box.clientHeight)) {
199      box.scrollTop = a.offsetTop - (box.clientHeight - a.offsetHeight) / 2;
200    }
201    try { localStorage.setItem('mr-files-folded', fold ? '1' : ''); } catch { /* storage refused: holds for this page */ }
202  }
203
204  // openFile opens the file of a link of the list (and loads it first).
205  function openFile(id) {
206    const f = document.getElementById(id);
207    if (f) f.open = true;
208    reveal(id);
209  }
210
211  function start() {
212    if (!$('#diffs[data-mr]')) return;
213    let folded = false;
214    try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
215    if (folded) foldFiles(true);
216    // The page answers a refused action from the address of the action: show
217    // the address of the request, so that a reload does not post again.
218    const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
219    if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
220    // A link to a file or a comment that is not loaded yet.
221    const hash = () => {
222      const id = decodeURIComponent(location.hash.slice(1));
223      if (id) reveal(id);
224    };
225    addEventListener('hashchange', hash);
226    hash();
227    let lastLine = null;
228    try { lastLine = sessionStorage.getItem('mr-last-line'); } catch { /* storage optional */ }
229    addEventListener('click', (e) => {
230      const quote = e.target.closest?.('[data-quote]');
231      if (quote) { core.quote(quote); return; }
232      const link = e.target.closest?.('a[href*="on="]');
233      if (!link || e.ctrlKey || e.metaKey || e.altKey) return;
234      const next = e.shiftKey && lastLine && core.rangeURL(lastLine, link.href, location.href);
235      lastLine = link.href;
236      try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ }
237      if (next) { e.preventDefault(); location.assign(next); }
238    });
239  }
240
241  return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
242})();
243
244if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
245else 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