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 function panels() {
319 disposePanels();
320 const footer = $('#mr-panels'), nav = $('[data-mr-tabs]');
321 if (!footer || !nav) return;
322 const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);
323 nav.setAttribute('role', 'tablist');
324 function select(index, focus = false, fromUser = false) {
325 tabs.forEach((tab, i) => {
326 tab.setAttribute('role', 'tab');
327 tab.setAttribute('aria-controls', panels[i].id);
328 tab.setAttribute('aria-selected', String(i === index));
329 tab.tabIndex = i === index ? 0 : -1;
330 panels[i].setAttribute('role', 'tabpanel');
331 panels[i].tabIndex = 0;
332 panels[i].hidden = i !== index;
333 });
334 if (focus) tabs[index].focus();
335 if (fromUser && location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id);
336 }
337 tabs.forEach((tab, i) => {
338 tab.addEventListener('click', (e) => { e.preventDefault(); select(i, false, true); });
339 tab.addEventListener('keydown', (e) => {
340 const next = core.tabIndex(e.key, i, tabs.length);
341 if (next === null) return;
342 e.preventDefault(); select(next, true, true);
343 });
344 });
345 function hash() {
346 const h = location.hash;
347 const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1;
348 if (index < 0) return;
349 footer.open = true; select(index);
350 }
351 // A repeated click on the same hash must also reopen a folded footer.
352 const checksClick = (e) => {
353 if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); }
354 };
355 document.addEventListener('click', checksClick);
356 let observer;
357 const summary = $('#mr-ck');
358 if (summary) {
359 // The live stream replaces #mr-ck as a whole: watch only the children of
360 // its parent, and write the label only when it changes. hx-live rewrites
361 // other text in the same bar on every mutation of the document; a write
362 // here on each of them would wake hx-live again, a loop that never yields.
363 const sync = () => {
364 const label = $('[data-checks-label]', nav);
365 const text = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks';
366 if (label && label.textContent !== text) label.textContent = text;
367 };
368 observer = new MutationObserver(sync);
369 observer.observe(summary.parentElement, { childList: true });
370 sync();
371 }
372 select(0); hash();
373 addEventListener('hashchange', hash);
374 disposePanels = () => {
375 document.removeEventListener?.('click', checksClick);
376 window.removeEventListener?.('hashchange', hash);
377 observer?.disconnect?.();
378 };
379 }
380
381 let disposeTouch = () => {};
382 function touchComments(scroll = true) {
383 disposeTouch();
384 const diffs = $('#diffs');
385 const bar = document.createElement('div');
386 bar.className = 'mr-touch-range';
387 const button = document.createElement('button');
388 button.type = 'button';
389 button.className = 'btn';
390 button.textContent = 'Select line range';
391 button.setAttribute('aria-pressed', 'false');
392 const status = document.createElement('span');
393 status.setAttribute('role', 'status');
394 bar.append(button, status);
395 diffs.prepend(bar);
396 refreshTouchComments = () => { bar.hidden = !$('.ln a[href*="on="]', diffs); };
397 refreshTouchComments();
398 const touch = window.matchMedia?.('(pointer: coarse), (hover: none)');
399 let enabled = false, start = null, marker = null;
400 const clear = () => { marker?.removeAttribute('data-range-start'); marker = null; start = null; };
401 button.addEventListener('click', () => {
402 enabled = !enabled;
403 clear();
404 button.setAttribute('aria-pressed', String(enabled));
405 status.textContent = enabled ? 'Tap the first line number, then the last. Tap the same line for one line.' : '';
406 });
407 diffs.addEventListener('click', (e) => {
408 const link = e.target.closest?.('.ln a[href*="on="]');
409 if (!touch?.matches || !enabled || !link || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
410 e.preventDefault();
411 e.stopPropagation(); // do not alter the desktop shift-click anchor
412 const next = core.touchRange(start, link.href, location.href);
413 clear();
414 if (next.url) { location.assign(next.url); return; }
415 start = next.start;
416 marker = link;
417 marker.setAttribute('data-range-start', '');
418 status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`;
419 });
420 const changed = () => {
421 if (touch.matches) return;
422 enabled = false;
423 clear();
424 button.setAttribute('aria-pressed', 'false');
425 status.textContent = '';
426 };
427 touch?.addEventListener('change', changed);
428 disposeTouch = () => { touch?.removeEventListener?.('change', changed); bar.remove?.(); };
429 // The server opens the line form. Scroll after layout without stealing
430 // focus or forcing a mobile keyboard to appear.
431 if (scroll && new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => {
432 const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length);
433 form?.scrollIntoView({ block: 'center' });
434 });
435 }
436
437 // A refresh scheduler has one request in flight, a trailing debounce, and
438 // a dirty bit for pushes received during that request. Drafts are checked
439 // again after the fetch: typing while the network is slow is safe too.
440 function refresher({ blocked, fetchPage, apply, notice, failed, delay = 300 }) {
441 let timer, busy = false, pending = false;
442 const schedule = () => { clearTimeout(timer); timer = setTimeout(run, delay); };
443 async function run() {
444 if (busy || !pending) return;
445 if (blocked()) { notice(true); return; }
446 busy = true;
447 pending = false;
448 try {
449 const page = await fetchPage();
450 if (blocked()) { pending = true; notice(true); return; }
451 if (await apply(page) === false) { pending = true; notice(true); }
452 else notice(false);
453 } catch { if (blocked()) { pending = true; notice(true); } else failed(); }
454 finally {
455 busy = false;
456 if (pending && !blocked()) schedule();
457 }
458 }
459 return {
460 request() { pending = true; schedule(); },
461 resume() { if (pending) schedule(); },
462 pending() { return pending || busy; },
463 };
464 }
465
466 function hasDraft() {
467 // Only form controls, never diff rows. Inline edits live in .cmt forms; the
468 // merge form (d-acts) holds a chosen mode and the delete-branch box.
469 return $$(':is(form.cform, .cmt form, form.d-acts) :is(textarea, input, select)').some((t) => {
470 if (['hidden', 'submit', 'button', 'reset', 'image'].includes(t.type)) return false;
471 if (document.activeElement === t) return true;
472 if (t.type === 'radio' || t.type === 'checkbox') return t.checked !== t.defaultChecked;
473 if (t.tagName === 'SELECT') return [...t.options].some((o) => o.selected !== o.defaultSelected);
474 return t.value !== t.defaultValue;
475 }) ||
476 !!document.activeElement?.closest?.('.cform');
477 }
478
479 // Path + side + line is stable when files are inserted before this one;
480 // fN is only a positional anchor. Hidden unified/split rows are ignored.
481 function scrollAnchor() {
482 const top = Math.max(0, $('.hdr')?.getBoundingClientRect().bottom || 0,
483 $('.mr-bar')?.getBoundingClientRect().bottom || 0);
484 const diffs = $('#diffs');
485 const bounds = diffs.getBoundingClientRect();
486 const x = Math.max(1, Math.min(innerWidth - 1, bounds.left + Math.min(100, bounds.width / 2)));
487 // A bounded number of probes, independent of the number of diff lines.
488 let y = top + 2, fallback;
489 for (let probe = 0; probe < 6 && y < innerHeight; probe++) {
490 const hit = document.elementFromPoint(x, y);
491 const file = hit?.closest('details.dfile');
492 if (!file || !diffs.contains(file)) { y += 32; continue; }
493 const row = hit.closest('tr');
494 const link = row && $('.ln a[href*="on="]', row);
495 const header = $(':scope > summary', file);
496 const headerOffset = file.getBoundingClientRect().top;
497 fallback = { file: file.dataset.file, offset: headerOffset, headerOffset, y: scrollY };
498 if (link) return { ...fallback,
499 on: new URL(link.href, location.href).searchParams.get('on'),
500 suffix: link.getAttribute('href').split('on=')[1].split('#')[0],
501 offset: row.getBoundingClientRect().top };
502 if (file.open && header) y = Math.max(y + 24, header.getBoundingClientRect().bottom + 2);
503 else return fallback;
504 }
505 if (fallback) return fallback;
506 return { y: scrollY };
507 }
508
509 function restoreAnchor(anchor) {
510 const file = $$('#diffs details.dfile').find((f) => f.dataset.file === anchor.file);
511 let el, offset = anchor.offset;
512 if (file && anchor.on) {
513 const suffix = anchor.suffix && 'on=' + anchor.suffix + '#' + file.id;
514 const layout = $('#diffs').dataset.diff || $('#diffs').dataset.view;
515 const table = $(layout === 'split' && !window.matchMedia?.('(max-width: 720px)').matches ? '.dt.spl' : '.dt.uni', file) || file;
516 if (suffix) el = $('a[href$="' + CSS.escape(suffix) + '"]', table)?.closest('tr');
517 }
518 if (!el && file) { el = file; offset = anchor.headerOffset ?? anchor.offset; }
519 if (el) window.scrollBy({ top: el.getBoundingClientRect().top - offset, behavior: 'instant' });
520 else window.scrollTo({ top: Math.max(0, Math.min(anchor.y, document.documentElement.scrollHeight - innerHeight)), behavior: 'instant' });
521 }
522
523 async function fetchPage(url = location.href, mobileRetry = false) {
524 const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
525 if (!res.ok || res.redirected) throw new Error('MR refresh refused');
526 const page = new DOMParser().parseFromString(await res.text(), 'text/html');
527 if (!$('#diffs[data-mr]', page) || !$('.mr-bar', page) || !$('.hint-live', page)) throw new Error('not an MR page');
528 const unified = core.mobileURL(url, $('#diffs', page).dataset.view,
529 !!window.matchMedia?.('(max-width: 720px)').matches);
530 if (unified && !mobileRetry) return fetchPage(unified, true);
531 return page;
532 }
533
534 // htmx 4's public delete swap invokes recursive before:cleanup (including
535 // hx-sse's abort/reader.cancel and hx-live's binding disposal). Only an empty
536 // string is parsed; the fetched nodes themselves are adopted, never serialized.
537 async function replaceRegion(old, fresh) {
538 if (!old || !fresh) throw new Error('missing MR refresh region');
539 for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus');
540 const marker = document.createComment('MR region');
541 old.before(marker);
542 const remove = () => window.htmx.swap({ sourceElement: old, target: old, text: '',
543 swap: 'delete settle:0 transition:false', push: false, replace: false });
544 if (old.matches('.hint-live')) {
545 window.ermiteLive.resumeStream(fresh);
546 await window.ermiteLive.teardown(remove);
547 } else await remove();
548 const node = document.adoptNode(fresh);
549 marker.replaceWith(node);
550 window.htmx.process(node);
551 }
552
553 async function refreshPage(page) {
554 const regions = ['.mr-footer', '#mr-range', '.mr-bar', '#mr-diff-notices', '#diffs', '#mr-comments', '.rail', '.hint-live'];
555 for (const sel of regions) if (!$(sel) || !$(sel, page)) throw new Error('missing MR region');
556 let anchor = scrollAnchor();
557 const nextDiffs = $('#diffs', page);
558 const mobile = !!window.matchMedia?.('(max-width: 720px)').matches;
559 const chosen = mobile ? 'unified' : ($('#diffs').dataset.diff || $('#diffs').dataset.view);
560 if (nextDiffs.dataset.diff) nextDiffs.dataset.diff = chosen;
561 // A one-layout response must use its own contract, never the old page's.
562 if (mobile && nextDiffs.dataset.view === 'split') throw new Error('mobile needs unified');
563 const anchorFile = $$('#diffs details.dfile', page).find((f) => f.dataset.file === anchor.file);
564 const placeholder = anchorFile && $('.dlazy[data-src]', anchorFile);
565 if (placeholder) {
566 const d = nextDiffs.dataset;
567 const url = placeholder.dataset.src + '&view=' + encodeURIComponent(d.diff || d.view || 'unified') + '&layouts=' + (d.diff ? 'both' : 'one');
568 const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
569 if (!res.ok || res.redirected) throw new Error('anchor window changed');
570 let windowPage = new DOMParser().parseFromString(await res.text(), 'text/html');
571 if (hasDraft()) return false;
572 anchor = scrollAnchor();
573 const windowFiles = $$('details.dfile', windowPage);
574 // If the reader moved outside this window, discard it; lazy loading will
575 // fetch the new position rather than committing an unrelated window.
576 if (windowFiles.some((f) => f.dataset.file === anchor.file)) {
577 const byID = new Map($$('#diffs details.dfile', page).map((f) => [f.id, f]));
578 for (const f of windowFiles) byID.get(f.id)?.replaceWith(f);
579 }
580 windowPage = null;
581 }
582 if (hasDraft()) return false;
583 const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));
584 const folded = $('.r-files')?.hasAttribute('data-folded');
585 const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;
586 const footerOpen = $('#mr-panels')?.open;
587 const tab = $('[data-mr-tabs] [aria-selected="true"]')?.id;
588 const active = document.activeElement;
589 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') };
590 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
591 ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();
592 $('#diffs', page).dataset.mrStarted = 'true';
593 for (const sel of regions) {
594 const fresh = $(sel, page);
595 await replaceRegion($(sel), fresh);
596 }
597 for (const key of ['ref', 'sha', 'paths', 'blob']) {
598 if (page.body.dataset[key] !== undefined) document.body.dataset[key] = page.body.dataset[key];
599 }
600 const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page);
601 if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent;
602 page = null;
603 panels();
604 if (tab) {
605 const oldURL = location.href;
606 document.getElementById(tab)?.click();
607 if (location.href !== oldURL) history.replaceState(history.state, '', oldURL);
608 }
609 if ($('#mr-panels')) $('#mr-panels').open = footerOpen;
610 foldFiles(!!folded);
611 if ($('.rail')) $('.rail').scrollTop = railY;
612 if ($('.r-files .files')) $('.r-files .files').scrollTop = filesY;
613 touchComments(false);
614 $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);
615 window.ermite?.refreshFiles?.();
616 watch();
617 if (focus && active !== document.body) {
618 const region = focus.region ? $(focus.region) : document;
619 const menu = focus.menu && document.getElementById(focus.menu);
620 if (menu) menu.open = true;
621 const el = focus.file ? $('a[title="' + CSS.escape(focus.file) + '"]', region) :
622 focus.id ? document.getElementById(focus.id) :
623 focus.href ? $('a[href="' + CSS.escape(focus.href) + '"]', region) :
624 focus.name ? $('[name="' + CSS.escape(focus.name) + '"]', region) :
625 menu?.querySelector(':scope > summary');
626 el?.focus({ preventScroll: true });
627 }
628 restoreAnchor(anchor);
629 }
630
631 function refreshNotice(deferred) {
632 const slot = $('#mr-refresh-status');
633 if (!slot) return;
634 slot.replaceChildren();
635 if (!deferred) {
636 const text = document.createElement('span'); text.className = 'sr'; text.textContent = 'branch updated'; slot.append(text);
637 return;
638 }
639 slot.append('branch updated — refreshing after your comment. ');
640
641 }
642 const headRefresh = refresher({ blocked: hasDraft, fetchPage, apply: refreshPage, notice: refreshNotice,
643 failed: () => window.ermiteLive?.reload() });
644 function headChanged() { if ($('#diffs[data-mr]')) headRefresh.request(); }
645
646 function start() {
647 if (!$('#diffs[data-mr]')) return;
648 if (window.ermiteLive) window.ermiteLive.canReload = () => {
649 if (!hasDraft()) return true;
650 headRefresh.request(); refreshNotice(true); return false;
651 };
652 // Global delegation is installed once; replacement regions have their own
653 // disposable panel/touch hooks below.
654 if ($('#diffs').dataset.mrStarted) return;
655 $('#diffs').dataset.mrStarted = 'true';
656 for (const type of ['input', 'focusout', 'reset', 'submit']) document.addEventListener?.(type, () => headRefresh.resume());
657 document.addEventListener?.('click', (e) => {
658 const cancel = e.target.closest?.('.cform .f-btns a');
659 if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
660 e.preventDefault();
661 const form = cancel.closest('.cform');
662 form.reset();
663 if (form.contains(document.activeElement)) document.activeElement.blur();
664 headRefresh.resume();
665 });
666 panels();
667 // Large diffs may contain only the split table. Ask the existing server
668 // route for unified rather than writing the saved session preference.
669 const narrow = window.matchMedia?.('(max-width: 720px)');
670 const unify = () => {
671 $('#diffs').dataset.mobile = String(!!narrow?.matches);
672 const url = core.mobileURL(location.href, $('#diffs')?.dataset.view, narrow?.matches);
673 if (url) { location.replace(url); return true; }
674 return false;
675 };
676 if (unify()) return;
677 narrow?.addEventListener('change', unify);
678 addEventListener('click', (e) => {
679 if (narrow?.matches && e.target.closest?.('.vbtn[data-view="split"]')) {
680 e.preventDefault();
681 e.stopImmediatePropagation();
682 }
683 }, true);
684 touchComments();
685 let folded = false;
686 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
687 if (folded) foldFiles(true);
688 // The page answers a refused action from the address of the action: show
689 // the address of the request, so that a reload does not post again.
690 const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
691 if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
692 watch();
693 // A link to a file or a comment that is not loaded yet.
694 const hash = () => {
695 const id = decodeURIComponent(location.hash.slice(1));
696 if (id) reveal(id);
697 };
698 addEventListener('hashchange', hash);
699 hash();
700 let lastLine = null;
701 try { lastLine = sessionStorage.getItem('mr-last-line'); } catch { /* storage optional */ }
702 addEventListener('click', (e) => {
703 const quote = e.target.closest?.('[data-quote]');
704 if (quote) { core.quote(quote); return; }
705 const link = e.target.closest?.('a[href*="on="]');
706 if (!link || e.ctrlKey || e.metaKey || e.altKey) return;
707 const next = e.shiftKey && lastLine && core.rangeURL(lastLine, link.href, location.href);
708 lastLine = link.href;
709 try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ }
710 if (next) { e.preventDefault(); location.assign(next); }
711 });
712 }
713
714 return { core, refresher, hasDraft, fetchPage, scrollAnchor, restoreAnchor, headChanged, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
715})();
716
717if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
718else window.ermiteMR.start();