detached 98d36bf

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    // Range mode is explicit: ordinary taps still open single-line forms.
31    touchRange(previous, current, base) {
32      if (!previous) return { start: current, url: null };
33      const url = core.rangeURL(previous, current, base);
34      if (url) return { start: null, url };
35      if (previous === current) return { start: null, url: current };
36      return { start: current, url: null }; // another file/side starts afresh
37    },
38    mobileURL(href, view, narrow) {
39      if (!narrow || view !== 'split') return null;
40      const url = new URL(href);
41      url.searchParams.set('view', 'unified');
42      return url.href;
43    },
44    quote(button) {
45      const thread = button.closest('.cmt').closest('[data-thread]');
46      const details = thread?.querySelector(':scope > details[data-reply]');
47      const field = details?.querySelector('textarea');
48      if (!field) return false;
49      details.open = true;
50      field.value = button.dataset.quote;
51      field.focus();
52      return true;
53    },
54    // windowOf is the start of the window of file i (files are f1, f2, ...).
55    windowOf(id, size) {
56      const n = Number(String(id).replace(/^f/, '')) - 1;
57      return Number.isInteger(n) && n >= 0 ? n - (n % size) : -1;
58    },
59    // menuKey says what a key does while the merge menu is open: a mode
60    // (index), the branch deletion, or nothing.
61    menuKey(key, modes) {
62      if (key === 'D') return { toggle: true };
63      if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };
64      return null;
65    },
66    tabIndex(key, index, count) {
67      if (key === 'Home') return 0;
68      if (key === 'End') return count - 1;
69      if (key === 'ArrowRight') return (index + 1) % count;
70      if (key === 'ArrowLeft') return (index + count - 1) % count;
71      return null;
72    },
73    // step moves an index within [0, n).
74    step(i, d, n) {
75      if (n === 0) return -1;
76      if (i < 0) return d > 0 ? 0 : n - 1;
77      return Math.max(0, Math.min(n - 1, i + d));
78    },
79  };
80
81  const $ = (sel, root = document) => root.querySelector(sel);
82  const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
83  const loading = new Map(); // src -> promise of the swap
84  let refreshTouchComments = () => {};
85  let moved = false;
86  let lazyIO = null; // the observer of the placeholders (watch)
87  let diffGeneration = 0;
88  let revealGen = 0; // the last reveal wins the scroll
89
90  // load fetches the window of a placeholder and swaps its files in. The
91  // promise resolves once they are in the page (or the page said to reload).
92  function load(ph) {
93    const generation = diffGeneration;
94    const src = ph?.dataset.src;
95    if (!src || moved) return Promise.resolve();
96    if (loading.has(src)) return loading.get(src);
97    for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
98    // The window renders like the page: the layout shown, and both layouts
99    // when the page switches them itself (data-diff) or one (data-view).
100    const d = $('#diffs')?.dataset || {};
101    const view = d.diff || d.view;
102    const layouts = d.diff ? 'both' : d.view ? 'one' : '';
103    const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
104    const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
105      .then(async (res) => {
106        const html = await res.text();
107        if (generation !== diffGeneration) return;
108        if (res.status === 409) return stale(html);
109        if (!res.ok) throw new Error('loading files: ' + res.status);
110        // A window of the old diff after the page went stale stays out.
111        if (!moved) swap(html);
112      })
113      .catch((err) => {
114        if (generation !== diffGeneration) return;
115        loading.delete(src);
116        for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
117        window.ermite?.flash?.(err.message, 4000);
118      });
119    loading.set(src, p);
120    return p;
121  }
122
123  // swap replaces each placeholder file with the file of the same id. A
124  // file the reader folded stays folded.
125  function swap(html) {
126    const t = document.createElement('template');
127    t.innerHTML = html;
128    for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
129      const old = document.getElementById(f.id);
130      if (!old || !old.querySelector('.dlazy')) continue;
131      f.open = old.open;
132      for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
133      old.replaceWith(f);
134      window.htmx?.process(f);
135      window.ermite?.watchFile?.(f, old);
136    }
137    refreshTouchComments();
138  }
139
140  // stale: the diff changed under the page. The placeholders say so; no file
141  // of another diff comes in.
142  function stale(html) {
143    moved = true;
144    for (const p of $$('.dlazy')) {
145      p.removeAttribute('data-loading');
146      p.innerHTML = html;
147    }
148  }
149
150  // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
151  // a placeholder, then scrolls to it. It returns false when there is nothing
152  // to load.
153  function reveal(id, scroll = true) {
154    // Any navigation, loaded or not, makes the reveals still loading stale.
155    const gen = ++revealGen;
156    const target = () => {
157      // A page with both layouts says which shows in data-diff; a large diff
158      // rendered in one layout only says it in data-view.
159      const diffs = $('#diffs')?.dataset;
160      if (/^c\d+$/.test(id) && !window.matchMedia?.('(max-width: 720px)').matches && (diffs?.diff || diffs?.view) === 'split') {
161        const split = document.getElementById(id + '-s');
162        if (split) return split;
163      }
164      return document.getElementById(id);
165    };
166    let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
167    if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
168      const base = id.replace(/-s$/, '');
169      ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
170    }
171    if (!ph) {
172      const el = target();
173      if (el && el.id !== id) {
174        const d = el.closest('details');
175        if (d) d.open = true;
176        if (scroll) el.scrollIntoView({ block: 'start' });
177      }
178      return false;
179    }
180    load(ph).then(() => {
181      // A later reveal (another file asked meanwhile) owns the view.
182      if (gen !== revealGen) return;
183      const el = target();
184      if (!el) return;
185      const d = el.closest('details');
186      if (d) d.open = true;
187      if (!scroll) return;
188      // The files above may have changed size while it loaded (placeholders,
189      // content-visibility): place it, then again once laid out.
190      el.scrollIntoView({ block: 'start' });
191      requestAnimationFrame(() => requestAnimationFrame(() => {
192        if (gen === revealGen) el.scrollIntoView({ block: 'start' });
193      }));
194    });
195    return true;
196  }
197
198  // Placeholders load as they come within two screens of the view; files
199  // that come in are marked in the list of files (ermite.js marks the one in
200  // view).
201  function watch() {
202    lazyIO?.disconnect();
203    if (!('IntersectionObserver' in window)) {
204      for (const p of $$('.dlazy[data-src]')) load(p);
205      return;
206    }
207    lazyIO = new IntersectionObserver((entries) => {
208      for (const e of entries) {
209        if (!e.isIntersecting) continue;
210        lazyIO.unobserve(e.target);
211        load(e.target);
212      }
213    }, { rootMargin: '200% 0px 200% 0px' });
214    for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
215  }
216
217  // The merge menu.
218  const menu = () => $('#merge-menu');
219  function openMerge() {
220    const m = menu();
221    if (!m) return false;
222    for (const d of $$('details.menu[open]')) if (d !== m) d.open = false;
223    m.open = true;
224    $('[data-default]', m)?.focus();
225    return true;
226  }
227  // menuKey handles a key while the merge menu is open; true when it used it.
228  function menuKey(e) {
229    const m = menu();
230    if (!m?.open || e.ctrlKey || e.metaKey || e.altKey) return false;
231    // An open dialog (help, commands) is above the menu: it keeps the keys.
232    if ($('dialog[open]')) return false;
233    // A field outside the menu (a comment being written) keeps its keys.
234    const field = e.target.closest?.('input, textarea, select, [contenteditable]');
235    if (field && !m.contains(field)) return false;
236    const radios = $$('input[name="mode"]', m);
237    const act = core.menuKey(e.key, radios.length);
238    if (!act) return false;
239    e.preventDefault();
240    if (act.toggle) {
241      const box = $('input[name="delete_branch"]', m);
242      if (box) box.checked = !box.checked;
243    } else {
244      const r = radios[act.mode];
245      // A change event lets the live bindings enable the Merge button again.
246      if (!r.disabled && !r.checked) {
247        r.checked = true;
248        r.dispatchEvent(new Event('change', { bubbles: true }));
249      }
250    }
251    $('[data-default]', m)?.focus();
252    return true;
253  }
254  function markRead() {
255    const b = $('button[data-read]');
256    if (!b) return false;
257    b.closest('form').requestSubmit(b);
258    return true;
259  }
260
261  // The list of files by keyboard: g f focuses the current file.
262  const links = () => $$('.r-files .files > li:not([hidden]) > a');
263  function focusFiles() {
264    foldFiles(false);
265    const all = links();
266    if (!all.length) return false;
267    const cur = all.find((a) => a.getAttribute('aria-current') === 'true') || all[0];
268    cur.focus();
269    cur.scrollIntoView({ block: 'nearest' });
270    return true;
271  }
272  // stepFiles moves the focus in the list; false when the focus is not in it.
273  function stepFiles(d) {
274    const all = links();
275    const i = all.indexOf(document.activeElement);
276    if (i < 0) return false;
277    const j = core.step(i, d, all.length);
278    all[j].focus();
279    all[j].scrollIntoView({ block: 'nearest' });
280    return true;
281  }
282  function leaveFiles() {
283    if (!links().includes(document.activeElement)) return false;
284    document.getElementById('main')?.focus({ preventScroll: true });
285    return true;
286  }
287
288  // foldFiles folds the list of files to its header, or opens it again:
289  // toggles without an argument. The choice is kept for the next pages.
290  function foldFiles(fold) {
291    const sec = $('.r-files');
292    const btn = sec && $('button.fold', sec);
293    if (!btn) return;
294    if (fold === undefined) fold = !sec.hasAttribute('data-folded');
295    sec.toggleAttribute('data-folded', fold);
296    btn.setAttribute('aria-expanded', String(!fold));
297    btn.title = fold ? 'open the list of files' : 'fold the list of files';
298    $('.sr', btn).textContent = btn.title;
299    // The list did not follow the review while folded: show its current file,
300    // moving the list only (never the page).
301    const box = !fold && $('.files', sec);
302    const a = box && $('a[aria-current="true"]', box);
303    if (a && (a.offsetTop < box.scrollTop || a.offsetTop + a.offsetHeight > box.scrollTop + box.clientHeight)) {
304      box.scrollTop = a.offsetTop - (box.clientHeight - a.offsetHeight) / 2;
305    }
306    try { localStorage.setItem('mr-files-folded', fold ? '1' : ''); } catch { /* storage refused: holds for this page */ }
307  }
308
309  // openFile opens the file of a link of the list (and loads it first).
310  function openFile(id) {
311    const f = document.getElementById(id);
312    if (f) f.open = true;
313    reveal(id);
314  }
315
316  // Enhance the ordinary anchor navigation only when scripts are available.
317  let disposePanels = () => {};
318  // The tabs are disclosure buttons (aria-expanded + aria-controls), not a
319  // tablist: a tablist always has a selected tab, and here every panel can be
320  // folded. State: the index of the open panel, or -1 for none.
321  function panels(initial = -1) {
322    disposePanels();
323    const footer = $('.mr-footer'), nav = $('[data-mr-tabs]');
324    if (!footer || !nav) return;
325    const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);
326    const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']);
327    let current = -1;
328    // show applies the state; fromUser also keeps the URL fragment in step.
329    function show(index, fromUser = false) {
330      current = index;
331      tabs.forEach((tab, i) => {
332        tab.setAttribute('aria-controls', panels[i].id);
333        tab.setAttribute('aria-expanded', String(i === index));
334        panels[i].hidden = i !== index;
335      });
336      if (!fromUser) return;
337      if (index >= 0) {
338        if (location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id);
339      } else if (ids.has(location.hash)) {
340        history.replaceState(null, '', location.pathname + location.search);
341      }
342    }
343    const toggle = (i) => show(i === current ? -1 : i, true);
344    tabs.forEach((tab, i) => {
345      tab.addEventListener('click', (e) => { e.preventDefault(); toggle(i); });
346      tab.addEventListener('keydown', (e) => {
347        const next = core.tabIndex(e.key, i, tabs.length);
348        if (next === null) return;
349        e.preventDefault(); tabs[next].focus();
350      });
351    });
352    // hash opens the panel of the URL fragment. Its target was hidden when the
353    // browser scrolled, so the panel is brought in; never on a refresh, which
354    // keeps the scroll.
355    function hash() {
356      const h = location.hash;
357      const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1;
358      if (index >= 0) { show(index); panels[index].scrollIntoView?.({ block: 'nearest' }); }
359    }
360    // The checks badge always opens Checks; it never folds it.
361    const checksClick = (e) => {
362      if (e.target.closest?.('#mr-ck')) show(1);
363    };
364    document.addEventListener('click', checksClick);
365    let observer;
366    const summary = $('#mr-ck');
367    if (summary) {
368      // The live stream replaces #mr-ck as a whole: watch only the children of
369      // its parent, and write the label only when it changes. hx-live rewrites
370      // other text in the same bar on every mutation of the document; a write
371      // here on each of them would wake hx-live again, a loop that never yields.
372      const sync = () => {
373        const label = $('[data-checks-label]', nav);
374        const text = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks';
375        if (label && label.textContent !== text) label.textContent = text;
376      };
377      observer = new MutationObserver(sync);
378      observer.observe(summary.parentElement, { childList: true });
379      sync();
380    }
381    show(initial);
382    footer.setAttribute('data-panels', '');
383    if (initial < 0) hash();
384    addEventListener('hashchange', hash);
385    disposePanels = () => {
386      document.removeEventListener?.('click', checksClick);
387      window.removeEventListener?.('hashchange', hash);
388      observer?.disconnect?.();
389    };
390  }
391
392  let disposeTouch = () => {};
393  function touchComments(scroll = true) {
394    disposeTouch();
395    const diffs = $('#diffs');
396    const bar = document.createElement('div');
397    bar.className = 'mr-touch-range';
398    const button = document.createElement('button');
399    button.type = 'button';
400    button.className = 'btn';
401    button.textContent = 'Select line range';
402    button.setAttribute('aria-pressed', 'false');
403    const status = document.createElement('span');
404    status.setAttribute('role', 'status');
405    bar.append(button, status);
406    diffs.prepend(bar);
407    refreshTouchComments = () => { bar.hidden = !$('.ln a[href*="on="]', diffs); };
408    refreshTouchComments();
409    const touch = window.matchMedia?.('(pointer: coarse), (hover: none)');
410    let enabled = false, start = null, marker = null;
411    const clear = () => { marker?.removeAttribute('data-range-start'); marker = null; start = null; };
412    button.addEventListener('click', () => {
413      enabled = !enabled;
414      clear();
415      button.setAttribute('aria-pressed', String(enabled));
416      status.textContent = enabled ? 'Tap the first line number, then the last. Tap the same line for one line.' : '';
417    });
418    diffs.addEventListener('click', (e) => {
419      const link = e.target.closest?.('.ln a[href*="on="]');
420      if (!touch?.matches || !enabled || !link || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
421      e.preventDefault();
422      e.stopPropagation(); // do not alter the desktop shift-click anchor
423      const next = core.touchRange(start, link.href, location.href);
424      clear();
425      if (next.url) { location.assign(next.url); return; }
426      start = next.start;
427      marker = link;
428      marker.setAttribute('data-range-start', '');
429      status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`;
430    });
431    const changed = () => {
432      if (touch.matches) return;
433      enabled = false;
434      clear();
435      button.setAttribute('aria-pressed', 'false');
436      status.textContent = '';
437    };
438    touch?.addEventListener('change', changed);
439    disposeTouch = () => { touch?.removeEventListener?.('change', changed); bar.remove?.(); };
440    // The server opens the line form. Scroll after layout without stealing
441    // focus or forcing a mobile keyboard to appear.
442    if (scroll && new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => {
443      const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length);
444      form?.scrollIntoView({ block: 'center' });
445    });
446  }
447
448  // A refresh scheduler has one request in flight, a trailing debounce, and
449  // a dirty bit for pushes received during that request. Drafts are checked
450  // again after the fetch: typing while the network is slow is safe too.
451  function refresher({ blocked, fetchPage, apply, notice, failed, delay = 300 }) {
452    let timer, busy = false, pending = false;
453    const schedule = () => { clearTimeout(timer); timer = setTimeout(run, delay); };
454    async function run() {
455      if (busy || !pending) return;
456      if (blocked()) { notice(true); return; }
457      busy = true;
458      pending = false;
459      try {
460        const page = await fetchPage();
461        if (blocked()) { pending = true; notice(true); return; }
462        if (await apply(page) === false) { pending = true; notice(true); }
463        else notice(false);
464      } catch { if (blocked()) { pending = true; notice(true); } else failed(); }
465      finally {
466        busy = false;
467        if (pending && !blocked()) schedule();
468      }
469    }
470    return {
471      request() { pending = true; schedule(); },
472      resume() { if (pending) schedule(); },
473      pending() { return pending || busy; },
474    };
475  }
476
477  function hasDraft() {
478    // Only form controls, never diff rows. Inline edits live in .cmt forms; the
479    // merge form (d-acts) holds a chosen mode and the delete-branch box.
480    return $$(':is(form.cform, .cmt form, form.d-acts) :is(textarea, input, select)').some((t) => {
481      if (['hidden', 'submit', 'button', 'reset', 'image'].includes(t.type)) return false;
482      if (document.activeElement === t) return true;
483      if (t.type === 'radio' || t.type === 'checkbox') return t.checked !== t.defaultChecked;
484      if (t.tagName === 'SELECT') return [...t.options].some((o) => o.selected !== o.defaultSelected);
485      return t.value !== t.defaultValue;
486    }) ||
487      !!document.activeElement?.closest?.('.cform');
488  }
489
490  // Path + side + line is stable when files are inserted before this one;
491  // fN is only a positional anchor. Hidden unified/split rows are ignored.
492  function scrollAnchor() {
493    const top = Math.max(0, $('.hdr')?.getBoundingClientRect().bottom || 0,
494      $('.mr-bar')?.getBoundingClientRect().bottom || 0);
495    const diffs = $('#diffs');
496    const bounds = diffs.getBoundingClientRect();
497    const x = Math.max(1, Math.min(innerWidth - 1, bounds.left + Math.min(100, bounds.width / 2)));
498    // A bounded number of probes, independent of the number of diff lines.
499    let y = top + 2, fallback;
500    for (let probe = 0; probe < 6 && y < innerHeight; probe++) {
501      const hit = document.elementFromPoint(x, y);
502      const file = hit?.closest('details.dfile');
503      if (!file || !diffs.contains(file)) { y += 32; continue; }
504      const row = hit.closest('tr');
505      const link = row && $('.ln a[href*="on="]', row);
506      const header = $(':scope > summary', file);
507      const headerOffset = file.getBoundingClientRect().top;
508      fallback = { file: file.dataset.file, offset: headerOffset, headerOffset, y: scrollY };
509      if (link) return { ...fallback,
510        on: new URL(link.href, location.href).searchParams.get('on'),
511        suffix: link.getAttribute('href').split('on=')[1].split('#')[0],
512        offset: row.getBoundingClientRect().top };
513      if (file.open && header) y = Math.max(y + 24, header.getBoundingClientRect().bottom + 2);
514      else return fallback;
515    }
516    if (fallback) return fallback;
517    return { y: scrollY };
518  }
519
520  function restoreAnchor(anchor) {
521    const file = $$('#diffs details.dfile').find((f) => f.dataset.file === anchor.file);
522    let el, offset = anchor.offset;
523    if (file && anchor.on) {
524      const suffix = anchor.suffix && 'on=' + anchor.suffix + '#' + file.id;
525      const layout = $('#diffs').dataset.diff || $('#diffs').dataset.view;
526      const table = $(layout === 'split' && !window.matchMedia?.('(max-width: 720px)').matches ? '.dt.spl' : '.dt.uni', file) || file;
527      if (suffix) el = $('a[href$="' + CSS.escape(suffix) + '"]', table)?.closest('tr');
528    }
529    if (!el && file) { el = file; offset = anchor.headerOffset ?? anchor.offset; }
530    if (el) window.scrollBy({ top: el.getBoundingClientRect().top - offset, behavior: 'instant' });
531    else window.scrollTo({ top: Math.max(0, Math.min(anchor.y, document.documentElement.scrollHeight - innerHeight)), behavior: 'instant' });
532  }
533
534  async function fetchPage(url = location.href, mobileRetry = false) {
535    const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
536    if (!res.ok || res.redirected) throw new Error('MR refresh refused');
537    const page = new DOMParser().parseFromString(await res.text(), 'text/html');
538    if (!$('#diffs[data-mr]', page) || !$('.mr-bar', page) || !$('.hint-live', page)) throw new Error('not an MR page');
539    const unified = core.mobileURL(url, $('#diffs', page).dataset.view,
540      !!window.matchMedia?.('(max-width: 720px)').matches);
541    if (unified && !mobileRetry) return fetchPage(unified, true);
542    return page;
543  }
544
545  // htmx 4's public delete swap invokes recursive before:cleanup (including
546  // hx-sse's abort/reader.cancel and hx-live's binding disposal). Only an empty
547  // string is parsed; the fetched nodes themselves are adopted, never serialized.
548  async function replaceRegion(old, fresh) {
549    if (!old || !fresh) throw new Error('missing MR refresh region');
550    for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus');
551    const marker = document.createComment('MR region');
552    old.before(marker);
553    const remove = () => window.htmx.swap({ sourceElement: old, target: old, text: '',
554      swap: 'delete settle:0 transition:false', push: false, replace: false });
555    if (old.matches('.hint-live')) {
556      window.ermiteLive.resumeStream(fresh);
557      await window.ermiteLive.teardown(remove);
558    } else await remove();
559    const node = document.adoptNode(fresh);
560    marker.replaceWith(node);
561    window.htmx.process(node);
562  }
563
564  async function refreshPage(page) {
565    const regions = ['.mr-footer', '#mr-range', '.mr-bar', '#mr-diff-notices', '#diffs', '#mr-comments', '.rail', '.hint-live'];
566    for (const sel of regions) if (!$(sel) || !$(sel, page)) throw new Error('missing MR region');
567    let anchor = scrollAnchor();
568    const nextDiffs = $('#diffs', page);
569    const mobile = !!window.matchMedia?.('(max-width: 720px)').matches;
570    const chosen = mobile ? 'unified' : ($('#diffs').dataset.diff || $('#diffs').dataset.view);
571    if (nextDiffs.dataset.diff) nextDiffs.dataset.diff = chosen;
572    // A one-layout response must use its own contract, never the old page's.
573    if (mobile && nextDiffs.dataset.view === 'split') throw new Error('mobile needs unified');
574    const anchorFile = $$('#diffs details.dfile', page).find((f) => f.dataset.file === anchor.file);
575    const placeholder = anchorFile && $('.dlazy[data-src]', anchorFile);
576    if (placeholder) {
577      const d = nextDiffs.dataset;
578      const url = placeholder.dataset.src + '&view=' + encodeURIComponent(d.diff || d.view || 'unified') + '&layouts=' + (d.diff ? 'both' : 'one');
579      const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
580      if (!res.ok || res.redirected) throw new Error('anchor window changed');
581      let windowPage = new DOMParser().parseFromString(await res.text(), 'text/html');
582      if (hasDraft()) return false;
583      anchor = scrollAnchor();
584      const windowFiles = $$('details.dfile', windowPage);
585      // If the reader moved outside this window, discard it; lazy loading will
586      // fetch the new position rather than committing an unrelated window.
587      if (windowFiles.some((f) => f.dataset.file === anchor.file)) {
588        const byID = new Map($$('#diffs details.dfile', page).map((f) => [f.id, f]));
589        for (const f of windowFiles) byID.get(f.id)?.replaceWith(f);
590      }
591      windowPage = null;
592    }
593    if (hasDraft()) return false;
594    const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));
595    const folded = $('.r-files')?.hasAttribute('data-folded');
596    const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;
597    const openPanel = $$('[data-mr-tabs] a').findIndex((t) => t.getAttribute('aria-expanded') === 'true');
598    const active = document.activeElement;
599    const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
600    for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
601    ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();
602    $('#diffs', page).dataset.mrStarted = 'true';
603    // The fresh footer comes in with the panels as they are, so that an open
604    // panel does not fold and open again while the other regions swap.
605    const freshFooter = $('.mr-footer', page);
606    if (freshFooter) {
607      freshFooter.setAttribute('data-panels', '');
608      $$('[data-mr-panel]', freshFooter).forEach((p, i) => { p.hidden = i !== openPanel; });
609    }
610    for (const sel of regions) {
611      const fresh = $(sel, page);
612      await replaceRegion($(sel), fresh);
613    }
614    for (const key of ['ref', 'sha', 'paths', 'blob']) {
615      if (page.body.dataset[key] !== undefined) document.body.dataset[key] = page.body.dataset[key];
616    }
617    const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page);
618    if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent;
619    page = null;
620    panels(openPanel);
621    foldFiles(!!folded);
622    if ($('.rail')) $('.rail').scrollTop = railY;
623    if ($('.r-files .files')) $('.r-files .files').scrollTop = filesY;
624    touchComments(false);
625    $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);
626    window.ermite?.refreshFiles?.();
627    watch();
628    if (focus && active !== document.body) {
629      const region = focus.region ? $(focus.region) : document;
630      const menu = focus.menu && document.getElementById(focus.menu);
631      if (menu) menu.open = true;
632      const el = focus.file ? $('a[title="' + CSS.escape(focus.file) + '"]', region) :
633        focus.id ? document.getElementById(focus.id) :
634        focus.href ? $('a[href="' + CSS.escape(focus.href) + '"]', region) :
635        focus.name ? $('[name="' + CSS.escape(focus.name) + '"]', region) :
636        menu?.querySelector(':scope > summary');
637      el?.focus({ preventScroll: true });
638    }
639    restoreAnchor(anchor);
640  }
641
642  function refreshNotice(deferred) {
643    const slot = $('#mr-refresh-status');
644    if (!slot) return;
645    slot.replaceChildren();
646    if (!deferred) {
647      const text = document.createElement('span'); text.className = 'sr'; text.textContent = 'branch updated'; slot.append(text);
648      return;
649    }
650    slot.append('branch updated — refreshing after your comment. ');
651
652  }
653  const headRefresh = refresher({ blocked: hasDraft, fetchPage, apply: refreshPage, notice: refreshNotice,
654    failed: () => window.ermiteLive?.reload() });
655  function headChanged() { if ($('#diffs[data-mr]')) headRefresh.request(); }
656
657  function start() {
658    if (!$('#diffs[data-mr]')) return;
659    if (window.ermiteLive) window.ermiteLive.canReload = () => {
660      if (!hasDraft()) return true;
661      headRefresh.request(); refreshNotice(true); return false;
662    };
663    // Global delegation is installed once; replacement regions have their own
664    // disposable panel/touch hooks below.
665    if ($('#diffs').dataset.mrStarted) return;
666    $('#diffs').dataset.mrStarted = 'true';
667    for (const type of ['input', 'focusout', 'reset', 'submit']) document.addEventListener?.(type, () => headRefresh.resume());
668    document.addEventListener?.('click', (e) => {
669      const cancel = e.target.closest?.('.cform .f-btns a');
670      if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
671      e.preventDefault();
672      const form = cancel.closest('.cform');
673      form.reset();
674      if (form.contains(document.activeElement)) document.activeElement.blur();
675      headRefresh.resume();
676    });
677    panels();
678    // Large diffs may contain only the split table. Ask the existing server
679    // route for unified rather than writing the saved session preference.
680    const narrow = window.matchMedia?.('(max-width: 720px)');
681    const unify = () => {
682      $('#diffs').dataset.mobile = String(!!narrow?.matches);
683      const url = core.mobileURL(location.href, $('#diffs')?.dataset.view, narrow?.matches);
684      if (url) { location.replace(url); return true; }
685      return false;
686    };
687    if (unify()) return;
688    narrow?.addEventListener('change', unify);
689    addEventListener('click', (e) => {
690      if (narrow?.matches && e.target.closest?.('.vbtn[data-view="split"]')) {
691        e.preventDefault();
692        e.stopImmediatePropagation();
693      }
694    }, true);
695    touchComments();
696    let folded = false;
697    try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
698    if (folded) foldFiles(true);
699    // The page answers a refused action from the address of the action: show
700    // the address of the request, so that a reload does not post again.
701    const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
702    if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
703    watch();
704    // A link to a file or a comment that is not loaded yet.
705    const hash = () => {
706      const id = decodeURIComponent(location.hash.slice(1));
707      if (id) reveal(id);
708    };
709    addEventListener('hashchange', hash);
710    hash();
711    let lastLine = null;
712    try { lastLine = sessionStorage.getItem('mr-last-line'); } catch { /* storage optional */ }
713    addEventListener('click', (e) => {
714      const quote = e.target.closest?.('[data-quote]');
715      if (quote) { core.quote(quote); return; }
716      const link = e.target.closest?.('a[href*="on="]');
717      if (!link || e.ctrlKey || e.metaKey || e.altKey) return;
718      const next = e.shiftKey && lastLine && core.rangeURL(lastLine, link.href, location.href);
719      lastLine = link.href;
720      try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ }
721      if (next) { e.preventDefault(); location.assign(next); }
722    });
723  }
724
725  return { core, refresher, hasDraft, fetchPage, scrollAnchor, restoreAnchor, headChanged, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
726})();
727
728if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
729else 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 2

Tags 46

↑↓ moveenter openesc close