vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 20m ago
4b8c9e1d
| 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. |
| 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 | |
| 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 | |
| 45 | (() => { |
| 46 | document.addEventListener('click', (event) => { |
| 47 | const button = event.target.closest('[data-copy], [data-copy-query]'); |
| 48 | if (!button) return; |
| 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 | } |
| 60 | if (!value) return; |
| 61 | event.preventDefault(); |
| 62 | copyText(value, () => { |
| 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 | } |
| 72 | button.textContent = 'Copied'; |
| 73 | setTimeout(() => { button.innerHTML = original; }, 1200); |
| 74 | }); |
| 75 | }); |
| 76 | |
| 77 | document.addEventListener('change', (event) => { |
| 78 | const select = event.target.closest('[data-autosubmit]'); |
| 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(); |
| 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 | |
| 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 | |
| 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. |
| 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. |
| 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 | } |
| 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; |
| 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 | } |
| 222 | const w = start(); |
| 223 | if (!w) return; |
| 224 | block.dataset.highlighted = 'queued'; |
| 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 | } |
| 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 | |
| 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 | |
| 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. |
| 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 | })(); |
| 405 | |
| 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'); |
| 441 | if (textarea) { |
| 442 | fitTextarea(textarea); |
| 443 | textarea.focus(); |
| 444 | } |
| 445 | } |
| 446 | } else if (item.hasAttribute('data-copy-link')) { |
| 447 | const id = item.getAttribute('data-copy-link'); |
| 448 | copyText(window.location.href.split('#')[0] + '#c-' + id); |
| 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 | |
| 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 | |
| 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 | |
| 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 | (() => { |
| 548 | const sync = (treeID, main) => { |
| 549 | const tree = document.getElementById(treeID); |
| 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; |
| 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); |
| 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 | })(); |
| 577 | |
| 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'; |
| 631 | const state = pre.closest('details')?.querySelector('.run-log-state'); |
| 632 | if (state) state.textContent = 'complete'; |
| 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 | })(); |
| 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) => { |
| 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(); |
| 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 | })(); |
| 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 | })(); |
| 789 | |
| 790 |