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