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; j / k move in it, Enter opens
13// the file, Esc goes back to the diff.
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 // windowOf is the start of the window of file i (files are f1, f2, ...).
21 windowOf(id, size) {
22 const n = Number(String(id).replace(/^f/, '')) - 1;
23 return Number.isInteger(n) && n >= 0 ? n - (n % size) : -1;
24 },
25 // menuKey says what a key does while the merge menu is open: a mode
26 // (index), the branch deletion, or nothing.
27 menuKey(key, modes) {
28 if (key === 'D') return { toggle: true };
29 if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };
30 return null;
31 },
32 // step moves an index within [0, n).
33 step(i, d, n) {
34 if (n === 0) return -1;
35 if (i < 0) return d > 0 ? 0 : n - 1;
36 return Math.max(0, Math.min(n - 1, i + d));
37 },
38 };
39
40 const $ = (sel, root = document) => root.querySelector(sel);
41 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
42 const loading = new Map(); // src -> promise of the swap
43 let moved = false;
44 let lazyIO = null; // the observer of the placeholders (watch)
45 let revealGen = 0; // the last reveal wins the scroll
46
47 // load fetches the window of a placeholder and swaps its files in. The
48 // promise resolves once they are in the page (or the page said to reload).
49 function load(ph) {
50 const src = ph?.dataset.src;
51 if (!src || moved) return Promise.resolve();
52 if (loading.has(src)) return loading.get(src);
53 for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', '');
54 // The window renders like the page: the layout shown, and both layouts
55 // when the page switches them itself (data-diff) or one (data-view).
56 const d = $('#diffs')?.dataset || {};
57 const view = d.diff || d.view;
58 const layouts = d.diff ? 'both' : d.view ? 'one' : '';
59 const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : '');
60 const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
61 .then(async (res) => {
62 const html = await res.text();
63 if (res.status === 409) return stale(html);
64 if (!res.ok) throw new Error('loading files: ' + res.status);
65 // A window of the old diff after the page went stale stays out.
66 if (!moved) swap(html);
67 })
68 .catch((err) => {
69 loading.delete(src);
70 for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
71 window.ermite?.flash?.(err.message, 4000);
72 });
73 loading.set(src, p);
74 return p;
75 }
76
77 // swap replaces each placeholder file with the file of the same id. A
78 // file the reader folded stays folded.
79 function swap(html) {
80 const t = document.createElement('template');
81 t.innerHTML = html;
82 for (const f of [...t.content.querySelectorAll('details[data-file]')]) {
83 const old = document.getElementById(f.id);
84 if (!old || !old.querySelector('.dlazy')) continue;
85 f.open = old.open;
86 for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p);
87 old.replaceWith(f);
88 window.htmx?.process(f);
89 window.ermite?.watchFile?.(f, old);
90 }
91 }
92
93 // stale: the diff changed under the page. The placeholders say so; no file
94 // of another diff comes in.
95 function stale(html) {
96 moved = true;
97 for (const p of $$('.dlazy')) {
98 p.removeAttribute('data-loading');
99 p.innerHTML = html;
100 }
101 }
102
103 // reveal loads the file of an id (a file "f12" or a comment "c3") when it is
104 // a placeholder, then scrolls to it. It returns false when there is nothing
105 // to load.
106 function reveal(id, scroll = true) {
107 // Any navigation, loaded or not, makes the reveals still loading stale.
108 const gen = ++revealGen;
109 let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
110 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
111 const base = id.replace(/-s$/, '');
112 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
113 }
114 if (!ph) return false;
115 load(ph).then(() => {
116 // A later reveal (another file asked meanwhile) owns the view.
117 if (gen !== revealGen) return;
118 const el = document.getElementById(id);
119 if (!el) return;
120 const d = el.closest('details');
121 if (d) d.open = true;
122 if (!scroll) return;
123 // The files above may have changed size while it loaded (placeholders,
124 // content-visibility): place it, then again once laid out.
125 el.scrollIntoView({ block: 'start' });
126 requestAnimationFrame(() => requestAnimationFrame(() => {
127 if (gen === revealGen) el.scrollIntoView({ block: 'start' });
128 }));
129 });
130 return true;
131 }
132
133 // Placeholders load as they come within two screens of the view; files
134 // that come in are marked in the list of files (ermite.js marks the one in
135 // view).
136 function watch() {
137 if (!('IntersectionObserver' in window)) {
138 for (const p of $$('.dlazy[data-src]')) load(p);
139 return;
140 }
141 lazyIO = new IntersectionObserver((entries) => {
142 for (const e of entries) {
143 if (!e.isIntersecting) continue;
144 lazyIO.unobserve(e.target);
145 load(e.target);
146 }
147 }, { rootMargin: '200% 0px 200% 0px' });
148 for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p);
149 }
150
151 // The merge menu.
152 const menu = () => $('#merge-menu');
153 function openMerge() {
154 const m = menu();
155 if (!m) return false;
156 for (const d of $$('details.menu[open]')) if (d !== m) d.open = false;
157 m.open = true;
158 $('[data-default]', m)?.focus();
159 return true;
160 }
161 function closeMenus() {
162 const open = $$('details.menu[open]');
163 for (const d of open) d.open = false;
164 if (open.length) $('summary', open[0])?.focus();
165 return open.length > 0;
166 }
167 // menuKey handles a key while the merge menu is open; true when it used it.
168 function menuKey(e) {
169 const m = menu();
170 if (!m?.open || e.ctrlKey || e.metaKey || e.altKey) return false;
171 // An open dialog (help, commands) is above the menu: it keeps the keys.
172 if ($('dialog[open]')) return false;
173 // A field outside the menu (a comment being written) keeps its keys.
174 const field = e.target.closest?.('input, textarea, select, [contenteditable]');
175 if (field && !m.contains(field)) return false;
176 const radios = $$('input[name="mode"]', m);
177 const act = core.menuKey(e.key, radios.length);
178 if (!act) return false;
179 e.preventDefault();
180 if (act.toggle) {
181 const box = $('input[name="delete_branch"]', m);
182 if (box) box.checked = !box.checked;
183 } else {
184 radios[act.mode].checked = true;
185 }
186 $('[data-default]', m)?.focus();
187 return true;
188 }
189 function markRead() {
190 const b = $('button[data-read]');
191 if (!b) return false;
192 b.closest('form').requestSubmit(b);
193 return true;
194 }
195
196 // The list of files by keyboard: g f focuses the current file.
197 const links = () => $$('.r-files .files > li:not([hidden]) > a');
198 function focusFiles() {
199 const all = links();
200 if (!all.length) return false;
201 const cur = all.find((a) => a.getAttribute('aria-current') === 'true') || all[0];
202 cur.focus();
203 cur.scrollIntoView({ block: 'nearest' });
204 return true;
205 }
206 // stepFiles moves the focus in the list; false when the focus is not in it.
207 function stepFiles(d) {
208 const all = links();
209 const i = all.indexOf(document.activeElement);
210 if (i < 0) return false;
211 const j = core.step(i, d, all.length);
212 all[j].focus();
213 all[j].scrollIntoView({ block: 'nearest' });
214 return true;
215 }
216 function leaveFiles() {
217 if (!links().includes(document.activeElement)) return false;
218 document.getElementById('main')?.focus({ preventScroll: true });
219 return true;
220 }
221
222 // openFile opens the file of a link of the list (and loads it first).
223 function openFile(id) {
224 const f = document.getElementById(id);
225 if (f) f.open = true;
226 reveal(id);
227 }
228
229 function start() {
230 if (!$('#diffs[data-mr]')) return;
231 watch();
232 // A link to a file or a comment that is not loaded yet.
233 const hash = () => {
234 const id = decodeURIComponent(location.hash.slice(1));
235 if (id) reveal(id);
236 };
237 addEventListener('hashchange', hash);
238 hash();
239 // A click outside an open menu closes it.
240 addEventListener('click', (e) => {
241 for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
242 });
243 }
244
245 return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, openFile };
246})();
247
248if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
249else window.ermiteMR.start();