Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/web/static/app.js

Line attribution on main · 789 line(s)

306f36fc
Keenan Nemetz
12d ago
1 // Page behaviour the server does not render: clipboard copy, select-to-submit,
2 // and a confirmation on destructive forms. The pages need this file.
c717d4eb
Keenan Nemetz
13d ago
3
4 // copyText puts a string on the clipboard, falling back to a hidden textarea
5 // where the clipboard API is unavailable (an insecure origin, an older browser).
6 // The callback runs when the copy is believed to have happened.
7 const copyText = (value, done) => {
8 const finish = done || (() => {});
9 if (navigator.clipboard) {
10 navigator.clipboard.writeText(value).then(finish, () => {});
11 return;
12 }
13 const area = document.createElement('textarea');
14 area.value = value;
15 document.body.appendChild(area);
16 area.select();
17 document.execCommand('copy');
18 area.remove();
19 finish();
20 };
21
802e0e52
Keenan Nemetz
5d ago
22 // fitTextarea gives a prefilled edit field the height of its content. The field
23 // stays resizable: this only picks the starting height, and typing grows it again.
24 const fitTextarea = (area) => {
25 if (!area || area.hidden || area.offsetParent === null) return;
26 area.style.height = 'auto';
27 area.style.height = `${area.scrollHeight}px`;
28 };
29
30 (() => {
31 const init = (scope = document) => {
32 if (scope instanceof Element && scope.matches('[data-autosize]')) fitTextarea(scope);
33 if (scope && typeof scope.querySelectorAll === 'function') {
34 scope.querySelectorAll('[data-autosize]').forEach(fitTextarea);
35 }
36 };
37 document.addEventListener('input', (event) => {
38 const area = event.target.closest && event.target.closest('[data-autosize]');
39 if (area) fitTextarea(area);
40 });
41 init();
42 document.body.addEventListener('htmx:afterSwap', (event) => init(event.target));
43 })();
44
f09ef39e
Keenan Nemetz
15d ago
45 (() => {
46 document.addEventListener('click', (event) => {
802e0e52
Keenan Nemetz
5d ago
47 const button = event.target.closest('[data-copy], [data-copy-query]');
f09ef39e
Keenan Nemetz
15d ago
48 if (!button) return;
802e0e52
Keenan Nemetz
5d ago
49 let value = button.getAttribute('data-copy');
50 const query = button.getAttribute('data-copy-query');
51 if (query !== null) {
52 // Prose is query-escaped on the way into the attribute, so a script URL in
53 // an issue or comment cannot appear verbatim in the page.
54 try {
55 value = decodeURIComponent(query.replace(/\+/g, ' '));
56 } catch (_) {
57 value = query;
58 }
59 }
f09ef39e
Keenan Nemetz
15d ago
60 if (!value) return;
61 event.preventDefault();
c717d4eb
Keenan Nemetz
13d ago
62 copyText(value, () => {
816cc543
Keenan Nemetz
11d ago
63 // innerHTML, not textContent: a copy button may carry an icon, and
64 // restoring textContent would drop it. The original markup is kept on the
65 // element so a second copy before the label resets cannot capture
66 // "Copied" as the label to restore.
67 let original = button.dataset.copyLabel;
68 if (original === undefined) {
69 original = button.innerHTML;
70 button.dataset.copyLabel = original;
71 }
f09ef39e
Keenan Nemetz
15d ago
72 button.textContent = 'Copied';
816cc543
Keenan Nemetz
11d ago
73 setTimeout(() => { button.innerHTML = original; }, 1200);
c717d4eb
Keenan Nemetz
13d ago
74 });
f09ef39e
Keenan Nemetz
15d ago
75 });
76
77 document.addEventListener('change', (event) => {
78 const select = event.target.closest('[data-autosubmit]');
55615c7a
Keenan Nemetz
11d ago
79 // requestSubmit, not submit: it fires the submit event, so htmx can swap the
80 // filtered pane instead of the page navigating. A plain form.submit() skips
81 // that event and every auto-applied filter would be a full page load.
82 if (select && select.form) select.form.requestSubmit();
f09ef39e
Keenan Nemetz
15d ago
83 });
84
85 document.addEventListener('submit', (event) => {
86 const message = event.target.getAttribute('data-confirm');
87 if (message && !window.confirm(message)) event.preventDefault();
88 });
89 })();
90
3a068983
Keenan Nemetz
11d ago
91 // A form post is a server round trip that can take a while: landing a change
92 // request writes the merge, a repository rename moves the store. The submit
93 // button doubles as the progress indicator. It is disabled before the request
94 // leaves, so a second click cannot run the same mutation twice, and after a short
95 // delay its icon gives way to a spinner. The delay keeps a fast save from
96 // flashing a loader. A successful write answers with a redirect or a new page,
97 // so the next page is the reset; pageshow puts the buttons back when a back
98 // navigation restores this page from the back/forward cache. GET forms are left
99 // alone: they search and filter, and do not need a wait state. A form with
100 // hx-post runs through HTMX, which has its own disabled-element handling, and a
101 // form with data-no-busy opts out.
102 (() => {
103 const DELAY = 150;
104
105 const reset = (root = document) => {
106 if (!root || typeof root.querySelectorAll !== 'function') return;
107 root.querySelectorAll('button[data-loading]').forEach((button) => {
108 if (button.loadingTimer) {
109 clearTimeout(button.loadingTimer);
110 button.loadingTimer = 0;
111 }
112 button.removeAttribute('data-loading');
113 button.removeAttribute('aria-busy');
114 button.classList.remove('spinning');
115 button.disabled = false;
116 const mark = button.firstElementChild;
117 if (mark && mark.classList.contains('spinner')) mark.remove();
118 });
119 };
120
121 const begin = (button) => {
122 button.disabled = true;
123 button.setAttribute('aria-busy', 'true');
124 // Set the marker now so a cancel that arrives before the spinner is due can
125 // still find and restore the button. Only the spinner waits for DELAY.
126 button.setAttribute('data-loading', '');
127 button.loadingTimer = setTimeout(() => {
128 button.loadingTimer = 0;
129 const mark = document.createElement('span');
130 mark.className = 'spinner';
131 mark.setAttribute('aria-hidden', 'true');
132 button.classList.add('spinning');
133 button.insertBefore(mark, button.firstChild);
134 }, DELAY);
135 };
136
137 document.addEventListener('submit', (event) => {
138 const form = event.target;
139 if (!(form instanceof HTMLFormElement)) return;
140 if (form.method.toLowerCase() !== 'post') return;
141 if (form.hasAttribute('data-no-busy')) return;
142 if (form.hasAttribute('hx-post') || form.hasAttribute('hx-put') ||
143 form.hasAttribute('hx-patch') || form.hasAttribute('hx-delete')) return;
144 // The confirmation handler above has already had its say. Checking here also
145 // covers any other listener that runs before this one and takes over.
146 if (event.defaultPrevented) return;
147 const buttons = Array.from(form.querySelectorAll('button[type="submit"], button:not([type])'))
148 .filter((button) => !button.disabled);
149 if (!buttons.length) return;
150 buttons.forEach(begin);
151 // A listener registered after this one may still cancel the submission.
152 // If it does, put the buttons back rather than leaving them disabled.
153 setTimeout(() => {
154 if (event.defaultPrevented) reset(form);
155 }, 0);
156 });
157
158 window.addEventListener('pageshow', () => reset());
159 })();
160
e7982ac5
Keenan Nemetz
12d ago
161 // Syntax highlighting. The server sends each blob and each diff side as plain
162 // lines, so the page is complete and scrollable before this runs; the tokens come
163 // from a worker (static/prism-worker.js) so a large file never blocks the main
164 // thread. A file whose language has no grammar keeps the text it was served with.
306f36fc
Keenan Nemetz
12d ago
165 (() => {
166 let worker = null;
167 let nextId = 0;
168 const pending = new Map();
169
170 // start creates the worker on first use: a page without a blob never pays for it.
171 const start = () => {
172 if (worker) return worker;
173 if (typeof Worker === 'undefined') return null;
174 try {
175 worker = new Worker('/static/prism-worker.js');
176 } catch (err) {
177 return null;
178 }
179 worker.addEventListener('message', (event) => {
180 const job = pending.get(event.data.id);
181 if (!job) return;
182 pending.delete(event.data.id);
183 const lines = event.data.lines;
184 // A grammar that answered nothing, or a line count that does not match the
185 // table, leaves the server's text alone rather than mangling it.
e7982ac5
Keenan Nemetz
12d ago
186 if (lines && lines.length === job.cells.length) {
187 for (let i = 0; i < lines.length; i++) job.cells[i].innerHTML = lines[i];
188 }
189 // A diff file is queued once per side, so the block is finished only when
190 // the last side has answered (or declined).
191 if (job.state) {
192 job.state.pending -= 1;
193 if (job.state.pending > 0) return;
194 }
306f36fc
Keenan Nemetz
12d ago
195 job.block.dataset.highlighted = 'applied';
196 });
197 worker.addEventListener('error', () => {
198 worker.terminate();
199 worker = null;
200 // Let a later init try again with a fresh worker rather than leaving these
201 // blocks marked as queued forever.
202 for (const job of pending.values()) delete job.block.dataset.highlighted;
203 pending.clear();
204 });
205 return worker;
206 };
207
208 const queue = (block) => {
209 if (block.dataset.highlighted) return;
210 const lang = block.getAttribute('data-highlight');
211 const cells = Array.from(block.querySelectorAll('td.code'));
212 if (!lang || !cells.length) return;
e7982ac5
Keenan Nemetz
12d ago
213 // A diff interleaves the old and new sides. Highlighting them as one stream
214 // lets a multiline comment or string started on one side colour lines from
215 // the other, so each side is its own job. A blob has no side and is one job.
216 const groups = new Map();
217 for (const cell of cells) {
218 const side = cell.getAttribute('data-highlight-side') || '';
219 if (!groups.has(side)) groups.set(side, []);
220 groups.get(side).push(cell);
221 }
306f36fc
Keenan Nemetz
12d ago
222 const w = start();
223 if (!w) return;
224 block.dataset.highlighted = 'queued';
e7982ac5
Keenan Nemetz
12d ago
225 const state = { pending: groups.size };
226 for (const group of groups.values()) {
227 const id = ++nextId;
228 pending.set(id, { block, cells: group, state });
229 w.postMessage({ id, lang, code: group.map((cell) => cell.textContent).join('\n') });
230 }
306f36fc
Keenan Nemetz
12d ago
231 };
232
233 const init = (scope = document) => {
234 if (scope instanceof Element && scope.matches('[data-highlight]')) queue(scope);
235 if (scope && typeof scope.querySelectorAll === 'function') {
236 scope.querySelectorAll('[data-highlight]').forEach(queue);
237 }
238 };
239
240 init();
241 // The code browser swaps the open file into the pane; a new blob is a new job.
242 document.body.addEventListener('htmx:afterSwap', (event) => init(event.target));
243 })();
244
f6f4b1db
Keenan Nemetz
15d ago
245 // One discussion composer per page. Reply buttons only select their target; the
246 // visible "Replying to ..." chip can be cleared without losing the draft.
247 (() => {
248 document.addEventListener('click', (event) => {
249 const pick = event.target.closest('[data-reply-to]');
250 if (pick) {
251 event.preventDefault();
252 const form = document.querySelector('.discussion-composer');
253 if (!form) return;
254 const input = form.querySelector('[data-reply-input]');
255 const box = form.querySelector('[data-reply-target]');
256 const name = form.querySelector('[data-reply-name]');
257 if (!input || !box || !name) return;
258 input.value = pick.dataset.replyTo || '';
259 name.textContent = pick.dataset.replyAuthor || '';
260 box.hidden = false;
261 const textarea = form.querySelector('textarea[name="body"]');
262 form.scrollIntoView({ behavior: 'smooth', block: 'end' });
263 if (textarea) textarea.focus({ preventScroll: true });
264 return;
265 }
266 const clear = event.target.closest('[data-reply-clear]');
267 if (!clear) return;
268 const form = clear.closest('.discussion-composer');
269 if (!form) return;
270 const input = form.querySelector('[data-reply-input]');
271 const box = form.querySelector('[data-reply-target]');
272 const name = form.querySelector('[data-reply-name]');
273 if (input) input.value = '';
274 if (name) name.textContent = '';
275 if (box) box.hidden = true;
276 });
277 })();
278
279 // Inline review comments: the composer is inserted beneath a diff line. Cancel
280 // removes it, Escape closes it, and opening another line keeps one composer.
281 (() => {
282 const close = (row) => { if (row) row.remove(); };
283 document.addEventListener('click', (event) => {
284 if (event.target.closest('[data-inline-comment-cancel]')) {
285 close(event.target.closest('tr.inline-comment'));
286 } else if (event.target.closest('[data-inline-comment-add]')) {
287 document.querySelectorAll('tr.inline-comment').forEach(close);
288 }
289 });
290 document.addEventListener('keydown', (event) => {
291 if (event.key !== 'Escape') return;
292 const form = event.target.closest('.inline-comment-form');
293 if (form) close(form.closest('tr.inline-comment'));
294 });
295 })();
296
306f36fc
Keenan Nemetz
12d ago
297 // Label token input: labels become removable chips and existing labels are
298 // offered as suggestions. The chips are hidden inputs, so this is what submits
299 // the set.
f09ef39e
Keenan Nemetz
15d ago
300 (() => {
301 const setup = (root) => {
302 const tokens = root.querySelector('[data-label-tokens]');
303 const entry = root.querySelector('[data-label-entry]');
304 const suggest = root.querySelector('[data-label-suggest]');
305 if (!tokens || !entry || !suggest) return;
306 const options = Array.from(root.querySelectorAll('[data-label-option]'));
307 const itemName = root.dataset.labelItem || 'label';
308
309 const selected = () => new Set(
310 Array.from(tokens.querySelectorAll(`input[type="hidden"][name="${itemName}"]`)).map((i) => i.value),
311 );
312
313 const notify = () => {
314 const form = root.closest('form');
315 if (form) form.dispatchEvent(new Event('token-change', { bubbles: true }));
316 };
317
318 const filter = () => {
319 const query = entry.value.trim().toLowerCase();
320 const have = selected();
321 let visible = false;
322 for (const option of options) {
323 const label = option.dataset.labelOption || '';
324 const show = !have.has(label) && (!query || label.toLowerCase().includes(query));
325 option.hidden = !show;
326 if (show) visible = true;
327 }
328 suggest.hidden = !visible || document.activeElement !== entry;
329 };
330
331 const add = (value, focus = true, announce = true) => {
332 let changed = false;
333 for (const part of String(value).split(',')) {
334 const label = part.trim();
335 if (!label || selected().has(label)) continue;
336 changed = true;
337 const token = document.createElement('span');
338 token.className = 'label-token';
339 token.dataset.labelToken = label;
340 token.append(document.createTextNode(label));
341 const remove = document.createElement('button');
342 remove.type = 'button';
343 remove.className = 'label-remove';
344 remove.setAttribute('aria-label', `Remove ${label}`);
345 remove.textContent = '×';
346 remove.addEventListener('click', () => { token.remove(); filter(); notify(); });
347 const hidden = document.createElement('input');
348 hidden.type = 'hidden';
349 hidden.name = itemName;
350 hidden.value = label;
351 token.append(remove, hidden);
352 tokens.insertBefore(token, entry);
353 }
354 entry.value = '';
355 filter();
356 if (announce && changed) notify();
357 if (focus) entry.focus();
358 };
359
360 const initial = entry.value;
361 entry.value = '';
362 if (initial.trim()) add(initial, false, false);
363
364 tokens.addEventListener('click', (event) => {
365 if (event.target === tokens) entry.focus();
366 });
367 entry.addEventListener('input', filter);
368 entry.addEventListener('focus', filter);
369 entry.addEventListener('keydown', (event) => {
370 if (event.key === 'Enter' || event.key === ',') {
371 event.preventDefault();
372 add(entry.value);
373 } else if (event.key === 'Backspace' && entry.value === '') {
374 const last = tokens.querySelector('.label-token:last-of-type');
375 if (last) {
376 last.remove();
377 filter();
378 notify();
379 }
380 } else if (event.key === 'Escape') {
381 suggest.hidden = true;
382 }
383 });
384 entry.addEventListener('blur', () => {
385 if (entry.value.trim() !== '') add(entry.value, false);
386 window.setTimeout(() => { suggest.hidden = true; }, 120);
387 });
388 for (const option of options) {
389 option.addEventListener('mousedown', (event) => event.preventDefault());
390 option.addEventListener('click', () => { add(option.dataset.labelOption); entry.focus(); });
391 }
392 filter();
393 };
394
395 const init = (scope = document) => {
396 if (scope instanceof Element && scope.matches('[data-label-input]')) setup(scope);
397 if (scope && typeof scope.querySelectorAll === 'function') {
398 scope.querySelectorAll('[data-label-input]').forEach(setup);
399 }
400 };
401 init();
402 // HTMX replaces a sidebar section with a fresh token input; wire it up again.
403 document.body.addEventListener('htmx:afterSwap', (event) => init(event.target));
404 })();
a464f892
Keenan Nemetz
15d ago
405
f6f4b1db
Keenan Nemetz
15d ago
406 // Comment card menus: one small menu holds Reply, Copy, Edit and Delete. The
407 // edit form is part of the card, so opening it does not navigate away.
408 (() => {
409 const closeMenus = (except) => {
410 document.querySelectorAll('[data-comment-menu]').forEach((menu) => {
411 const list = menu.querySelector('[data-comment-menu-list]');
412 const toggle = menu.querySelector('[data-comment-menu-toggle]');
413 if (!list || list === except) return;
414 list.hidden = true;
415 if (toggle) toggle.setAttribute('aria-expanded', 'false');
416 });
417 };
418 document.addEventListener('click', (event) => {
419 const toggle = event.target.closest('[data-comment-menu-toggle]');
420 if (toggle) {
421 event.preventDefault();
422 const menu = toggle.closest('[data-comment-menu]');
423 const list = menu && menu.querySelector('[data-comment-menu-list]');
424 if (!list) return;
425 const open = list.hidden;
426 closeMenus(list);
427 list.hidden = !open;
428 toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
429 return;
430 }
431
432 const item = event.target.closest('[data-comment-menu-list] button, [data-comment-menu-list] a');
433 if (item) {
434 if (item.hasAttribute('data-comment-edit')) {
435 const article = item.closest('.comment');
436 const form = article && article.querySelector('[data-comment-edit-form]');
437 if (article && form) {
438 article.classList.add('editing');
439 form.hidden = false;
440 const textarea = form.querySelector('textarea');
802e0e52
Keenan Nemetz
5d ago
441 if (textarea) {
442 fitTextarea(textarea);
443 textarea.focus();
444 }
f6f4b1db
Keenan Nemetz
15d ago
445 }
446 } else if (item.hasAttribute('data-copy-link')) {
447 const id = item.getAttribute('data-copy-link');
c717d4eb
Keenan Nemetz
13d ago
448 copyText(window.location.href.split('#')[0] + '#c-' + id);
f6f4b1db
Keenan Nemetz
15d ago
449 }
450 if (item.type !== 'submit') closeMenus();
451 return;
452 }
453
454 if (!event.target.closest('[data-comment-menu]')) closeMenus();
455 const cancel = event.target.closest('[data-comment-edit-cancel]');
456 if (cancel) {
457 const article = cancel.closest('.comment');
458 const form = article && article.querySelector('[data-comment-edit-form]');
459 if (article && form) {
460 article.classList.remove('editing');
461 form.hidden = true;
462 }
463 }
464 });
465 document.addEventListener('submit', (event) => {
466 if (event.target.closest('[data-comment-menu]')) closeMenus();
467 });
468 document.addEventListener('keydown', (event) => {
469 if (event.key === 'Escape') closeMenus();
470 });
471 })();
472
802e0e52
Keenan Nemetz
5d ago
473 // The issue or change title: the pencil beside the heading swaps it for a
474 // title-only form. The description has its own comment-style menu and form, so
475 // this is deliberately only the heading.
476 (() => {
477 const parts = () => ({
478 view: document.querySelector('[data-details-title-view]'),
479 toggle: document.querySelector('[data-title-edit]'),
480 form: document.querySelector('[data-title-edit-form]'),
481 input: document.querySelector('[data-title-edit-input]'),
482 });
483 const set = (open) => {
484 const p = parts();
485 if (!p.form) return;
486 if (p.view) p.view.hidden = open;
487 if (p.toggle) p.toggle.hidden = open;
488 p.form.hidden = !open;
489 if (open && p.input) p.input.focus();
490 };
491 document.addEventListener('click', (event) => {
492 if (event.target.closest('[data-title-edit]')) {
493 event.preventDefault();
494 set(true);
495 return;
496 }
497 if (event.target.closest('[data-title-edit-cancel]')) {
498 event.preventDefault();
499 set(false);
500 }
501 });
502 document.addEventListener('keydown', (event) => {
503 if (event.key !== 'Escape') return;
504 const p = parts();
505 if (p.form && !p.form.hidden) set(false);
506 });
507 })();
508
f6f4b1db
Keenan Nemetz
15d ago
509 // A hash can point at a reply inside a collapsed thread. Open its ancestors
510 // before the browser tries to scroll, and repeat on click so a second click on
511 // the same "replying to" link still finds the comment.
512 (() => {
513 const reveal = (id) => {
514 if (!id) return;
515 const node = document.getElementById(id);
516 if (!node) return;
517 let details = node.closest('details');
518 while (details) {
519 details.open = true;
520 details = details.parentElement && details.parentElement.closest('details');
521 }
522 if (node.matches('details')) node.open = true;
523 requestAnimationFrame(() => node.scrollIntoView({ block: 'center' }));
524 };
525 const fromHash = () => {
526 let id = window.location.hash.slice(1);
527 if (!id) return;
528 try { id = decodeURIComponent(id); } catch (_) {}
529 reveal(id);
530 };
531 window.addEventListener('hashchange', fromHash);
532 window.addEventListener('load', fromHash);
533 document.addEventListener('click', (event) => {
534 const link = event.target.closest('a[href^="#"]');
535 if (!link) return;
536 let id = link.getAttribute('href').slice(1);
537 if (!id) return;
538 try { id = decodeURIComponent(id); } catch (_) {}
539 reveal(id);
540 });
541 fromHash();
542 })();
543
a464f892
Keenan Nemetz
15d ago
544 // Code browser: keep the file tree's active row and expanded ancestors in sync
545 // when HTMX replaces the main pane. The full-page render already resolves this
546 // server-side; this only closes the gap for fragment navigation.
547 (() => {
f6f4b1db
Keenan Nemetz
15d ago
548 const sync = (treeID, main) => {
549 const tree = document.getElementById(treeID);
a464f892
Keenan Nemetz
15d ago
550 if (!tree || !main) return;
551 const path = main.dataset.path || '';
552 tree.querySelectorAll('.active').forEach((el) => el.classList.remove('active'));
553 if (!path) return;
554 const node = Array.from(tree.querySelectorAll('[data-path]')).find((el) => el.dataset.path === path);
555 if (!node) return;
556 node.classList.add('active');
557 let details = node.matches('details') ? node : node.closest('details');
558 while (details) {
559 details.open = true;
560 details = details.parentElement.closest('details');
561 }
562 if (!node.matches('details')) node.scrollIntoView({ block: 'nearest' });
563 };
564 document.body.addEventListener('htmx:afterSwap', (event) => {
565 const main = event.target;
f6f4b1db
Keenan Nemetz
15d ago
566 if (!main) return;
567 const treeID = main.id === 'code-main' ? 'file-tree' : main.id === 'review-main' ? 'review-tree' : '';
568 if (!treeID) return;
569 sync(treeID, main);
a464f892
Keenan Nemetz
15d ago
570 // If the old file had scrolled the page down, the next file should start at
571 // its own top rather than midway through.
572 if (main.getBoundingClientRect().top < 60) {
573 main.scrollIntoView({ block: 'start' });
574 }
575 });
576 })();
c8fa5759
Keenan Nemetz
13d ago
577
34d6fd7a
Keenan Nemetz
10d ago
578 // Live CI job output. The server serves the attempt's file from a byte offset and
579 // says when the log is final; the page asks again only while the job is running.
580 // Chunks are decoded with a streaming TextDecoder because a multi-byte character
581 // can straddle two byte offsets.
582 (() => {
583 const follow = (pre) => {
584 if (pre.dataset.logStreaming) return;
585 pre.dataset.logStreaming = '1';
586 const url = pre.dataset.logUrl;
587 if (!url) return;
588 const decoder = new TextDecoder();
589 let offset = 0;
590 let stopped = false;
591 const finalOn404 = pre.dataset.logFinal === '1';
592 const schedule = () => {
593 if (!stopped) setTimeout(tick, 1000);
594 };
595 const tick = async () => {
596 let res;
597 try {
598 res = await fetch(url + (url.includes('?') ? '&' : '?') + 'after=' + offset, {
599 cache: 'no-store',
600 credentials: 'same-origin',
601 });
602 } catch (_) {
603 schedule();
604 return;
605 }
606 if (res.status === 404) {
607 if (finalOn404 || res.headers.get('X-Log-Final') === '1') return;
608 schedule();
609 return;
610 }
611 if (!res.ok) {
612 schedule();
613 return;
614 }
615 let buf;
616 try {
617 buf = await res.arrayBuffer();
618 } catch (_) {
619 schedule();
620 return;
621 }
622 if (buf.byteLength) {
623 const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 12;
624 pre.appendChild(document.createTextNode(decoder.decode(buf, { stream: true })));
625 offset += buf.byteLength;
626 if (stick) pre.scrollTop = pre.scrollHeight;
627 }
628 if (res.headers.get('X-Log-Final') === '1') {
629 pre.appendChild(document.createTextNode(decoder.decode()));
630 pre.dataset.logStreaming = 'done';
257e0d81
Keenan Nemetz
10d ago
631 const state = pre.closest('details')?.querySelector('.run-log-state');
632 if (state) state.textContent = 'complete';
34d6fd7a
Keenan Nemetz
10d ago
633 return;
634 }
635 schedule();
636 };
637 tick();
638 };
639
640 document.querySelectorAll('details').forEach((details) => {
641 const pre = details.querySelector('pre[data-log-stream]');
642 if (!pre) return;
643 if (details.open) follow(pre);
644 details.addEventListener('toggle', () => {
645 if (details.open) follow(pre);
646 });
647 });
648 })();
6c03e6d3
Keenan Nemetz
9d ago
649
650 // The palette's shortcut hint names the reader's own modifier key. The markup
651 // carries Ctrl K, which is also what a browser that blocks scripts shows.
652 (() => {
653 const platform = navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || '';
654 const mac = /Mac|iPhone|iPad|iPod/i.test(platform);
655 document.querySelectorAll('[data-palette-key]').forEach((hint) => {
656 hint.textContent = mac ? '⌘K' : 'Ctrl K';
657 });
658 })();
659
660 // The global search opens a command palette over issues, changes and comments.
661 // The top bar's launcher is a button shaped like the input it replaces; the
662 // dialog has a field because the results must stay open while the reader types.
663 // HTMX fetches the rows, so this only opens and closes the dialog and moves the
664 // selection between links. Every row is a link, so Enter and a click navigate
665 // to the same place.
666 (() => {
667 const dialog = document.querySelector('#search-palette');
668 if (!dialog || typeof dialog.showModal !== 'function') return;
669 const form = dialog.querySelector('.palette-search');
670 const input = dialog.querySelector('#search-palette-input');
671 const results = dialog.querySelector('#palette-results');
672 const launcher = document.querySelector('[data-palette-open]');
673 if (!form || !input || !results) return;
674
675 let selected = -1;
676 const rows = () => Array.from(results.querySelectorAll('[data-palette-row]'));
677
678 const select = (index) => {
679 const list = rows();
680 if (!list.length) {
681 selected = -1;
682 input.removeAttribute('aria-activedescendant');
683 return;
684 }
685 selected = Math.max(0, Math.min(index, list.length - 1));
686 list.forEach((row, i) => {
687 const active = i === selected;
688 if (active) row.id = row.id || 'palette-row-' + i;
689 row.classList.toggle('is-selected', active);
690 row.setAttribute('aria-selected', active ? 'true' : 'false');
691 if (active) {
692 input.setAttribute('aria-activedescendant', row.id);
693 row.scrollIntoView({ block: 'nearest' });
694 }
695 });
696 };
697
698 const open = () => {
699 if (!dialog.open) dialog.showModal();
700 input.focus();
701 input.select();
702 // Reopening should show the rows for the query already in the field. htmx
703 // drops the event if the value has not changed since the last request.
704 if (window.htmx) htmx.trigger(input, 'input');
705 };
706
707 if (launcher) {
708 launcher.addEventListener('click', (event) => {
709 event.preventDefault();
710 open();
711 });
712 }
713
714 document.addEventListener('keydown', (event) => {
715 if (!(event.ctrlKey || event.metaKey) || event.altKey || event.key.toLowerCase() !== 'k') return;
716 event.preventDefault();
717 open();
718 });
719
720 dialog.querySelector('[data-palette-close]')?.addEventListener('click', () => dialog.close());
721 // A click on the backdrop is aimed at the dialog itself; its content fills the
722 // rest of the box.
723 dialog.addEventListener('click', (event) => {
724 if (event.target === dialog) dialog.close();
725 });
726 dialog.addEventListener('close', () => {
727 selected = -1;
728 input.removeAttribute('aria-activedescendant');
729 rows().forEach((row) => {
730 row.classList.remove('is-selected');
731 row.setAttribute('aria-selected', 'false');
732 });
733 });
734
735 input.addEventListener('keydown', (event) => {
736 if (event.key === 'ArrowDown') {
737 event.preventDefault();
738 select(selected + 1);
739 } else if (event.key === 'ArrowUp') {
740 event.preventDefault();
741 select(selected - 1);
742 }
743 });
744
745 form.addEventListener('submit', (event) => {
1265ea55
Keenan Nemetz
9d ago
746 // The palette is the whole search surface, so Enter must never navigate to
747 // a page behind it. With no selected row there is nothing to open yet.
748 event.preventDefault();
6c03e6d3
Keenan Nemetz
9d ago
749 const list = rows();
750 if (selected < 0 || !list[selected]) return;
751 window.location.assign(list[selected].href);
752 });
753
754 // A new result set invalidates the old selection. Start on the best match so
755 // Enter after a pause opens it.
756 document.body.addEventListener('htmx:afterSwap', (event) => {
757 if (event.target !== results) return;
758 selected = -1;
759 select(rows().length ? 0 : -1);
760 });
761 })();
a2ec9561
Keenan Nemetz
9d ago
762
763 // The repository tabs poll their open counts, and a number changing while the
764 // reader is looking somewhere else is easy to miss. A badge that moved keeps one
765 // short ring; the page's first counts are the baseline, so only a later poll that
766 // changes a number marks the tab. An out-of-band response replaces one badge at a
767 // time, so every swap reads all of them: the badge that has not been replaced yet
768 // is still the value already recorded and is left alone.
769 (() => {
770 const seen = new Map();
771 const read = () => {
772 document.querySelectorAll('.tabs .chip-count[id]').forEach((badge) => {
773 const value = badge.textContent.trim();
774 if (seen.get(badge.id) === value) return;
775 const first = !seen.has(badge.id);
776 seen.set(badge.id, value);
777 if (first) return;
778 // The class is removed and the layout read in between so a second change
779 // restarts the animation instead of finding the class already applied.
780 badge.classList.remove('count-changed');
781 void badge.offsetWidth;
782 badge.classList.add('count-changed');
783 badge.addEventListener('animationend', () => badge.classList.remove('count-changed'), { once: true });
784 });
785 };
786 read();
787 document.body.addEventListener('htmx:oobAfterSwap', read);
788 })();
97f77001
Keenan Nemetz
7d ago
789