Tahinli/forge
public0 watching
★ 0
| 1 | // Soft navigation. Same-app links and form posts are fetched and swapped into the page in place, |
| 2 | // so a button press no longer reloads the whole document. Progressive enhancement: without this |
| 3 | // script (or on any failure) the browser's own navigation runs. Forms marked `data-hard` (sign-in, |
| 4 | // sign-out, enrolment, password and account changes) always post natively. |
| 5 | (function () { |
| 6 | 'use strict'; |
| 7 | if (window.__forgeSoft) { return; } |
| 8 | window.__forgeSoft = true; |
| 9 | |
| 10 | var root = document.documentElement; |
| 11 | var latest = 0; // id of the newest request; older responses are dropped |
| 12 | var userBusy = 0; // navigations and form posts on the wire (not live refreshes) |
| 13 | var liveDirty = false; // a live change came while a navigation was on the wire |
| 14 | var inFlight = 0; // requests on the wire, drives aria-busy |
| 15 | var pending = new WeakSet(); // forms with a request on the wire: a second submit is ignored |
| 16 | var shown = location.pathname + location.search; |
| 17 | var GET_INIT = { headers: { accept: 'text/html' } }; |
| 18 | |
| 19 | // Files, not pages: static assets, the JSON api, archives, raw and LFS objects and release |
| 20 | // downloads. These always take the browser's own path. |
| 21 | var FILE_PATH = /^\/(assets|api)\/|^\/[^\/]+\/[^\/]+\/(archive|raw|info\/lfs|-\/media|releases\/download)(\/|$)/; |
| 22 | |
| 23 | function busyStart() { |
| 24 | inFlight += 1; |
| 25 | root.setAttribute('aria-busy', 'true'); |
| 26 | } |
| 27 | |
| 28 | function busyEnd() { |
| 29 | inFlight = Math.max(0, inFlight - 1); |
| 30 | if (inFlight === 0) { root.removeAttribute('aria-busy'); } |
| 31 | } |
| 32 | |
| 33 | // The URL to fetch for `href`, or null when the browser should handle it: another origin, a file |
| 34 | // route, or a value that does not parse. |
| 35 | function pageUrl(href) { |
| 36 | var url; |
| 37 | try { url = new URL(href, location.href); } catch (err) { return null; } |
| 38 | if (url.origin !== location.origin || FILE_PATH.test(url.pathname)) { return null; } |
| 39 | return url; |
| 40 | } |
| 41 | |
| 42 | function withoutHash(url) { |
| 43 | var out = new URL(url.href); |
| 44 | out.hash = ''; |
| 45 | return out; |
| 46 | } |
| 47 | |
| 48 | // Field preservation: a dirty or focused field keeps its value, and the caret offsets come along. |
| 49 | function fieldKey(el) { |
| 50 | var form = el.form ? (el.form.getAttribute('action') || '') : ''; |
| 51 | return form + '|' + (el.name || '') + '|' + (el.type || '') + '|' + el.tagName; |
| 52 | } |
| 53 | |
| 54 | function savable(el) { |
| 55 | return el.type !== 'hidden' && el.type !== 'password' && el.type !== 'file'; |
| 56 | } |
| 57 | |
| 58 | function captureFields() { |
| 59 | var active = document.activeElement; |
| 60 | var out = []; |
| 61 | document.querySelectorAll('input, textarea, select').forEach(function (el) { |
| 62 | if (!savable(el)) { return; } |
| 63 | var moved; |
| 64 | if (el.type === 'checkbox' || el.type === 'radio') { |
| 65 | moved = el.checked !== el.defaultChecked; |
| 66 | } else if (el.tagName === 'SELECT') { |
| 67 | var def = -1; |
| 68 | for (var j = 0; j < el.options.length; j++) { |
| 69 | if (el.options[j].defaultSelected) { def = j; break; } |
| 70 | } |
| 71 | if (def < 0) { def = 0; } |
| 72 | moved = el.selectedIndex !== def; |
| 73 | } else { |
| 74 | moved = el.value !== el.defaultValue; |
| 75 | } |
| 76 | var focused = el === active; |
| 77 | if (!moved && !focused) { return; } |
| 78 | var start = -1, end = -1; |
| 79 | try { |
| 80 | if (el.selectionStart != null) { start = el.selectionStart; end = el.selectionEnd; } |
| 81 | } catch (err) { } |
| 82 | out.push({ k: fieldKey(el), v: el.value, c: el.checked, i: el.selectedIndex, f: focused, s: start, e: end }); |
| 83 | }); |
| 84 | return out; |
| 85 | } |
| 86 | |
| 87 | function restoreFields(saved) { |
| 88 | if (!saved || !saved.length) { return; } |
| 89 | var fields = document.querySelectorAll('input, textarea, select'); |
| 90 | saved.forEach(function (was) { |
| 91 | var el = null; |
| 92 | for (var i = 0; i < fields.length; i++) { |
| 93 | if (fieldKey(fields[i]) === was.k) { el = fields[i]; break; } |
| 94 | } |
| 95 | if (!el) { return; } |
| 96 | if (el.type === 'checkbox' || el.type === 'radio') { el.checked = was.c; } |
| 97 | else if (el.tagName === 'SELECT') { el.selectedIndex = was.i; } |
| 98 | else { el.value = was.v; } |
| 99 | if (was.f) { |
| 100 | try { |
| 101 | el.focus({ preventScroll: true }); |
| 102 | if (was.s >= 0 && el.setSelectionRange) { el.setSelectionRange(was.s, was.e); } |
| 103 | } catch (err) { } |
| 104 | } |
| 105 | }); |
| 106 | } |
| 107 | |
| 108 | // Stylesheets in <head> follow the new page: missing ones are added, extra ones removed. Matched by |
| 109 | // href, so main.css and the highlight sheet stay in place when they are the same. |
| 110 | function syncStyles(doc) { |
| 111 | var sheets = 'link[rel="stylesheet"]'; |
| 112 | var wanted = {}; |
| 113 | doc.head.querySelectorAll(sheets).forEach(function (l) { wanted[l.getAttribute('href') || ''] = true; }); |
| 114 | var have = {}; |
| 115 | document.head.querySelectorAll(sheets).forEach(function (l) { have[l.getAttribute('href') || ''] = true; }); |
| 116 | doc.head.querySelectorAll(sheets).forEach(function (l) { |
| 117 | var href = l.getAttribute('href') || ''; |
| 118 | if (!have[href]) { |
| 119 | have[href] = true; |
| 120 | document.head.appendChild(document.importNode(l, true)); |
| 121 | } |
| 122 | }); |
| 123 | document.head.querySelectorAll(sheets).forEach(function (l) { |
| 124 | if (!wanted[l.getAttribute('href') || '']) { l.remove(); } |
| 125 | }); |
| 126 | } |
| 127 | |
| 128 | function adopt(doc, keepFields) { |
| 129 | document.title = doc.title; |
| 130 | ['lang', 'data-theme', 'data-ui'].forEach(function (name) { |
| 131 | if (doc.documentElement.hasAttribute(name)) { root.setAttribute(name, doc.documentElement.getAttribute(name)); } |
| 132 | else { root.removeAttribute(name); } |
| 133 | }); |
| 134 | syncStyles(doc); |
| 135 | var kept = keepFields ? captureFields() : null; |
| 136 | var nodes = Array.prototype.map.call(doc.body.childNodes, function (n) { return document.importNode(n, true); }); |
| 137 | document.body.replaceChildren.apply(document.body, nodes); |
| 138 | // Re-create scripts so they run (a parsed-in script does not). The guard above keeps this file |
| 139 | // from starting twice. |
| 140 | document.body.querySelectorAll('script').forEach(function (old) { |
| 141 | var s = document.createElement('script'); |
| 142 | Array.prototype.forEach.call(old.attributes, function (a) { s.setAttribute(a.name, a.value); }); |
| 143 | s.textContent = old.textContent; |
| 144 | old.replaceWith(s); |
| 145 | }); |
| 146 | if (kept) { restoreFields(kept); } |
| 147 | syncUnread(); |
| 148 | syncLive(); |
| 149 | } |
| 150 | |
| 151 | function focusMain() { |
| 152 | var main = document.querySelector('main'); |
| 153 | if (main && main.focus) { main.focus({ preventScroll: true }); } |
| 154 | } |
| 155 | |
| 156 | // Scrolls to the fragment's element when there is one. Returns false when there is nothing to scroll to. |
| 157 | function placeHash(hash) { |
| 158 | if (!hash || hash.length < 2) { return false; } |
| 159 | var id; |
| 160 | try { id = decodeURIComponent(hash.slice(1)); } catch (err) { id = hash.slice(1); } |
| 161 | var el = document.getElementById(id); |
| 162 | if (!el) { return false; } |
| 163 | el.scrollIntoView(); |
| 164 | return true; |
| 165 | } |
| 166 | |
| 167 | // how: 'nav' (link or GET form: push, top), 'pop' (history: push nothing, restore scroll), |
| 168 | // 'post' (form post: replace, keep scroll), 'query' (in place: replace, keep scroll), 'live' (a |
| 169 | // live page's change: no history entry, keep scroll, no fragment jump). |
| 170 | function swap(html, dest, how, keepFields) { |
| 171 | var doc = new DOMParser().parseFromString(html, 'text/html'); |
| 172 | if (!doc.body) { location.assign(dest.href); return; } |
| 173 | var x = window.scrollX, y = window.scrollY; |
| 174 | var saved = history.state; |
| 175 | // A live swap keeps the log tail where the reader had it: at the end it stays at the end. |
| 176 | var tail = how === 'live' ? document.querySelector('[data-live-log]') : null; |
| 177 | var tailAt = tail ? { top: tail.scrollTop, end: tail.scrollTop + tail.clientHeight >= tail.scrollHeight - 8 } : null; |
| 178 | closeDropdowns(null); |
| 179 | adopt(doc, keepFields); |
| 180 | if (tailAt) { |
| 181 | var nextTail = document.querySelector('[data-live-log]'); |
| 182 | if (nextTail) { nextTail.scrollTop = tailAt.end ? nextTail.scrollHeight : tailAt.top; } |
| 183 | } |
| 184 | if (how === 'nav') { history.pushState(null, '', dest.href); } |
| 185 | else if (how === 'post' || how === 'query') { history.replaceState(null, '', dest.href); } |
| 186 | shown = dest.pathname + dest.search; |
| 187 | if (how === 'nav' || how === 'pop') { focusMain(); } |
| 188 | if (how !== 'live' && placeHash(dest.hash)) { return; } |
| 189 | if (how === 'post' || how === 'query' || how === 'live') { window.scrollTo(x, y); } |
| 190 | else if (how === 'pop') { window.scrollTo(0, saved && typeof saved.y === 'number' ? saved.y : 0); } |
| 191 | else { window.scrollTo(0, 0); } |
| 192 | } |
| 193 | |
| 194 | // The browser takes over where a soft request cannot: a non-HTML answer, or another origin. |
| 195 | // A GET re-requests the page itself. A POST is never sent again (that would repeat the action): |
| 196 | // a redirect is followed as a GET, anything else reloads the current page. |
| 197 | function leave(res, o) { |
| 198 | if (o.post) { |
| 199 | if (res.redirected) { location.assign(res.url.href); } else { location.reload(); } |
| 200 | return; |
| 201 | } |
| 202 | var sameOrigin = res.url.origin === location.origin; |
| 203 | location.assign(sameOrigin && !res.redirected ? o.dest.href : res.url.href); |
| 204 | } |
| 205 | |
| 206 | // One request for a page. `o` carries target (fetched, no hash), dest (shown, with hash), init, |
| 207 | // how, keep (keep fields on a re-render of the same path), post, and the form for a POST or GET form. |
| 208 | function load(o) { |
| 209 | var mark = ++latest; |
| 210 | var user = o.how !== 'live'; |
| 211 | if (user) { userBusy += 1; } |
| 212 | busyStart(); |
| 213 | fetch(o.target.href, o.init).then(function (r) { |
| 214 | var final = new URL(r.url || o.target.href); |
| 215 | var html = /^\s*text\/html/i.test(r.headers.get('content-type') || ''); |
| 216 | if (final.origin !== location.origin || !html) { |
| 217 | return { leave: true, url: final, redirected: r.redirected }; |
| 218 | } |
| 219 | return r.text().then(function (text) { return { html: text, url: final }; }); |
| 220 | }).then(function (res) { |
| 221 | if (mark !== latest) { return; } |
| 222 | if (res.leave) { leave(res, o); return; } |
| 223 | var dest = new URL(res.url.href); |
| 224 | dest.hash = o.dest.hash; |
| 225 | swap(res.html, dest, o.how, !!o.keep && res.url.pathname === o.target.pathname); |
| 226 | }).catch(function () { |
| 227 | if (mark !== latest || o.how === 'live') { return; } |
| 228 | if (o.post) { o.form.submit(); } else { location.assign(o.dest.href); } |
| 229 | }).then(function () { |
| 230 | busyEnd(); |
| 231 | if (o.form) { pending.delete(o.form); } |
| 232 | if (user) { |
| 233 | userBusy -= 1; |
| 234 | if (liveDirty) { refreshLive(); } |
| 235 | } |
| 236 | }); |
| 237 | } |
| 238 | |
| 239 | function saveScroll() { |
| 240 | try { history.replaceState({ y: window.scrollY }, '', location.href); } catch (err) { } |
| 241 | } |
| 242 | |
| 243 | // Dropdown menus: the <details> that float over the page (avatar menu, branch selector, clone |
| 244 | // popover, phone menu). Inline disclosures are not listed here and keep their state. |
| 245 | var DROPDOWN = 'details.menu, details.ref-select, details.clone, details.nav-toggle'; |
| 246 | |
| 247 | // Closes every open dropdown except `keep` (pass null to close them all). |
| 248 | function closeDropdowns(keep) { |
| 249 | document.querySelectorAll(DROPDOWN).forEach(function (d) { |
| 250 | if (d.open && d !== keep) { d.open = false; } |
| 251 | }); |
| 252 | } |
| 253 | |
| 254 | // A click outside an open dropdown closes it. A click inside one (a select, a form field) keeps |
| 255 | // it open. Opening a dropdown by its summary closes the others: the browser toggles it after this. |
| 256 | document.addEventListener('click', function (e) { |
| 257 | var inside = e.target && e.target.closest ? e.target.closest(DROPDOWN) : null; |
| 258 | closeDropdowns(inside); |
| 259 | }, false); |
| 260 | |
| 261 | // Escape closes the open dropdowns and returns focus to the summary of the one that held it. |
| 262 | document.addEventListener('keydown', function (e) { |
| 263 | if (e.key !== 'Escape') { return; } |
| 264 | var back = null; |
| 265 | document.querySelectorAll(DROPDOWN).forEach(function (d) { |
| 266 | if (!d.open) { return; } |
| 267 | if (d.contains(document.activeElement)) { back = d; } |
| 268 | d.open = false; |
| 269 | }); |
| 270 | if (back) { |
| 271 | var summary = back.querySelector('summary'); |
| 272 | if (summary) { summary.focus(); } |
| 273 | } |
| 274 | }, false); |
| 275 | |
| 276 | document.addEventListener('click', function (e) { |
| 277 | if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return; } |
| 278 | var a = e.target && e.target.closest ? e.target.closest('a[href]') : null; |
| 279 | if (!a || a.target || a.hasAttribute('download') || a.hasAttribute('data-hard')) { return; } |
| 280 | var href = a.getAttribute('href') || ''; |
| 281 | // Same-app paths only: `//host` is protocol-relative, another origin, and goes the browser's way. |
| 282 | if (href.charAt(0) !== '/' || href.charAt(1) === '/') { return; } |
| 283 | var url = pageUrl(href); |
| 284 | if (!url) { return; } |
| 285 | // A fragment on the page already shown: the browser scrolls by itself. |
| 286 | if (url.pathname + url.search === shown && url.hash) { return; } |
| 287 | e.preventDefault(); |
| 288 | saveScroll(); |
| 289 | load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'nav', keep: false, post: false, form: null }); |
| 290 | }, true); |
| 291 | |
| 292 | document.addEventListener('submit', function (e) { |
| 293 | var form = e.target; |
| 294 | if (!form || form.tagName !== 'FORM' || form.hasAttribute('data-hard') || form.getAttribute('target')) { return; } |
| 295 | var submitter = e.submitter || null; |
| 296 | // Per-button overrides other than the action are rare: the browser handles those. |
| 297 | if (submitter && (submitter.hasAttribute('formmethod') || submitter.hasAttribute('formenctype') || |
| 298 | submitter.hasAttribute('formtarget') || submitter.hasAttribute('data-hard'))) { return; } |
| 299 | var action = (submitter && submitter.getAttribute('formaction')) || form.getAttribute('action') || location.href; |
| 300 | var url = pageUrl(action); |
| 301 | if (!url) { return; } |
| 302 | e.preventDefault(); |
| 303 | if (pending.has(form)) { return; } |
| 304 | pending.add(form); |
| 305 | |
| 306 | var data = new FormData(form); |
| 307 | if (submitter && submitter.name) { data.append(submitter.name, submitter.value); } |
| 308 | var method = (form.getAttribute('method') || 'get').toLowerCase(); |
| 309 | |
| 310 | if (method === 'get') { |
| 311 | var q = new URLSearchParams(data).toString(); |
| 312 | var dest = withoutHash(url); |
| 313 | dest.search = q ? '?' + q : ''; |
| 314 | saveScroll(); |
| 315 | load({ target: dest, dest: dest, init: GET_INIT, how: 'nav', keep: false, post: false, form: form }); |
| 316 | return; |
| 317 | } |
| 318 | |
| 319 | var multipart = (form.enctype || '').toLowerCase().indexOf('multipart') === 0; |
| 320 | load({ |
| 321 | target: withoutHash(url), |
| 322 | dest: url, |
| 323 | init: { |
| 324 | method: 'POST', |
| 325 | headers: { accept: 'text/html' }, |
| 326 | body: multipart ? data : new URLSearchParams(data) |
| 327 | }, |
| 328 | how: 'post', |
| 329 | keep: true, |
| 330 | post: true, |
| 331 | form: form |
| 332 | }); |
| 333 | }, false); |
| 334 | |
| 335 | window.addEventListener('popstate', function () { |
| 336 | var here = location.pathname + location.search; |
| 337 | if (here === shown) { placeHash(location.hash); return; } |
| 338 | var url = pageUrl(location.href); |
| 339 | if (!url) { location.reload(); return; } |
| 340 | load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'pop', keep: false, post: false, form: null }); |
| 341 | }); |
| 342 | |
| 343 | // Filter controls submit their form on change. |
| 344 | document.addEventListener('change', function (e) { |
| 345 | var c = e.target && e.target.closest ? e.target.closest('[data-autosubmit]') : null; |
| 346 | if (!c || !c.form) { return; } |
| 347 | if (c.form.requestSubmit) { c.form.requestSubmit(); } else { c.form.submit(); } |
| 348 | }); |
| 349 | |
| 350 | // A filtered view asked for in place: the URL is replaced (no history entry per change), fields |
| 351 | // and caret come through the swap, and the scroll stays put. |
| 352 | // Live unread count. The bell is re-rendered by every swap, so it is looked up on each update. One |
| 353 | // EventSource serves the page and stays open across swaps. It is opened only while a bell is shown |
| 354 | // (signed-in pages) and closed when the bell is gone. The server pushes each change; nothing polls. |
| 355 | var unreadStream = null; |
| 356 | |
| 357 | function bell() { return document.querySelector('a.bell'); } |
| 358 | |
| 359 | // Shows the count the way the server renders it: a `.bell-count` span with the number, or no span at zero. |
| 360 | function setUnread(n) { |
| 361 | var el = bell(); |
| 362 | if (!el) { return; } |
| 363 | var badge = el.querySelector('.bell-count'); |
| 364 | if (n > 0) { |
| 365 | if (!badge) { |
| 366 | badge = document.createElement('span'); |
| 367 | badge.className = 'bell-count'; |
| 368 | el.appendChild(badge); |
| 369 | } |
| 370 | badge.textContent = String(n); |
| 371 | } else if (badge) { |
| 372 | badge.remove(); |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | function syncUnread() { |
| 377 | if (!bell()) { |
| 378 | if (unreadStream) { unreadStream.close(); unreadStream = null; } |
| 379 | return; |
| 380 | } |
| 381 | if (unreadStream || !window.EventSource) { return; } |
| 382 | unreadStream = new EventSource('/notifications/stream'); |
| 383 | unreadStream.addEventListener('unread', function (e) { |
| 384 | setUnread(parseInt(e.data, 10) || 0); |
| 385 | }); |
| 386 | } |
| 387 | |
| 388 | syncUnread(); |
| 389 | |
| 390 | // Live pages. A run or job page that is not finished marks its wrapper with data-live, the URL of |
| 391 | // its event stream. One EventSource follows it. A `changed` event re-fetches the page and swaps it in |
| 392 | // with no history entry and the scroll kept. A job page also has a log tail (data-live-log) whose |
| 393 | // `log` events append the new bytes as they come. The stream opens at the tail's data-offset, and |
| 394 | // each swap brings a fresh offset, so the stream is opened again after every swap. The server ends |
| 395 | // the stream once the status is terminal, and the swap that shows that page (no data-live) closes it. |
| 396 | var liveStream = null; |
| 397 | var liveUrl = null; |
| 398 | |
| 399 | function closeLive() { |
| 400 | if (liveStream) { liveStream.close(); } |
| 401 | liveStream = null; |
| 402 | liveUrl = null; |
| 403 | liveDirty = false; |
| 404 | } |
| 405 | |
| 406 | function syncLive() { |
| 407 | var el = document.querySelector('[data-live]'); |
| 408 | var url = el ? el.getAttribute('data-live') : null; |
| 409 | var tail = document.querySelector('[data-live-log]'); |
| 410 | if (url && tail) { |
| 411 | url += (url.indexOf('?') < 0 ? '?' : '&') + 'from=' + encodeURIComponent(tail.getAttribute('data-offset') || '0'); |
| 412 | } |
| 413 | if (!url || !window.EventSource) { closeLive(); return; } |
| 414 | if (liveStream && liveUrl === url) { return; } |
| 415 | closeLive(); |
| 416 | liveUrl = url; |
| 417 | liveStream = new EventSource(url); |
| 418 | liveStream.addEventListener('changed', function (e) { |
| 419 | // A job page's stream opens with the status the page already shows: nothing to re-render then. |
| 420 | var wrap = document.querySelector('[data-live]'); |
| 421 | if (document.querySelector('[data-live-log]') && wrap && wrap.getAttribute('data-live-state') === e.data) { return; } |
| 422 | refreshLive(); |
| 423 | }); |
| 424 | liveStream.addEventListener('log', function (e) { appendLog(e.data); }); |
| 425 | } |
| 426 | |
| 427 | // Appends streamed log text to the tail as a text node, never as HTML. The tail follows the end |
| 428 | // only while the reader is already at the end. |
| 429 | function appendLog(text) { |
| 430 | var pre = document.querySelector('[data-live-log]'); |
| 431 | if (!pre || !text) { return; } |
| 432 | var atEnd = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 8; |
| 433 | pre.appendChild(document.createTextNode(text)); |
| 434 | if (atEnd) { pre.scrollTop = pre.scrollHeight; } |
| 435 | } |
| 436 | |
| 437 | // A change re-renders the current page. It waits while a navigation is on the wire, since that |
| 438 | // response could be superseded: the change is kept and sent after that request completes. |
| 439 | function refreshLive() { |
| 440 | if (!liveStream) { return; } |
| 441 | if (userBusy > 0) { liveDirty = true; return; } |
| 442 | liveDirty = false; |
| 443 | var here = new URL(location.href); |
| 444 | load({ target: withoutHash(here), dest: here, init: GET_INIT, how: 'live', keep: true, post: false, form: null }); |
| 445 | } |
| 446 | |
| 447 | syncLive(); |
| 448 | |
| 449 | window.__forgeQuery = function (href) { |
| 450 | var url = pageUrl(href); |
| 451 | if (!url) { location.assign(href); return; } |
| 452 | load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'query', keep: true, post: false, form: null }); |
| 453 | }; |
| 454 | })(); |