// Soft navigation. Same-app links and form posts are fetched and swapped into the page in place, // so a button press no longer reloads the whole document. Progressive enhancement: without this // script (or on any failure) the browser's own navigation runs. Forms marked `data-hard` (sign-in, // sign-out, enrolment, password and account changes) always post natively. (function () { 'use strict'; if (window.__forgeSoft) { return; } window.__forgeSoft = true; var root = document.documentElement; var latest = 0; // id of the newest request; older responses are dropped var userBusy = 0; // navigations and form posts on the wire (not live refreshes) var liveDirty = false; // a live change came while a navigation was on the wire var inFlight = 0; // requests on the wire, drives aria-busy var pending = new WeakSet(); // forms with a request on the wire: a second submit is ignored var shown = location.pathname + location.search; var GET_INIT = { headers: { accept: 'text/html' } }; // Files, not pages: static assets, the JSON api, archives, raw and LFS objects and release // downloads. These always take the browser's own path. var FILE_PATH = /^\/(assets|api)\/|^\/[^\/]+\/[^\/]+\/(archive|raw|info\/lfs|-\/media|releases\/download)(\/|$)/; function busyStart() { inFlight += 1; root.setAttribute('aria-busy', 'true'); } function busyEnd() { inFlight = Math.max(0, inFlight - 1); if (inFlight === 0) { root.removeAttribute('aria-busy'); } } // The URL to fetch for `href`, or null when the browser should handle it: another origin, a file // route, or a value that does not parse. function pageUrl(href) { var url; try { url = new URL(href, location.href); } catch (err) { return null; } if (url.origin !== location.origin || FILE_PATH.test(url.pathname)) { return null; } return url; } function withoutHash(url) { var out = new URL(url.href); out.hash = ''; return out; } // Field preservation: a dirty or focused field keeps its value, and the caret offsets come along. function fieldKey(el) { var form = el.form ? (el.form.getAttribute('action') || '') : ''; return form + '|' + (el.name || '') + '|' + (el.type || '') + '|' + el.tagName; } function savable(el) { return el.type !== 'hidden' && el.type !== 'password' && el.type !== 'file'; } function captureFields() { var active = document.activeElement; var out = []; document.querySelectorAll('input, textarea, select').forEach(function (el) { if (!savable(el)) { return; } var moved; if (el.type === 'checkbox' || el.type === 'radio') { moved = el.checked !== el.defaultChecked; } else if (el.tagName === 'SELECT') { var def = -1; for (var j = 0; j < el.options.length; j++) { if (el.options[j].defaultSelected) { def = j; break; } } if (def < 0) { def = 0; } moved = el.selectedIndex !== def; } else { moved = el.value !== el.defaultValue; } var focused = el === active; if (!moved && !focused) { return; } var start = -1, end = -1; try { if (el.selectionStart != null) { start = el.selectionStart; end = el.selectionEnd; } } catch (err) { } out.push({ k: fieldKey(el), v: el.value, c: el.checked, i: el.selectedIndex, f: focused, s: start, e: end }); }); return out; } function restoreFields(saved) { if (!saved || !saved.length) { return; } var fields = document.querySelectorAll('input, textarea, select'); saved.forEach(function (was) { var el = null; for (var i = 0; i < fields.length; i++) { if (fieldKey(fields[i]) === was.k) { el = fields[i]; break; } } if (!el) { return; } if (el.type === 'checkbox' || el.type === 'radio') { el.checked = was.c; } else if (el.tagName === 'SELECT') { el.selectedIndex = was.i; } else { el.value = was.v; } if (was.f) { try { el.focus({ preventScroll: true }); if (was.s >= 0 && el.setSelectionRange) { el.setSelectionRange(was.s, was.e); } } catch (err) { } } }); } // Stylesheets in follow the new page: missing ones are added, extra ones removed. Matched by // href, so main.css and the highlight sheet stay in place when they are the same. function syncStyles(doc) { var sheets = 'link[rel="stylesheet"]'; var wanted = {}; doc.head.querySelectorAll(sheets).forEach(function (l) { wanted[l.getAttribute('href') || ''] = true; }); var have = {}; document.head.querySelectorAll(sheets).forEach(function (l) { have[l.getAttribute('href') || ''] = true; }); doc.head.querySelectorAll(sheets).forEach(function (l) { var href = l.getAttribute('href') || ''; if (!have[href]) { have[href] = true; document.head.appendChild(document.importNode(l, true)); } }); document.head.querySelectorAll(sheets).forEach(function (l) { if (!wanted[l.getAttribute('href') || '']) { l.remove(); } }); } function adopt(doc, keepFields) { document.title = doc.title; ['lang', 'data-theme', 'data-ui'].forEach(function (name) { if (doc.documentElement.hasAttribute(name)) { root.setAttribute(name, doc.documentElement.getAttribute(name)); } else { root.removeAttribute(name); } }); syncStyles(doc); var kept = keepFields ? captureFields() : null; var nodes = Array.prototype.map.call(doc.body.childNodes, function (n) { return document.importNode(n, true); }); document.body.replaceChildren.apply(document.body, nodes); // Re-create scripts so they run (a parsed-in script does not). The guard above keeps this file // from starting twice. document.body.querySelectorAll('script').forEach(function (old) { var s = document.createElement('script'); Array.prototype.forEach.call(old.attributes, function (a) { s.setAttribute(a.name, a.value); }); s.textContent = old.textContent; old.replaceWith(s); }); if (kept) { restoreFields(kept); } syncUnread(); syncLive(); } function focusMain() { var main = document.querySelector('main'); if (main && main.focus) { main.focus({ preventScroll: true }); } } // Scrolls to the fragment's element when there is one. Returns false when there is nothing to scroll to. function placeHash(hash) { if (!hash || hash.length < 2) { return false; } var id; try { id = decodeURIComponent(hash.slice(1)); } catch (err) { id = hash.slice(1); } var el = document.getElementById(id); if (!el) { return false; } el.scrollIntoView(); return true; } // how: 'nav' (link or GET form: push, top), 'pop' (history: push nothing, restore scroll), // 'post' (form post: replace, keep scroll), 'query' (in place: replace, keep scroll), 'live' (a // live page's change: no history entry, keep scroll, no fragment jump). function swap(html, dest, how, keepFields) { var doc = new DOMParser().parseFromString(html, 'text/html'); if (!doc.body) { location.assign(dest.href); return; } var x = window.scrollX, y = window.scrollY; var saved = history.state; // A live swap keeps the log tail where the reader had it: at the end it stays at the end. var tail = how === 'live' ? document.querySelector('[data-live-log]') : null; var tailAt = tail ? { top: tail.scrollTop, end: tail.scrollTop + tail.clientHeight >= tail.scrollHeight - 8 } : null; closeDropdowns(null); adopt(doc, keepFields); if (tailAt) { var nextTail = document.querySelector('[data-live-log]'); if (nextTail) { nextTail.scrollTop = tailAt.end ? nextTail.scrollHeight : tailAt.top; } } if (how === 'nav') { history.pushState(null, '', dest.href); } else if (how === 'post' || how === 'query') { history.replaceState(null, '', dest.href); } shown = dest.pathname + dest.search; if (how === 'nav' || how === 'pop') { focusMain(); } if (how !== 'live' && placeHash(dest.hash)) { return; } if (how === 'post' || how === 'query' || how === 'live') { window.scrollTo(x, y); } else if (how === 'pop') { window.scrollTo(0, saved && typeof saved.y === 'number' ? saved.y : 0); } else { window.scrollTo(0, 0); } } // The browser takes over where a soft request cannot: a non-HTML answer, or another origin. // A GET re-requests the page itself. A POST is never sent again (that would repeat the action): // a redirect is followed as a GET, anything else reloads the current page. function leave(res, o) { if (o.post) { if (res.redirected) { location.assign(res.url.href); } else { location.reload(); } return; } var sameOrigin = res.url.origin === location.origin; location.assign(sameOrigin && !res.redirected ? o.dest.href : res.url.href); } // One request for a page. `o` carries target (fetched, no hash), dest (shown, with hash), init, // how, keep (keep fields on a re-render of the same path), post, and the form for a POST or GET form. function load(o) { var mark = ++latest; var user = o.how !== 'live'; if (user) { userBusy += 1; } busyStart(); fetch(o.target.href, o.init).then(function (r) { var final = new URL(r.url || o.target.href); var html = /^\s*text\/html/i.test(r.headers.get('content-type') || ''); if (final.origin !== location.origin || !html) { return { leave: true, url: final, redirected: r.redirected }; } return r.text().then(function (text) { return { html: text, url: final }; }); }).then(function (res) { if (mark !== latest) { return; } if (res.leave) { leave(res, o); return; } var dest = new URL(res.url.href); dest.hash = o.dest.hash; swap(res.html, dest, o.how, !!o.keep && res.url.pathname === o.target.pathname); }).catch(function () { if (mark !== latest || o.how === 'live') { return; } if (o.post) { o.form.submit(); } else { location.assign(o.dest.href); } }).then(function () { busyEnd(); if (o.form) { pending.delete(o.form); } if (user) { userBusy -= 1; if (liveDirty) { refreshLive(); } } }); } function saveScroll() { try { history.replaceState({ y: window.scrollY }, '', location.href); } catch (err) { } } // Dropdown menus: the
that float over the page (avatar menu, branch selector, clone // popover, phone menu). Inline disclosures are not listed here and keep their state. var DROPDOWN = 'details.menu, details.ref-select, details.clone, details.nav-toggle'; // Closes every open dropdown except `keep` (pass null to close them all). function closeDropdowns(keep) { document.querySelectorAll(DROPDOWN).forEach(function (d) { if (d.open && d !== keep) { d.open = false; } }); } // A click outside an open dropdown closes it. A click inside one (a select, a form field) keeps // it open. Opening a dropdown by its summary closes the others: the browser toggles it after this. document.addEventListener('click', function (e) { var inside = e.target && e.target.closest ? e.target.closest(DROPDOWN) : null; closeDropdowns(inside); }, false); // Escape closes the open dropdowns and returns focus to the summary of the one that held it. document.addEventListener('keydown', function (e) { if (e.key !== 'Escape') { return; } var back = null; document.querySelectorAll(DROPDOWN).forEach(function (d) { if (!d.open) { return; } if (d.contains(document.activeElement)) { back = d; } d.open = false; }); if (back) { var summary = back.querySelector('summary'); if (summary) { summary.focus(); } } }, false); document.addEventListener('click', function (e) { if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return; } var a = e.target && e.target.closest ? e.target.closest('a[href]') : null; if (!a || a.target || a.hasAttribute('download') || a.hasAttribute('data-hard')) { return; } var href = a.getAttribute('href') || ''; // Same-app paths only: `//host` is protocol-relative, another origin, and goes the browser's way. if (href.charAt(0) !== '/' || href.charAt(1) === '/') { return; } var url = pageUrl(href); if (!url) { return; } // A fragment on the page already shown: the browser scrolls by itself. if (url.pathname + url.search === shown && url.hash) { return; } e.preventDefault(); saveScroll(); load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'nav', keep: false, post: false, form: null }); }, true); document.addEventListener('submit', function (e) { var form = e.target; if (!form || form.tagName !== 'FORM' || form.hasAttribute('data-hard') || form.getAttribute('target')) { return; } var submitter = e.submitter || null; // Per-button overrides other than the action are rare: the browser handles those. if (submitter && (submitter.hasAttribute('formmethod') || submitter.hasAttribute('formenctype') || submitter.hasAttribute('formtarget') || submitter.hasAttribute('data-hard'))) { return; } var action = (submitter && submitter.getAttribute('formaction')) || form.getAttribute('action') || location.href; var url = pageUrl(action); if (!url) { return; } e.preventDefault(); if (pending.has(form)) { return; } pending.add(form); var data = new FormData(form); if (submitter && submitter.name) { data.append(submitter.name, submitter.value); } var method = (form.getAttribute('method') || 'get').toLowerCase(); if (method === 'get') { var q = new URLSearchParams(data).toString(); var dest = withoutHash(url); dest.search = q ? '?' + q : ''; saveScroll(); load({ target: dest, dest: dest, init: GET_INIT, how: 'nav', keep: false, post: false, form: form }); return; } var multipart = (form.enctype || '').toLowerCase().indexOf('multipart') === 0; load({ target: withoutHash(url), dest: url, init: { method: 'POST', headers: { accept: 'text/html' }, body: multipart ? data : new URLSearchParams(data) }, how: 'post', keep: true, post: true, form: form }); }, false); window.addEventListener('popstate', function () { var here = location.pathname + location.search; if (here === shown) { placeHash(location.hash); return; } var url = pageUrl(location.href); if (!url) { location.reload(); return; } load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'pop', keep: false, post: false, form: null }); }); // Filter controls submit their form on change. document.addEventListener('change', function (e) { var c = e.target && e.target.closest ? e.target.closest('[data-autosubmit]') : null; if (!c || !c.form) { return; } if (c.form.requestSubmit) { c.form.requestSubmit(); } else { c.form.submit(); } }); // A filtered view asked for in place: the URL is replaced (no history entry per change), fields // and caret come through the swap, and the scroll stays put. // Live unread count. The bell is re-rendered by every swap, so it is looked up on each update. One // EventSource serves the page and stays open across swaps. It is opened only while a bell is shown // (signed-in pages) and closed when the bell is gone. The server pushes each change; nothing polls. var unreadStream = null; function bell() { return document.querySelector('a.bell'); } // Shows the count the way the server renders it: a `.bell-count` span with the number, or no span at zero. function setUnread(n) { var el = bell(); if (!el) { return; } var badge = el.querySelector('.bell-count'); if (n > 0) { if (!badge) { badge = document.createElement('span'); badge.className = 'bell-count'; el.appendChild(badge); } badge.textContent = String(n); } else if (badge) { badge.remove(); } } function syncUnread() { if (!bell()) { if (unreadStream) { unreadStream.close(); unreadStream = null; } return; } if (unreadStream || !window.EventSource) { return; } unreadStream = new EventSource('/notifications/stream'); unreadStream.addEventListener('unread', function (e) { setUnread(parseInt(e.data, 10) || 0); }); } syncUnread(); // Live pages. A run or job page that is not finished marks its wrapper with data-live, the URL of // its event stream. One EventSource follows it. A `changed` event re-fetches the page and swaps it in // with no history entry and the scroll kept. A job page also has a log tail (data-live-log) whose // `log` events append the new bytes as they come. The stream opens at the tail's data-offset, and // each swap brings a fresh offset, so the stream is opened again after every swap. The server ends // the stream once the status is terminal, and the swap that shows that page (no data-live) closes it. var liveStream = null; var liveUrl = null; function closeLive() { if (liveStream) { liveStream.close(); } liveStream = null; liveUrl = null; liveDirty = false; } function syncLive() { var el = document.querySelector('[data-live]'); var url = el ? el.getAttribute('data-live') : null; var tail = document.querySelector('[data-live-log]'); if (url && tail) { url += (url.indexOf('?') < 0 ? '?' : '&') + 'from=' + encodeURIComponent(tail.getAttribute('data-offset') || '0'); } if (!url || !window.EventSource) { closeLive(); return; } if (liveStream && liveUrl === url) { return; } closeLive(); liveUrl = url; liveStream = new EventSource(url); liveStream.addEventListener('changed', function (e) { // A job page's stream opens with the status the page already shows: nothing to re-render then. var wrap = document.querySelector('[data-live]'); if (document.querySelector('[data-live-log]') && wrap && wrap.getAttribute('data-live-state') === e.data) { return; } refreshLive(); }); liveStream.addEventListener('log', function (e) { appendLog(e.data); }); } // Appends streamed log text to the tail as a text node, never as HTML. The tail follows the end // only while the reader is already at the end. function appendLog(text) { var pre = document.querySelector('[data-live-log]'); if (!pre || !text) { return; } var atEnd = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 8; pre.appendChild(document.createTextNode(text)); if (atEnd) { pre.scrollTop = pre.scrollHeight; } } // A change re-renders the current page. It waits while a navigation is on the wire, since that // response could be superseded: the change is kept and sent after that request completes. function refreshLive() { if (!liveStream) { return; } if (userBusy > 0) { liveDirty = true; return; } liveDirty = false; var here = new URL(location.href); load({ target: withoutHash(here), dest: here, init: GET_INIT, how: 'live', keep: true, post: false, form: null }); } syncLive(); window.__forgeQuery = function (href) { var url = pageUrl(href); if (!url) { location.assign(href); return; } load({ target: withoutHash(url), dest: url, init: GET_INIT, how: 'query', keep: true, post: false, form: null }); }; })();