/* Progressive enhancement only: every page works without this file. */ (function () { "use strict"; /* The band's active tab: the longest link that prefixes the path. */ var p = location.pathname, best = null; document.querySelectorAll(".band a").forEach(function (a) { var h = a.getAttribute("href"); if (h !== "/" && (p === h || p.indexOf(h + "/") === 0) && (!best || h.length > best.getAttribute("href").length)) best = a; }); if (best) best.classList.add("on"); /* The repository bar's menus (oxygenhub#254) are
, open without this file; here one open menu closes the others, and a click outside or Escape closes it. */ var menus = document.querySelectorAll("[data-tab-menu]"); function closeMenus(except) { menus.forEach(function (m) { if (m !== except) m.open = false; }); } menus.forEach(function (m) { m.addEventListener("toggle", function () { if (m.open) closeMenus(m); }); }); if (menus.length) { document.addEventListener("click", function (ev) { if (!ev.target.closest || !ev.target.closest("[data-tab-menu]")) closeMenus(null); }); document.addEventListener("keydown", function (ev) { if (ev.key !== "Escape") return; var open = document.querySelector("[data-tab-menu][open]"); if (open) { open.open = false; open.querySelector("summary").focus(); } }); } /* Off-the-shelf editors, loaded only where a page asks for them: CodeMirror 6 over textarea.code, EasyMDE over textarea.md. */ function load(src, css) { return new Promise(function (ok) { if (css) { var l = document.createElement("link"); l.rel = "stylesheet"; l.href = css; document.head.appendChild(l); } var s = document.createElement("script"); s.src = src; s.onload = ok; document.head.appendChild(s); }); } if (document.querySelector("textarea.code")) load("/static/vendor/editor.js"); var mds = document.querySelectorAll("textarea.md"); /* The Markdown editor in GitLab's manner (oxygenhub#193): a Preview tab, then icon buttons, fullscreen at the far end, and a footer that says Markdown is what it takes. Still a textarea underneath. */ if (mds.length) load("/static/vendor/easymde.min.js", "/static/vendor/easymde.min.css").then(function () { var E = window.EasyMDE; function svg(d) { return ''; } var I = { bold: svg(''), italic: svg(''), strike: svg(''), heading: svg(''), quote: svg(''), code: svg(''), link: svg(''), ul: svg(''), ol: svg(''), task: svg(''), table: svg(''), full: svg('') }; function b(name, action, title, icon, text) { return { name: name, action: action, title: title, icon: icon ? '' + icon + '' : undefined, text: text, className: "mde-btn" + (text ? " mde-text" : "") }; } function task(ed) { var cm = ed.codemirror, from = cm.getCursor("from"), to = cm.getCursor("to"); for (var l = from.line; l <= to.line; l++) { var line = cm.getLine(l), m = line.match(/^(\s*)[-*] \[[ xX]\] /); if (m) cm.replaceRange(m[1], { line: l, ch: 0 }, { line: l, ch: m[0].length }); else cm.replaceRange("- [ ] ", { line: l, ch: (line.match(/^\s*/) || [""])[0].length }); } cm.focus(); } var toolbar = [b("preview", E.togglePreview, "Preview", null, "Preview"), "|", b("bold", E.toggleBold, "Bold (Ctrl+B)", I.bold), b("italic", E.toggleItalic, "Italic (Ctrl+I)", I.italic), b("strikethrough", E.toggleStrikethrough, "Strikethrough", I.strike), b("heading", E.toggleHeadingSmaller, "Heading", I.heading), "|", b("quote", E.toggleBlockquote, "Quote", I.quote), b("code", E.toggleCodeBlock, "Code", I.code), b("link", E.drawLink, "Link (Ctrl+K)", I.link), "|", b("unordered-list", E.toggleUnorderedList, "Bullet list", I.ul), b("ordered-list", E.toggleOrderedList, "Numbered list", I.ol), b("task-list", task, "Task list", I.task), b("table", E.drawTable, "Table", I.table), "|", b("fullscreen", E.toggleFullScreen, "Fullscreen", I.full)]; mds.forEach(function (t) { t._mde = new E({ element: t, spellChecker: false, status: false, forceSync: true, autoDownloadFontAwesome: false, toolbar: toolbar, placeholder: t.getAttribute("placeholder") || "Write in Markdown…", minHeight: "120px", sideBySideFullscreen: false }); var c = t._mde.element.parentNode.querySelector(".EasyMDEContainer") || t.nextElementSibling; if (c && c.classList.contains("EasyMDEContainer")) { var f = document.createElement("div"); f.className = "mde-foot small mute"; f.innerHTML = 'Markdown supported'; c.appendChild(f); } }); /* An editor shown later (a :target edit form) lays itself out again. */ window.addEventListener("hashchange", function () { mds.forEach(function (t) { if (t._mde) setTimeout(function () { t._mde.codemirror.refresh(); }, 0); }); }); }); /* Mermaid diagrams (```mermaid blocks in Markdown): drawn by the hub's own copy of Mermaid, loaded only on pages that have one, in strict mode (no scripts or HTML from the diagram), in the page's theme. A diagram that does not parse stays as its source, with Mermaid's reason under it. */ var diagrams = document.querySelectorAll("pre > code.language-mermaid"); if (diagrams.length) load("/static/vendor/mermaid.min.js").then(function () { var m = window.mermaid, dark = document.documentElement.dataset.theme === "dark"; m.initialize({ startOnLoad: false, securityLevel: "strict", suppressErrorRendering: true, theme: dark ? "dark" : "default" }); diagrams.forEach(function (code, i) { var pre = code.parentNode, source = code.textContent; m.render("mermaid-" + i, source).then(function (r) { var fig = document.createElement("figure"); fig.className = "mermaid-diagram"; fig.innerHTML = r.svg; fig.setAttribute("aria-label", "Diagram"); pre.replaceWith(fig); }).catch(function (e) { var note = document.createElement("p"); note.className = "small warn"; note.textContent = "This diagram could not be drawn: " + ((e && e.message) || e); pre.after(note); }); }); }); /* Report frames (#127): a report's page says how tall it is, and the frame grows to fit; the frame is told the theme, at start and on a change. Only messages from the frame itself are taken. */ var frames = document.querySelectorAll("iframe.rep-frame"); var themeToFrames = function () { frames.forEach(function (f) { if (f.contentWindow) f.contentWindow.postMessage({ oxygenhubTheme: document.documentElement.dataset.theme || "dark" }, "*"); }); }; if (frames.length) window.addEventListener("message", function (e) { frames.forEach(function (f) { if (e.source !== f.contentWindow) return; var r = e.data && e.data.oxygenhubReport; if (!r) return; if (r.ready) themeToFrames(); if (typeof r.height === "number" && r.height > 0) { f.style.height = Math.min(Math.max(r.height, 120), 40000) + "px"; f.classList.add("sized"); } }); }); /* Report frames ask for runs (#127, RP4): report.run() is the run on screen, report.history(dataset) that file of data/ across the runs, oldest first. Answered only to the frame itself, from the files of this report's own runs. */ if (frames.length) window.addEventListener("message", function (e) { frames.forEach(function (f) { if (e.source !== f.contentWindow) return; var ask = e.data && e.data.oxygenhubAsk; if (!ask) return; var runs = []; try { runs = JSON.parse(f.dataset.reportRuns || "[]"); } catch (_) {} var current = Number(f.dataset.reportCurrent); var reply = function (body) { f.contentWindow.postMessage({ oxygenhubAnswer: { n: ask.n, value: body.value, error: body.error } }, "*"); }; var meta = function (r) { return { run: r.run, bookmark: r.bookmark, created_ms: r.created_ms, summary: r.summary, current: r.id === current }; }; if (ask.what === "run") { var r = runs.filter(function (x) { return x.id === current; })[0]; reply({ value: r ? meta(r) : null }); } else if (ask.what === "history" && typeof ask.dataset === "string" && /^[A-Za-z0-9._+-]+(\/[A-Za-z0-9._+-]+)*$/.test(ask.dataset)) { Promise.all(runs.map(function (r) { return fetch(r.files + "/data/" + ask.dataset).then(function (res) { return res.ok ? res.text() : null; }).catch(function () { return null; }) .then(function (text) { var m = meta(r); m.text = text; return m; }); })).then(function (all) { reply({ value: all.filter(function (x) { return x.text !== null; }) }); }); } else { reply({ error: "ask for run, or history with a file name under data/" }); } }); }); /* The report timeline (#127, RP4): arrow keys step through the runs, Play steps on by itself at the chosen speed, carried in the URL. */ var tl = document.querySelector("[data-timeline]"); if (tl) { var stops = Array.prototype.slice.call(tl.querySelectorAll("[data-tl-stop]")); var at = stops.findIndex(function (a) { return a.classList.contains("on"); }); var go = function (i, play) { if (i < 0 || i >= stops.length) return; var u = new URL(stops[i].href, location.href); if (play) u.searchParams.set("play", play); else u.searchParams.delete("play"); location.href = u.toString(); }; document.addEventListener("keydown", function (e) { if (e.target.closest("input, select, textarea, [contenteditable]")) return; if (e.key === "ArrowLeft") { e.preventDefault(); go(at - 1, tl.dataset.play); } if (e.key === "ArrowRight") { e.preventDefault(); go(at + 1, tl.dataset.play); } }); var speed = tl.querySelector("[data-tl-speed]"), button = tl.querySelector("[data-tl-play]"); if (button) button.addEventListener("click", function () { if (tl.dataset.play) { go(at, null); return; } go(at >= stops.length - 1 ? 0 : at + 1, speed ? speed.value : "2"); }); if (speed) speed.addEventListener("change", function () { if (tl.dataset.play) go(at, speed.value); }); if (tl.dataset.play) { var next = at + 1; if (next < stops.length) setTimeout(function () { go(next, tl.dataset.play); }, Number(tl.dataset.play) * 1000); else setTimeout(function () { go(at, null); }, Number(tl.dataset.play) * 1000); } } /* Theme switch in the header: dark, light, auto (follows the system); kept in the theme cookie. */ var toggle = document.querySelector("[data-theme-toggle]"); if (toggle) { var root = document.documentElement, order = ["light", "dark", "auto"], glyph = { dark: "◐", light: "☀", auto: "◑" }; var show = function () { var p = root.dataset.themePref || "light"; toggle.title = "Theme: " + p + " (click to change)"; toggle.setAttribute("aria-label", toggle.title); toggle.firstElementChild.textContent = glyph[p]; }; show(); toggle.addEventListener("click", function () { var p = order[(order.indexOf(root.dataset.themePref || "light") + 1) % order.length]; document.cookie = "theme=" + p + "; path=/; max-age=31536000; samesite=lax"; root.dataset.themePref = p; root.dataset.theme = p === "auto" ? (window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p; show(); themeToFrames(); }); } /* Diffs: unified or split (remembered in the diff cookie), collapse all. */ var diffs = document.querySelectorAll("table.code.diff"); if (diffs.length) { var pane = function (tr, col, side) { var n = document.createElement("td"), t = document.createElement("td"); n.className = "n"; t.className = "t" + (side === "right" ? " mid" : ""); if (!tr) { n.className += " none"; t.className += " none"; return [n, t]; } var kind = tr.classList.contains("add") ? "add" : tr.classList.contains("del") ? "del" : "ctx"; if (kind !== "ctx") { n.className += " " + kind; t.className += " " + kind; } n.textContent = tr.cells[col].textContent; t.innerHTML = tr.cells[3].innerHTML; // Line comments belong to the projected (right) side only. if (side === "left") t.querySelectorAll(".linecomment").forEach(function (d) { d.remove(); }); return [n, t]; }; var split = function (table) { var s = document.createElement("table"); s.className = "code diff split"; s.innerHTML = ''; var rows = [].slice.call(table.rows), i = 0; var add = function (l, r) { var tr = s.insertRow(); pane(l, 0, "left").concat(pane(r, 1, "right")).forEach(function (c) { tr.appendChild(c); }); }; while (i < rows.length) { var r = rows[i]; if (r.classList.contains("del") || r.classList.contains("add")) { var dels = [], adds = []; while (i < rows.length && rows[i].classList.contains("del")) dels.push(rows[i++]); while (i < rows.length && rows[i].classList.contains("add")) adds.push(rows[i++]); for (var k = 0; k < Math.max(dels.length, adds.length); k++) add(dels[k], adds[k]); continue; } if (r.classList.contains("gap") || r.querySelector("td.thread")) { var tr = s.insertRow(), td = tr.insertCell(); tr.className = r.className; td.colSpan = 4; td.className = r.cells[r.cells.length - 1].className; td.innerHTML = r.cells[r.cells.length - 1].innerHTML; } else { add(r, r); } i++; } return s; }; var cookieMode = (document.cookie.match(/(?:^|; )diff=(split|unified)/) || [])[1] || "unified"; var apply = function (mode) { diffs.forEach(function (t) { if (mode === "split" && !t._split) { t._split = split(t); t.parentNode.insertBefore(t._split, t.nextSibling); } t.hidden = mode === "split"; if (t._split) t._split.hidden = mode !== "split"; }); document.querySelectorAll("[data-diff-mode]").forEach(function (a) { a.classList.toggle("on", a.getAttribute("data-diff-mode") === mode); }); }; document.querySelectorAll("[data-diff-modes], [data-diff-collapse]").forEach(function (e) { e.hidden = false; }); document.querySelectorAll("[data-diff-mode]").forEach(function (a) { a.addEventListener("click", function (ev) { ev.preventDefault(); var mode = a.getAttribute("data-diff-mode"); document.cookie = "diff=" + mode + "; path=/; max-age=31536000; samesite=lax"; apply(mode); }); }); document.querySelectorAll("[data-diff-collapse]").forEach(function (a) { a.addEventListener("click", function (ev) { ev.preventDefault(); var files = document.querySelectorAll("details.file-diff"), anyOpen = [].some.call(files, function (d) { return d.open; }); files.forEach(function (d) { d.open = !anyOpen; }); a.textContent = anyOpen ? "expand all" : "collapse all"; }); }); apply(cookieMode); } /* Settings pages: mark the section in view in the list of sections. */ var navLinks = document.querySelectorAll(".section-nav a[href^='#']"); if (navLinks.length && "IntersectionObserver" in window) { var byId = {}; navLinks.forEach(function (a) { byId[a.getAttribute("href").slice(1)] = a; }); var io = new IntersectionObserver(function (entries) { entries.forEach(function (e) { if (e.isIntersecting && byId[e.target.id]) { navLinks.forEach(function (a) { a.classList.remove("on"); }); byId[e.target.id].classList.add("on"); } }); }, { rootMargin: "0px 0px -70% 0px" }); Object.keys(byId).forEach(function (id) { var el = document.getElementById(id); if (el) io.observe(el); }); } /* Copy buttons. A glyph button (#338) says "Copy to clipboard", then shows a tick and "Copied!" for two seconds; a worded one says so. */ document.querySelectorAll("[data-copy]").forEach(function (b) { var glyph = b.classList.contains("copy-btn"), timer; if (glyph) { b.setAttribute("data-tip", "Copy to clipboard"); b.title = ""; } b.addEventListener("click", function () { navigator.clipboard.writeText(b.getAttribute("data-copy")).then(function () { clearTimeout(timer); if (glyph) { b.classList.add("copied"); b.setAttribute("data-tip", "Copied!"); b.setAttribute("aria-label", "Copied!"); timer = setTimeout(function () { b.classList.remove("copied"); b.setAttribute("data-tip", "Copy to clipboard"); b.setAttribute("aria-label", "Copy to clipboard"); }, 2000); } else { var t = b.getAttribute("data-label") || b.textContent; b.setAttribute("data-label", t); b.textContent = "copied"; timer = setTimeout(function () { b.textContent = t; }, 1200); } }); }); }); /* Tree rows: the last change per entry, fetched after the page paints. */ var tree = document.querySelector("table.tree[data-last]"); if (tree) { var paths = [].map.call(tree.querySelectorAll("tr[data-path]"), function (r) { return r.getAttribute("data-path"); }); if (paths.length) { fetch(tree.getAttribute("data-last") + "?paths=" + encodeURIComponent(paths.join(",")), { credentials: "same-origin" }) .then(function (r) { return r.json(); }) .then(function (rows) { rows.forEach(function (c) { var tr = tree.querySelector('tr[data-path="' + CSS.escape(c.path) + '"]'); if (!tr) return; var a = tr.querySelector("td.last a"), t = tr.querySelector("td.age"); if (a) { a.textContent = c.title; a.href = tree.getAttribute("data-change") + c.hash; } if (t) { t.textContent = c.ago; t.title = c.when; } }); }).catch(function () {}); } } /* Line anchors: #L12 or #L12-L20 highlight a range and clicking a number selects. */ function mark() { var m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); document.querySelectorAll("tr.hl").forEach(function (r) { r.classList.remove("hl"); }); if (!m) { showBar(0, 0); return; } var a = +m[1], b = +(m[2] || m[1]); for (var i = Math.min(a, b); i <= Math.max(a, b); i++) { var row = document.getElementById("L" + i); if (row) row.classList.add("hl"); } var first = document.getElementById("L" + Math.min(a, b)); if (first && !mark.done) { first.scrollIntoView({ block: "center" }); mark.done = true; } showBar(Math.min(a, b), Math.max(a, b)); } /* The bar for a selected range: copy its text, or a permalink to it. */ var bar = document.querySelector(".linebar"); function showBar(a, b) { if (!bar) return; bar.hidden = !a; if (!a) return; bar.querySelector("[data-linebar-label]").textContent = a === b ? "Line " + a : "Lines " + a + "–" + b; bar.setAttribute("data-range", a + "-" + b); } function flash(btn) { var t = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = t; }, 1200); } if (bar) { bar.querySelector("[data-copy-lines]").addEventListener("click", function (ev) { var r = bar.getAttribute("data-range").split("-"), out = []; for (var i = +r[0]; i <= +r[1]; i++) { var row = document.getElementById("L" + i); if (row) out.push(row.querySelector("td.t").textContent); } navigator.clipboard.writeText(out.join("\n") + "\n").then(function () { flash(ev.target); }); }); var pl = bar.querySelector("[data-copy-permalink]"); if (pl) pl.addEventListener("click", function () { navigator.clipboard.writeText(new URL(document.body.getAttribute("data-permalink"), location.href).href + location.hash).then(function () { flash(pl); }); }); } function permalink() { var p = document.body.getAttribute("data-permalink"); return p ? p + location.hash : null; } window.addEventListener("hashchange", mark); mark(); document.querySelectorAll("table.code td.n").forEach(function (td) { td.addEventListener("click", function (e) { var n = td.parentNode.id; if (e.shiftKey && location.hash.match(/^#L(\d+)/)) location.hash = location.hash.replace(/^#(L\d+).*/, "$1-" + n); else location.hash = n; }); }); /* Flow run page: statuses and new log lines while the run is live. */ var liveUrl = document.body.getAttribute("data-flow-live"); if (liveUrl) { var tick = function () { var after = [].map.call(document.querySelectorAll("details[data-job]"), function (d) { return d.getAttribute("data-job") + ":" + d.querySelector("[data-log]").getAttribute("data-seq"); }).join(","); fetch(liveUrl + "?after=" + after, { headers: { accept: "application/json" } }) .then(function (r) { return r.json(); }) .then(function (s) { s.jobs.forEach(function (j) { var cls = "st-" + j.status; var node = document.querySelector("[data-job-node='" + j.id + "']"); if (node) { node.setAttribute("class", "node " + cls); node.querySelector("[data-glyph]").textContent = j.glyph; node.querySelector("[data-meta]").textContent = j.status + " · " + j.duration; } var d = document.querySelector("details[data-job='" + j.id + "']"); if (!d) return; d.className = "panel flow-job " + cls; var g = d.querySelector("summary [data-glyph]"); g.className = "flow-glyph " + cls; g.textContent = j.glyph; d.querySelector("summary [data-meta]").textContent = j.status + (j.exit_code !== null ? " · exit " + j.exit_code : "") + " · " + j.duration; if (j.status === "running" || j.status === "failed") d.open = true; // Once it has left the queue, it no longer waits (#282). if (j.status !== "queued") [].forEach.call(d.querySelectorAll("[data-queued-note]"), function (p) { p.remove(); }); var log = d.querySelector("[data-log]"); var stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 4; // Sections (oxygenflow#31): ::group::TITLE opens one, ::endgroup:: // (or the next ::group::) closes it; the open one takes the lines. var open = log.querySelector("details[data-open-group]"); var secs = function (ms) { return Math.floor(ms / 1000) + "." + Math.floor(ms % 1000 / 100) + "s"; }; var close = function (t) { if (!open) return; open.removeAttribute("data-open-group"); open.open = false; var took = document.createElement("span"); took.className = "took"; took.textContent = " " + secs(t - Number(open.getAttribute("data-from"))); open.querySelector("summary").appendChild(took); open = null; }; j.lines.forEach(function (l) { var tt = document.createElement("span"); tt.className = "t"; tt.textContent = secs(l.t); if (l.text.indexOf("::group::") === 0) { close(l.t); open = document.createElement("details"); open.className = "log-group"; open.open = true; open.setAttribute("data-open-group", ""); open.setAttribute("data-from", l.t); var sum = document.createElement("summary"); sum.className = "ln"; sum.appendChild(tt); sum.appendChild(document.createTextNode(l.text.slice(9).trim())); open.appendChild(sum); log.appendChild(open); } else if (l.text.trim() === "::endgroup::") { close(l.t); } else { var ln = document.createElement("span"); ln.className = "ln s-" + l.stream; ln.appendChild(tt); ln.appendChild(document.createTextNode(l.text)); var into = open || log; into.appendChild(ln); into.appendChild(document.createTextNode("\n")); } log.setAttribute("data-seq", l.seq); }); if (stick && j.lines.length) log.scrollTop = log.scrollHeight; }); var st = document.querySelector("[data-run-status]"); if (st) st.textContent = s.status; if (s.live) setTimeout(tick, 1500); else setTimeout(function () { location.reload(); }, 800); }) .catch(function () { setTimeout(tick, 5000); }); }; setTimeout(tick, 1500); } /* Keyboard: / focuses search; g c / g p / g i jump; ? shows the sheet. */ var pending = null; document.addEventListener("keydown", function (e) { if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return; if (e.key === "/") { var s = document.querySelector("input[name=q]"); if (s) { e.preventDefault(); s.focus(); } return; } if (e.key === "y" && !e.ctrlKey && !e.metaKey && !e.altKey && permalink()) { history.replaceState(null, "", permalink()); pending = null; return; } if (e.key === "?") { var k = document.getElementById("keys"); if (k) k.hidden = !k.hidden; return; } var repo = document.body.getAttribute("data-repo"); if (pending === "g" && repo) { var to = { c: "", p: "/proposals", i: "/issues", h: "/changes/" + (document.body.getAttribute("data-ref") || "main"), b: "/bookmarks", t: "/trust" }[e.key]; if (to !== undefined) location.href = repo + to; pending = null; return; } pending = e.key === "g" ? "g" : null; }); // Live conflict resolution (hub spec §6.10): who else is resolving this // conflict, their choices region by region (the last one wins), and the // resolution when someone commits it. var live = document.querySelector("[data-resolve-live]"); if (live && window.EventSource && window.fetch) { var url = live.getAttribute("data-resolve-live"), csrf = live.getAttribute("data-csrf"); var client = Math.random().toString(36).slice(2) + Date.now().toString(36); var present = document.querySelector("[data-live-present]"), landed = document.querySelector("[data-live-landed]"); var applying = false; var fieldsOf = function (region) { return region === "keep" ? { radios: live.querySelectorAll('input[name="keep"]'), text: null } : { radios: live.querySelectorAll('input[name="r' + region + '"]'), text: live.querySelector('textarea[name="t' + region + '"]') }; }; var apply = function (region, c) { var f = fieldsOf(region); applying = true; f.radios.forEach(function (r) { r.checked = r.value === c.choice; }); if (f.text && c.choice === "text") f.text.value = c.text; applying = false; var sec = f.radios.length ? f.radios[0].closest("section") : null; if (sec) sec.setAttribute("title", "chosen by " + c.by); }; var send = function (region, choice, text) { fetch(url, { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ _csrf: csrf, client: client, region: region, choice: choice, text: text || "" }) }); }; var timers = {}; live.addEventListener("change", function (ev) { var t = ev.target; if (applying || !t.name || t.type !== "radio") return; var region = t.name === "keep" ? "keep" : t.name.slice(1); var ta = live.querySelector('textarea[name="t' + region + '"]'); send(region, t.value, t.value === "text" && ta ? ta.value : ""); }); live.addEventListener("input", function (ev) { var t = ev.target; if (applying || t.tagName !== "TEXTAREA") return; var region = t.name.slice(1); live.querySelectorAll('input[name="r' + region + '"]').forEach(function (r) { r.checked = r.value === "text"; }); clearTimeout(timers[region]); timers[region] = setTimeout(function () { send(region, "text", t.value); }, 400); }); var es = new EventSource(url + "?client=" + encodeURIComponent(client)); var showPresent = function (names) { present.hidden = names.length < 2; present.textContent = "Resolving together: " + names.join(", ") + ". Choices are shared as you make them; the last choice for a conflict wins."; }; es.addEventListener("snapshot", function (e) { var d = JSON.parse(e.data); showPresent(d.present); Object.keys(d.choices).forEach(function (r) { apply(r, d.choices[r]); }); if (d.keep) apply("keep", d.keep); }); es.addEventListener("presence", function (e) { showPresent(JSON.parse(e.data).present); }); es.addEventListener("choice", function (e) { var d = JSON.parse(e.data); if (d.client !== client) apply(d.region, d.choice); }); es.addEventListener("landed", function (e) { var d = JSON.parse(e.data); es.close(); landed.hidden = false; landed.textContent = ""; landed.appendChild(document.createTextNode(d.by + " committed the resolution: ")); var a = document.createElement("a"); a.href = d.url; a.textContent = "see the change"; landed.appendChild(a); live.querySelectorAll("input, textarea, button").forEach(function (x) { x.disabled = true; }); }); } // Swimlanes of a proposal's bookmark (hub spec §6.7): drag a change onto // another to plan a squash or a rebase. The hub only writes the plan; // the author's client carries it out and signs it. var plan = document.querySelector("[data-plan]"); if (plan && window.fetch) { var panel = document.querySelector("[data-plan-panel]"), from = null, moved = false; plan.querySelectorAll("[data-change]").forEach(function (a) { a.style.cursor = "grab"; a.addEventListener("pointerdown", function (ev) { from = a.getAttribute("data-change"); moved = false; ev.preventDefault(); }); a.addEventListener("pointermove", function () { if (from) moved = true; }); a.addEventListener("click", function (ev) { if (moved) ev.preventDefault(); }); }); document.addEventListener("pointerup", function (ev) { var start = from; from = null; if (!start) return; var el = document.elementFromPoint(ev.clientX, ev.clientY), onto = el && el.closest ? el.closest("[data-change]") : null; if (!onto || onto.getAttribute("data-change") === start) return; var body = new URLSearchParams({ _csrf: plan.getAttribute("data-csrf"), change: start, target: onto.getAttribute("data-change") }); fetch(plan.getAttribute("data-plan"), { method: "POST", credentials: "same-origin", body: body }) .then(function (r) { return r.json(); }) .then(function (d) { panel.hidden = false; panel.textContent = ""; if (d.error) { panel.textContent = d.error; return; } d.options.forEach(function (o) { var p = document.createElement("p"); p.appendChild(document.createTextNode(o.summary + " ")); var a = document.createElement("a"); a.href = o.vscode; a.textContent = "Apply in VS Code"; p.appendChild(a); var code = document.createElement("pre"); code.className = "snippet"; code.textContent = o.command; panel.appendChild(p); panel.appendChild(code); }); }); }); } /* Sidebar menus (oxygenhub#82): type to filter a menu's items; a click outside an open menu closes it. */ document.querySelectorAll("[data-menu-filter]").forEach(function (input) { var items = input.closest(".menu-pop").querySelectorAll(".menu-item"); input.addEventListener("input", function () { var q = input.value.trim().toLowerCase(); items.forEach(function (it) { it.hidden = q !== "" && it.textContent.toLowerCase().indexOf(q) < 0; }); }); }); document.querySelectorAll("details.menu").forEach(function (d) { d.addEventListener("toggle", function () { var f = d.querySelector("[data-menu-filter]"); if (d.open && f) f.focus(); }); }); document.addEventListener("click", function (ev) { document.querySelectorAll("details.menu[open]").forEach(function (d) { if (!d.contains(ev.target)) d.open = false; }); }); /* ··· menus: copy a link to a part of the page, or some text. */ function copied(btn, text) { if (navigator.clipboard) navigator.clipboard.writeText(text).then(function () { var was = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = was; }, 1200); }); var d = btn.closest("details"); if (d) d.open = false; } document.querySelectorAll("[data-copy-link]").forEach(function (b) { b.addEventListener("click", function () { copied(b, location.origin + location.pathname + location.search + b.getAttribute("data-copy-link")); }); }); document.querySelectorAll("[data-copy-text]").forEach(function (b) { b.addEventListener("click", function () { copied(b, b.getAttribute("data-copy-text")); }); }); document.querySelectorAll('.menu-actions a[href^="#"]').forEach(function (a) { a.addEventListener("click", function () { var d = a.closest("details"); if (d) d.open = false; }); }); /* Settings pages: a form posts and the page comes back from the top. Remember which section's form was sent, and on the same page's next load scroll back to it, bringing the notice along. */ document.addEventListener("submit", function (e) { var sec = e.target.closest ? e.target.closest("section[id], form[id], details[id], div[id]") : null; if (!sec || !sec.id) return; try { sessionStorage.setItem("oxy-return", location.pathname + "#" + sec.id); } catch (err) { /* private mode */ } }); try { var back = sessionStorage.getItem("oxy-return"); if (back && back.indexOf(location.pathname + "#") === 0) { sessionStorage.removeItem("oxy-return"); var target = document.getElementById(back.split("#")[1]); if (target) { var flash = document.querySelector("main > .note, main > .error, .sections > .note, .sections > .error"); if (flash && !target.contains(flash)) target.parentNode.insertBefore(flash, target); target.scrollIntoView({ block: "start" }); } } } catch (err) { /* no storage: nothing to restore */ } /* Working (#142): on a repository whose store is remote, a land or a page can take seconds. A pressed button says what it is doing, with the CLI's spinner, and ignores a second press; a navigation that takes more than a moment spins the Oxygen mark's rings, in the header and beside the pointer. A quick page shows none of it. */ var SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; var waitTimer = 0, spinTimer = 0, pointer = null, mark = null; document.addEventListener("pointermove", function (e) { pointer = e.pointerType === "mouse" ? { x: e.clientX, y: e.clientY } : null; if (mark && pointer) place(); }, { passive: true }); function place() { if (pointer) { mark.style.left = pointer.x + 14 + "px"; mark.style.top = pointer.y + 14 + "px"; mark.classList.remove("corner"); } else mark.classList.add("corner"); } function waiting() { clearTimeout(waitTimer); waitTimer = setTimeout(function () { if (!mark) { var logo = document.querySelector("header .brand svg"); mark = document.createElement("div"); mark.className = "oxy-wait-mark"; mark.setAttribute("role", "status"); mark.setAttribute("aria-label", "Working"); if (logo) mark.appendChild(logo.cloneNode(true)); document.body.appendChild(mark); } place(); document.documentElement.classList.add("oxy-wait"); }, 300); } function busy(button, form) { if (!button) return; var label = button.getAttribute("data-busy") || button.textContent.trim(); button.dataset.idle = button.innerHTML; button.style.minWidth = button.offsetWidth + "px"; button.textContent = label + " "; var glyph = document.createElement("span"); glyph.className = "oxy-spin"; glyph.setAttribute("aria-hidden", "true"); glyph.textContent = SPIN[0]; button.appendChild(glyph); button.setAttribute("aria-busy", "true"); var i = 0; clearInterval(spinTimer); spinTimer = setInterval(function () { glyph.textContent = SPIN[++i % SPIN.length]; }, 80); /* Disabled only after the form's data is gathered, so the button's own name and value still go with it. */ setTimeout(function () { button.disabled = true; }, 0); form.dataset.sending = "1"; } document.addEventListener("submit", function (e) { var form = e.target; if (form.dataset.sending) { e.preventDefault(); return; } if (e.defaultPrevented || (form.target && form.target !== "_self")) return; if ((form.getAttribute("method") || "get").toLowerCase() === "post") { busy(e.submitter || form.querySelector("button[type=submit], button:not([type])"), form); } waiting(); }); document.addEventListener("click", function (e) { if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; var a = e.target.closest ? e.target.closest("a[href]") : null; if (!a || a.hasAttribute("download") || (a.target && a.target !== "_self")) return; var url = new URL(a.href, location.href); if (url.origin !== location.origin) return; if (url.pathname === location.pathname && url.search === location.search && url.hash) return; waiting(); }); /* Back to a page the browser kept: it is not working any more. */ window.addEventListener("pageshow", function (e) { if (!e.persisted) return; clearTimeout(waitTimer); clearInterval(spinTimer); document.documentElement.classList.remove("oxy-wait"); document.querySelectorAll("[aria-busy=true][data-idle]").forEach(function (b) { b.innerHTML = b.dataset.idle; b.disabled = false; b.removeAttribute("aria-busy"); b.style.minWidth = ""; if (b.form) delete b.form.dataset.sending; }); }); /* AI assistants (#109): a provider fills the name, endpoint and API; List models asks the endpoint what it offers and fills the model field's suggestions. Without this script, both are typed by hand. */ document.querySelectorAll("[data-preset-select]").forEach(function (sel) { var form = sel.form; function apply() { var o = sel.options[sel.selectedIndex]; if (!o || !o.value) return; var name = form.querySelector("[name=name]"), ep = form.querySelector("[name=endpoint]"), api = form.querySelector("[name=api]"); name.placeholder = o.dataset.name; ep.value = o.dataset.endpoint; api.value = o.dataset.api; } sel.addEventListener("change", apply); apply(); }); document.querySelectorAll("[data-list-models]").forEach(function (b) { b.addEventListener("click", function () { var form = b.form, input = document.getElementById(b.dataset.for); var list = document.getElementById(b.dataset.for + "-list"), note = document.querySelector("[data-models-note='" + b.dataset.for + "']"); var body = new URLSearchParams(); body.set("_csrf", b.dataset.csrf); ["endpoint", "api", "token", "preset"].forEach(function (k) { var el = form.querySelector("[name=" + k + "]"); if (el) body.set(k, el.value); }); if (b.dataset.id) body.set("id", b.dataset.id); if (note) note.textContent = "Asking the provider…"; fetch(b.dataset.listModels, { method: "POST", body: body, credentials: "same-origin" }) .then(function (r) { return r.json(); }) .then(function (j) { if (j.models) { list.innerHTML = ""; j.models.forEach(function (m) { var o = document.createElement("option"); o.value = m; list.appendChild(o); }); if (note) note.textContent = j.models.length + " models: pick one in the Model field."; input.focus(); } else if (note) { note.textContent = "No list: " + (j.error || "unknown") + ". Type the model's name."; } }) .catch(function () { if (note) note.textContent = "The hub could not ask; type the model's name."; }); }); }); })(); // Live pages (docs/live.md, oxygenhub#204): elements marked // data-live="" data-fragment="" are refreshed from this page's // own route when the hub says their topic changed (one fetch for all of // them; ?fragment= serves a single part to anyone else). Someone typing inside one is not interrupted: // it is refreshed when they leave it. Without JavaScript or a stream the // page is as it was; a reload shows everything. (function () { var marked = document.querySelectorAll("[data-live][data-fragment]"); if (!marked.length || !window.EventSource || !window.fetch) return; var topics = {}; // data-live may name several topics, separated by spaces. var topicsOf = function (el) { return el.getAttribute("data-live").split(/\s+/).filter(Boolean); }; Array.prototype.forEach.call(marked, function (el) { topicsOf(el).forEach(function (t) { topics[t] = true; }); }); var typingIn = function (el) { var a = document.activeElement; return a && el.contains(a) && (a.tagName === "TEXTAREA" || (a.tagName === "INPUT" && a.type !== "hidden")) && a.value; }; // Stale parts are gathered for a moment, then the page is fetched once // and each part swapped from it: one render however many parts and // events (a slow repository's page costs the server seconds each time). var stale = [], timer = null, fetching = false; var swapFrom = function (doc) { var todo = stale; stale = []; todo.forEach(function (el) { if (typingIn(el)) { el.addEventListener("focusout", function again() { el.removeEventListener("focusout", again); refresh(el); }); return; } var name = el.getAttribute("data-fragment"); var fresh = doc.querySelector('[data-fragment="' + name + '"]'); if (fresh) el.innerHTML = fresh.innerHTML; }); }; var flush = function () { timer = null; if (fetching || !stale.length) return; fetching = true; fetch(location.href, { credentials: "same-origin", headers: { "X-Fragment": "page" } }) .then(function (r) { return r.ok ? r.text() : null; }) .then(function (html) { fetching = false; if (html !== null) swapFrom(new DOMParser().parseFromString(html, "text/html")); if (stale.length) timer = setTimeout(flush, 300); }) .catch(function () { fetching = false; }); }; var refresh = function (el) { if (stale.indexOf(el) < 0) stale.push(el); if (!timer) timer = setTimeout(flush, 300); }; var es = new EventSource("/live?topics=" + encodeURIComponent(Object.keys(topics).join(","))); es.addEventListener("change", function (e) { var d; try { d = JSON.parse(e.data); } catch (err) { return; } Array.prototype.forEach.call(marked, function (el) { if (topicsOf(el).indexOf(d.topic) >= 0) refresh(el); }); }); // Missed more than the hub keeps: everything marked is fetched again. es.addEventListener("reload", function () { Array.prototype.forEach.call(marked, refresh); }); })(); /* Insights (oxygenhub#257): a part marked data-refresh="SECONDS" data-fragment="NAME" is fetched again on that interval while the page is visible, and swapped in place; never while someone is using a control in it. Without this file the page is a snapshot; a reload updates it. */ (function () { "use strict"; var parts = document.querySelectorAll("[data-refresh][data-fragment]"); if (!parts.length || !window.fetch || !window.DOMParser) return; var every = Math.max(10, parseInt(parts[0].getAttribute("data-refresh"), 10) || 30) * 1000; var busy = false; var inUse = function (el) { var a = document.activeElement; return a && el.contains(a) && /^(SELECT|INPUT|TEXTAREA)$/.test(a.tagName); }; var tick = function () { if (busy || document.hidden) return; busy = true; fetch(location.href, { credentials: "same-origin", headers: { "X-Fragment": "page" } }) .then(function (r) { return r.ok ? r.text() : null; }) .then(function (html) { busy = false; if (html === null) return; var doc = new DOMParser().parseFromString(html, "text/html"); Array.prototype.forEach.call(parts, function (el) { if (inUse(el)) return; var fresh = doc.querySelector('[data-fragment="' + el.getAttribute("data-fragment") + '"]'); if (fresh) el.innerHTML = fresh.innerHTML; }); }) .catch(function () { busy = false; }); }; setInterval(tick, every); document.addEventListener("visibilitychange", function () { if (!document.hidden) tick(); }); })(); /* Drafting with an assistant (oxygenhub#248): the draft is streamed into the description as it is written, so a long one is neither cut off by a proxy nor waited for in silence. Without this file the button posts the form and the page comes back with the draft. */ (function () { "use strict"; if (!window.fetch || !window.TextDecoder || !window.URLSearchParams) return; document.querySelectorAll("button.agent-btn[data-stream]").forEach(function (btn) { btn.addEventListener("click", function (e) { var form = btn.form, area = form && form.querySelector('textarea[name="body"]'); if (!form || !area) return; e.preventDefault(); function get() { return area._mde ? area._mde.value() : area.value; } function show(t) { if (area._mde) area._mde.value(t); else area.value = t; } // A new proposal's draft begins with its title line (#316): kept out // of the description, and put in the title unless the person wrote one. var titleField = form.querySelector('input[name="title"]'); function untitled(t) { return t.replace(/^\s*title:[^\n]*(\n\s*|$)/i, ""); } var before = get(), text = ""; var box = (area._mde && area.parentNode.querySelector(".EasyMDEContainer")) || area; var note = form.querySelector(".draft-note"); if (!note) { note = document.createElement("p"); note.className = "draft-note small mute"; box.parentNode.insertBefore(note, box.nextSibling); } note.textContent = btn.value + " is drafting the description…"; var buttons = form.querySelectorAll("button.agent-btn"); buttons.forEach(function (b) { b.disabled = true; }); var data = new URLSearchParams(new FormData(form)); data.set("assistant", btn.value); var finished = false; function end(message, keep) { finished = true; if (!keep) show(before); note.textContent = message; } fetch(btn.dataset.stream, { method: "POST", body: data, credentials: "same-origin" }) .then(function (r) { if (!r.ok || !r.body) throw new Error("the hub answered " + r.status); var reader = r.body.getReader(), dec = new TextDecoder(), buf = ""; function pump() { return reader.read().then(function (res) { if (res.done) { if (!finished) end(btn.value + " stopped before finishing.", text !== ""); return; } buf += dec.decode(res.value, { stream: true }); var i; while ((i = buf.indexOf("\n\n")) >= 0) { var block = buf.slice(0, i), kind = "message", payload = ""; buf = buf.slice(i + 2); block.split("\n").forEach(function (l) { if (l.indexOf("event:") === 0) kind = l.slice(6).trim(); else if (l.indexOf("data:") === 0) payload += l.slice(5).trim(); }); if (!payload) continue; var v; try { v = JSON.parse(payload); } catch (_) { continue; } if (kind === "part") { text += v; show(untitled(text)); } else if (kind === "title") { if (titleField && (!titleField.value.trim() || titleField.value === titleField.defaultValue)) titleField.value = v; } else if (kind === "done") { show(v); end("Drafted by " + btn.value + "; nothing is saved until you save the proposal.", true); return; } else if (kind === "error") { end(v, false); return; } } return pump(); }); } return pump(); }) .catch(function (err) { end(btn.value + " did not answer: " + err.message + ".", false); }) .then(function () { buttons.forEach(function (b) { b.disabled = false; }); }); }); }); /* Declared in data- attributes, so pages carry no inline handlers and the Content-Security-Policy can be enforced (oxygenhub#227): data-confirm asks before a click or a form's submit goes ahead; data-autosubmit sends a select's form when it changes; data-select selects a field's text when it is focused or clicked. */ document.addEventListener("click", function (e) { var el = e.target.closest && e.target.closest("[data-confirm]"); if (el && el.tagName !== "FORM" && !window.confirm(el.getAttribute("data-confirm"))) { e.preventDefault(); e.stopImmediatePropagation(); } }, true); document.addEventListener("submit", function (e) { var f = e.target; if (f.hasAttribute && f.hasAttribute("data-confirm") && !window.confirm(f.getAttribute("data-confirm"))) e.preventDefault(); }, true); document.addEventListener("change", function (e) { var el = e.target; if (el.hasAttribute && el.hasAttribute("data-autosubmit") && el.form) el.form.submit(); }); function selectAll(e) { var el = e.target; if (el.hasAttribute && el.hasAttribute("data-select") && el.select) el.select(); } document.addEventListener("focusin", selectAll); document.addEventListener("click", selectAll); /* Mail panel (oxygenhub#181): show the fields the chosen service asks. */ (function () { var sel = document.querySelector("[data-mail-service]"); if (!sel) return; var show = function () { document.querySelectorAll(".mail-panel [data-for]").forEach(function (el) { el.hidden = el.getAttribute("data-for").split(" ").indexOf(sel.value) < 0; }); }; sel.addEventListener("change", show); show(); })(); /* Drops (oxygenhub#279): add and remove file rows on the drop form. Without this file the form has its rows and one empty one; more come by saving and editing again. */ (function () { var list = document.querySelector("[data-drop-files]"); var add = document.querySelector("[data-drop-add]"); if (!list || !add) return; function wire(row) { var rm = row.querySelector("[data-drop-remove]"); if (!rm) return; rm.hidden = false; rm.addEventListener("click", function () { if (list.querySelectorAll("[data-drop-file]").length > 1) row.remove(); else row.querySelectorAll("input, textarea").forEach(function (f) { f.value = ""; }); }); } list.querySelectorAll("[data-drop-file]").forEach(wire); add.hidden = false; add.addEventListener("click", function () { var rows = list.querySelectorAll("[data-drop-file]"); var row = rows[rows.length - 1].cloneNode(true); row.querySelectorAll("input, textarea").forEach(function (f) { f.value = ""; f.textContent = ""; }); list.appendChild(row); wire(row); row.querySelector("input").focus(); }); })(); /* A repository's settings were one page with a section per #anchor; each is a page of its own now (oxygenhub#278). A kept link such as /settings#flow-priority opens the page that holds that section, when the viewer has it (its link is in the sidebar). */ (function () { var nav = document.querySelector("[data-settings-anchors]"); var hash = location.hash.slice(1); if (!nav || !/^[a-z-]+$/.test(hash)) return; var moved = { "history-scan": "push-protection", "flow-priority": "runs", "flow-supersede": "runs", "agent-gates": "gates" }; var link = nav.querySelector('a[data-slug="' + (moved[hash] || hash) + '"]'); if (link && link.getAttribute("aria-current") !== "page") location.replace(link.getAttribute("href") + "#" + hash); })(); /* Waiting for a phone's approval (oxygenhub#356): at sign-in the page posts its proof of the password and signs in once the phone approves; at the sudo step it asks with the session and goes back where it came from. A no, or too long a wait, says so and stops. */ (function () { var box = document.querySelector("[data-approval-poll]"); if (!box) return; var status = box.querySelector("[data-approval-status]"); var form = box.closest("form"); var url = box.getAttribute("data-approval-poll"); var said = { denied: "Your phone said no, or a different number was picked. Sign in again, or use a code.", expired: "That took too long. Start again, or use a code.", unknown: "That approval is gone. Start again." }; var tries = 0; function ask() { var req; if (form) { var body = new URLSearchParams(); ["username", "pending", "approval"].forEach(function (n) { var el = form.querySelector('input[name="' + n + '"]'); if (el) body.set(n, el.value); }); req = fetch(url, { method: "POST", credentials: "same-origin", body: body }); } else { req = fetch(url, { credentials: "same-origin", headers: { accept: "application/json" } }); } req.then(function (r) { return r.json(); }).then(function (j) { var state = j && j.state; box.setAttribute("data-state", state || ""); if (state === "approved") { if (status) status.textContent = "Approved. Carrying on…"; if (form) form.submit(); else location.href = box.getAttribute("data-approval-next") || "/"; } else if (said[state] || state === "used") { if (status) status.textContent = said[state] || said.unknown; } else if (++tries < 75) { setTimeout(ask, 2000); } }).catch(function () { if (++tries < 75) setTimeout(ask, 4000); }); } setTimeout(ask, 1500); })(); })();